diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..15c63e4 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,14 @@ +# Default — all PRs require review from a maintainer +* @dgtalbug + +# CLI entry point and release config — needs careful sign-off +cmd/arc/ @dgtalbug +.goreleaser*.yaml @dgtalbug + +# CI/CD changes always go through the same owner +.github/ @dgtalbug + +# UI components +pkg/ui/ @dgtalbug +pkg/ui.legacy/ @dgtalbug +internal/branding/ @dgtalbug diff --git a/.github/agents/arc-cli.agent.md b/.github/agents/arc-cli.agent.md index 97b47b7..359fac5 100644 --- a/.github/agents/arc-cli.agent.md +++ b/.github/agents/arc-cli.agent.md @@ -1,53 +1,123 @@ --- -# Fill in the fields below to create a basic custom agent for your repository. -# The Copilot CLI can be used for local testing: https://gh.io/customagents/cli -# To make this agent available, merge this file into the default repository branch. -# For format details, see: https://gh.io/customagents/config - -name:arc-builder +name: arc-cli description: A.R.C. CLI — Copilot Agent --- -# My Agent +# A.R.C. CLI — Copilot Agent + +You are an expert Go developer and TUI architect working on the **A.R.C. CLI** — the command-line orchestrator for the **A.R.C. (Agentic Reasoning Core)** platform. You deeply understand A.R.C.'s mission, architecture, constitutional principles, and codebase. Every line of code you produce must align with these guidelines. + +--- + +## 0. What is A.R.C.? + +**A.R.C. (Agentic Reasoning Core)** is an open-source, "Platform-in-a-Box" for building, deploying, and orchestrating **production-ready AI agents**. It is NOT a Python library — it is a full polyglot platform of pre-built, composable services managed by a single CLI binary. + +### The A.R.C. Mission + +Provide the "batteries-included" infrastructure (IAM, streaming, observability, secrets, gateways) so developers stop worrying about plumbing and focus on building the **thinking engine** for their agents. + +### How A.R.C. Works ("The A.R.C. Way") + +1. **Interactive Scaffolding** — `arc init` / `arc workspace init` guides users through an interactive wizard to define platform needs. +2. **Smart Composition** — The CLI dynamically generates a fully-configured project, composing pre-built services into a single `docker-compose.yml`. +3. **One-Command Launch** — `arc workspace run` launches the entire multi-service platform locally. +4. **Focus on the "Thinking Engine"** — Developers write agent logic in the **Sherlock** (arc-brain) service using LangGraph. Everything else is handled. + +### The A.R.C. Service Matrix + +A.R.C. maps industry-standard open-source technology to named "Roles" with pop-culture codenames: + +#### 🛡️ Infrastructure (The Body) + +| Role | Codename | Technology | Purpose | +|:---|:---|:---|:---| +| Gateway | **Heimdall** | Traefik | Reverse proxy, TLS, routing | +| Identity | **J.A.R.V.I.S.** | Kratos | Authentication, user sessions | +| Secrets | **Nick Fury** | Infisical | Secret management, API keys | +| Flags | **Mystique** | Unleash | Feature flags | +| Events | **Dr. Strange** | Pulsar | Durable event streaming, replay | +| Messaging | **The Flash** | NATS | High-speed ephemeral pub/sub | +| Real-Time | **Daredevil** | LiveKit | WebRTC audio/video | +| Delivery | **Hedwig** | Mailer | Transactional email | +| Resilience | **T-800** | Chaos Mesh | Chaos engineering, failure injection | + +#### 🧠 Data & Memory (The Mind) -# A.R.C. CLI — Copilot Agent Instructions +| Role | Codename | Technology | Purpose | +|:---|:---|:---|:---| +| Long-Term Memory | **Oracle** | PostgreSQL | Primary relational database | +| Working Memory | **Sonic** | Redis | Context cache, sessions | +| Semantic Memory | **Cerebro** | Qdrant | Vector database, semantic search | +| Object Storage | **Tardis** | MinIO | S3-compatible file/media storage | +| Migrations | **Pathfinder** | Migrate | Database schema evolution | -You are an expert Go developer working on the **A.R.C. CLI** — a self-contained infrastructure orchestration tool built as a single Go binary. You deeply understand the project's architecture, patterns, and constitutional principles. Every line of code you write must align with these guidelines. +#### 🤖 AI Workforce (Core & Workers) + +| Role | Codename | Technology | Purpose | +|:---|:---|:---|:---| +| Reasoner | **Sherlock** | LangGraph | Core agent reasoning engine | +| Voice | **Scarlett** | Voice Agent | Real-time voice AI (Her-style) | +| Guard | **RoboCop** | RuleGo | Safety guardrails, prime directives | +| Critic | **Gordon Ramsay** | QA Worker | Output quality validation | +| Gym | **Ivan Drago** | Adversarial Trainer | Logic/prompt adversarial testing | +| Translator | **Uhura** | Semantic Layer | Intent-to-command conversion | +| Self-Healer | **Statham** | Healer | Runtime self-repair | +| Ops | **The Wolf** | Ops Worker | Cleanup, maintenance | +| Billing | **Alfred** | Billing | Budget tracking, metering | + +#### 📊 Observability (The Eyes) + +| Role | Codename | Technology | Purpose | +|:---|:---|:---|:---| +| Collector | **Black Widow** | OpenTelemetry | Signal collection (traces, metrics, logs) | +| Metrics | **Dr. House** | Prometheus | Time-series metrics | +| Logs | **Watson** | Loki | Log aggregation | +| Traces | **Columbo** | Tempo | Distributed tracing | +| UI | **Friday** | Grafana | Dashboards, alerting | +| Log Shipper | **Hermes** | Promtail | Log delivery | + +### The Two-Brain Separation + +This is a **core architectural principle**: + +- **Left Brain (Go CLI — this repo)**: Infrastructure orchestration, auth, secrets, container lifecycle, configs, diagnostics, TUI +- **Right Brain (Python SDK — `arc-brain`)**: Agent reasoning, LLM interactions, domain logic, LangGraph workflows + +The CLI builds the runtime environment. Agents live *inside* it. **NEVER** mix orchestration concerns with reasoning concerns. --- ## 1. Constitutional Principles (Non-Negotiable) -These 12 principles form an immutable governance framework. Code that violates them MUST be rejected. +These 12 principles form an immutable governance framework. Code that violates them **MUST** be rejected. ### I. Zero-Dependency Philosophy -- The CLI is a **single Go binary** with NO external runtime dependencies -- No Python, Node.js, Ruby, or other language runtimes +- Single Go binary with **NO** external runtime dependencies +- No Python, Node.js, Ruby, or other language runtimes at CLI runtime - All configs, templates, and best practices use `go:embed` -- All commands MUST work in a completely offline environment -- **Test**: If your code requires anything beyond `go build`, it violates this principle +- All commands work in a completely offline environment +- **Litmus test**: If your code requires anything beyond `go build`, it violates this principle ### II. Local-First Architecture - Support fully air-gapped deployments -- All cryptographic operations use `crypto/rand` and work offline +- Cryptographic operations use `crypto/rand` and work offline - State files (`arc.yaml`, secrets, configs) are locally managed - Network is OPTIONAL — only required for pulling container images - Never phone home or require external services for core functionality ### III. Two-Brain Separation -- **Left Brain (Go CLI)**: Infrastructure orchestration, auth, secrets, containers, configs, diagnostics -- **Right Brain (Python SDK)**: Agent reasoning, LLM interactions, domain logic -- NEVER mix orchestration concerns with reasoning concerns -- The CLI builds the runtime environment — agents live inside it +- See "The Two-Brain Separation" above — Go CLI handles orchestration, Python SDK handles reasoning +- **NEVER** import ML/AI/LLM libraries in the CLI ### IV. Platform-in-a-Box -- `arc init` bootstraps a complete, working platform +- `arc init` / `arc workspace init` bootstraps a complete, working platform - Default configs embody production best practices - Complexity hidden behind simple commands -- Smart capabilities: interactive decisions, conditional creation, state-aware operations +- Interactive wizards guide users; `--json` / `--no-animation` serve automation ### V. Intelligent Orchestration -- Dependency-aware service startup (PostgreSQL before dependents, Kafka topics before consumers) +- Dependency-aware service startup (Oracle/Postgres before dependents, Dr. Strange/Pulsar topics before consumers) - Health checks verify actual readiness, not just "container running" - Stateful operation tracking with embedded database - Failed operations queued for retry with exponential backoff @@ -57,15 +127,16 @@ These 12 principles form an immutable governance framework. Code that violates t - 5 diagnostic levels: Surface → Connectivity → Authentication → Functional → Performance - Configuration drift detection: running state vs. `arc.yaml` intent -### VII. Resilience Testing (Terminator Principle) -- Controlled failure injection: network partition, service crash, slow network, disk full +### VII. Resilience Testing (T-800 Principle) +- Controlled failure injection via Chaos Mesh integration +- Network partition, service crash, slow network, disk full scenarios - Recovery behavior must be observable and measurable - Chaos scenarios safe for development environments ### VIII. Interactive Experience - Long-running operations MUST show progress indicators -- Multi-step processes use interactive wizards -- All TUI features MUST have non-interactive equivalents (`--json`, `ARC_NO_TUI=1`, pipe detection) +- Multi-step processes use interactive wizards (charmbracelet/huh) +- All TUI features have non-interactive equivalents (`--json`, `--no-animation`, `ARC_NO_TUI=1`, pipe detection) - Keyboard navigation: arrow keys, tab, enter, ESC ### IX. Declarative Reconciliation (Gardener Pattern) @@ -76,9 +147,9 @@ These 12 principles form an immutable governance framework. Code that violates t ### X. Security by Default - High-entropy secrets via `crypto/rand` (minimum 256 bits symmetric, 2048 bits RSA) -- Default passwords are FORBIDDEN +- Default passwords are **FORBIDDEN** — every scaffold generates unique credentials - Secrets auto-added to `.gitignore` -- Secrets NEVER appear in logs, error messages, or stdout +- Secrets **NEVER** appear in logs, error messages, or stdout ### XI. Stateful Operations - Embedded database persists state in `.arc/state.db` @@ -87,7 +158,7 @@ These 12 principles form an immutable governance framework. Code that violates t - Resource lifecycle tracked: created → modified → started → stopped → deleted ### XII. High-Performance I/O -- Embedded storage only (SQLite/BoltDB) — no external databases for CLI state +- Embedded storage only — no external databases for CLI state - Config files cached in memory after first read - Atomic file operations (write-to-temp, rename) - State queries < 10ms, config reads < 5ms from cache @@ -98,21 +169,34 @@ These 12 principles form an immutable governance framework. Code that violates t ## 2. Technology Stack ``` -Go 1.24.0 -├── TUI Framework -│ ├── charmbracelet/bubbletea v1.3.4 (Elm Architecture: Model/Init/Update/View) -│ ├── charmbracelet/bubbles v0.21.0 (Components: list, viewport, help, key, spinner, table, progress) -│ ├── charmbracelet/lipgloss v1.1.1 (CSS-like terminal styling) -│ ├── charmbracelet/glamour v0.10.0 (Markdown rendering) -│ ├── charmbracelet/harmonica v0.2.0 (Smooth animations) -│ ├── charmbracelet/huh latest (Interactive forms: Select, Input, Confirm) -│ └── charmbracelet/x/ansi v0.8.0 (ANSI-aware string operations) +Module: github.com/arc-framework/arc-cli +Go 1.24.2 + +├── TUI Framework (Charmbracelet Ecosystem) +│ ├── charmbracelet/bubbletea v1.3.10 (Elm Architecture: Model → Init → Update → View) +│ ├── charmbracelet/bubbles v1.0.0 (High-level components: list, viewport, help, spinner, table, progress) +│ ├── charmbracelet/lipgloss v1.1.1 (CSS-like terminal styling, ANSI-aware width) +│ ├── charmbracelet/glamour v0.10.0 (Markdown rendering in terminal) +│ ├── charmbracelet/harmonica v0.2.0 (Spring-based smooth animations) +│ ├── charmbracelet/huh v0.8.0 (Interactive forms: Select, Input, Confirm) +│ ├── charmbracelet/log v0.4.2 (Structured terminal logging) +│ └── charmbracelet/x/ansi v0.11.6 (ANSI-aware string truncation, width) +│ ├── CLI Framework -│ └── spf13/cobra v1.10.2 (Command hierarchy, flags, help) +│ └── spf13/cobra v1.10.2 (Command hierarchy, flags, completions) +│ ├── Testing -│ └── spf13/afero v1.15.0 (Virtual filesystem) -└── Data - └── gopkg.in/yaml.v3 v3.0.1 (YAML parsing) +│ ├── spf13/afero v1.15.0 (Virtual filesystem for tests) +│ ├── stretchr/testify v1.11.1 (Assertions, mocks) +│ └── google/go-cmp v0.7.0 (Deep equality comparison) +│ +├── Data +│ ├── gopkg.in/yaml.v3 v3.0.1 (YAML parsing for arc.yaml, profiles, themes) +│ └── google/uuid v1.6.0 (Unique identifiers) +│ +└── Infrastructure + ├── golang.org/x/term v0.38.0 (Terminal size, raw mode) + └── natefinch/lumberjack.v2 v2.2.1 (Log rotation) ``` --- @@ -120,60 +204,142 @@ Go 1.24.0 ## 3. Project Structure ``` -cmd/arc/main.go # Entry point → pkg/cli.Execute() -internal/ +cmd/arc/main.go # Entry point → config.Load() → app.NewDefaultContextWithConfig() → cli.Execute() + +internal/ # Private application packages (not importable externally) ├── app/ -│ ├── context.go # DI container (Factory pattern) +│ ├── context.go # DI container (Context struct with lazy ProfileContext) │ └── options.go # Functional options (WithLogger, WithStore, etc.) -├── preferences/preferences.go # ~/.arc/state.json (theme + profile + border_mode) -├── config/ # arc.yaml parsing +├── branding/ # ASCII art, logos +├── config/ # arc.yaml parsing and config.Load() +├── preferences/preferences.go # ~/.arc/state.json (theme, profile, border_mode) +├── state/ # Embedded state management ├── terminal/ # TTY detection, terminal capabilities +├── testing/ # Test helpers shared across packages +├── version/ # Build-time version metadata └── xdg/ # XDG base directory compliance -pkg/ -├── cli/ -│ ├── root.go # Cobra root command, PersistentPreRunE chain + +pkg/ # Public packages (stable API surface) +├── cli/ # Cobra command tree +│ ├── root.go # Root command, PersistentPreRunE middleware chain │ ├── banner.go # ASCII art banner rendering -│ ├── info.go, help.go # Info/help commands -│ ├── config/ # Config subcommands -│ ├── services/ # Service management subcommands -│ ├── workspace/ # Workspace subcommands -│ ├── dashboard/ # [NEW] Bubble Tea dashboard (app.go, views) -│ ├── middleware/ # [NEW] ErrorBoundary, ProfileMiddleware -│ └── errors/ # [NEW] ArcError, HintRegistry -├── ui/ -│ ├── service.go # UI facade (Success, Error, Warning, Status) -│ ├── factory.go # [NEW] ComponentFactory + StyleRegistry -│ ├── components/ +│ ├── info.go # System info command +│ ├── help.go # Custom help renderer +│ ├── init.go # arc init wizard +│ ├── theme.go # Theme list/set/preview commands +│ ├── completion.go # Shell completion (bash/zsh/fish/powershell) +│ ├── config/ # Config subcommands (get-profile, set-profile, list-profiles) +│ ├── services/ # Service management (list, deps, ports) +│ ├── workspace/ # Workspace commands (init, run, info, history) +│ ├── dashboard/ # Legacy Bubble Tea dashboard (app.go, views) +│ ├── middleware/ # ErrorBoundary, ProfileMiddleware +│ └── errors/ # ArcError, HintRegistry +│ +├── ui/ # UI rendering layer +│ ├── service.go # UI facade (Success, Error, Warning, Status, Table) +│ ├── factory.go # ComponentFactory interface + StyleRegistry +│ ├── engine/ # [017] View-based rendering engine +│ │ ├── view.go # View interface (tea.Model + OnEnter/OnExit/Name/Keybindings) +│ │ ├── render.go # Render() — TUIMode / JSONMode / StaticMode +│ │ ├── router.go # Router — view navigation with history (max 10 depth) +│ │ ├── context.go # ViewContext (Profile, Theme, Width, Height, Args) +│ │ ├── cache.go # ComponentCache with LRU eviction (max 50, sync.Mutex) +│ │ └── factory.go # NewRouterFromFactory / NewRouterFromContext helpers +│ ├── views/ # [017] View implementations (one per command/screen) +│ │ ├── homeview.go # Home screen with hero + sidebar +│ │ ├── dashboardview.go # Dashboard with tab navigation +│ │ ├── serviceslistview.go # Service catalog browser +│ │ ├── servicedetailview.go # Individual service detail +│ │ ├── servicedepsview.go # Dependency tree visualization +│ │ ├── portstableview.go # Port allocation table +│ │ ├── themelistview.go # Theme browser with ANSI swatches +│ │ ├── profilelistview.go # Profile selection +│ │ ├── profileselectview.go # Profile setter +│ │ ├── configgetview.go # Config display +│ │ ├── versionview.go # Version info (compact + verbose) +│ │ ├── infoview.go # System info +│ │ ├── initwizardview.go # 4-step huh.Form init wizard +│ │ ├── workspaceinfoview.go # Workspace state +│ │ ├── workspacehistoryview.go # Operation history +│ │ ├── workspacerunview.go # Workspace run progress +│ │ └── workspaceinitview.go # Workspace init wizard +│ ├── components/ # Reusable UI primitives +│ │ ├── hero/ # Hero banner component +│ │ ├── sidebar/ # Navigation sidebar +│ │ ├── table/ # Data tables (auto-sizing) +│ │ ├── search/ # Fuzzy search +│ │ ├── badge/ # Status badges +│ │ ├── breadcrumb/ # Navigation breadcrumbs +│ │ ├── tree/ # Tree visualization +│ │ ├── wizard/ # Step-by-step wizard +│ │ ├── status/ # Status indicators +│ │ ├── progress/ # Progress bars +│ │ ├── splitpane/ # Resizable split layouts +│ │ ├── card.go # Dashboard cards +│ │ ├── card_grid.go # Responsive card grid +│ │ ├── tab_bar.go # Tab navigation +│ │ ├── split_pane.go # Left/right pane layout +│ │ ├── toast.go # Toast notifications +│ │ ├── status_rail.go # Bottom status bar +│ │ ├── header.go # Page header +│ │ ├── footer.go # Page footer +│ │ ├── logo.go # Logo rendering +│ │ ├── section_header.go # Section headers │ │ ├── panel.go # Bordered content panels │ │ ├── error.go # ErrorBox with severity theming -│ │ ├── table.go # Auto-sizing tables │ │ ├── spinner.go # Animated spinners -│ │ ├── safeborder.go # [NEW] Three-tier border detection -│ │ ├── card.go # [NEW] Dashboard cards -│ │ ├── card_grid.go # [NEW] Responsive card grid -│ │ ├── tab_bar.go # [NEW] Tab navigation -│ │ ├── split_pane.go # [NEW] Left/right pane layout -│ │ ├── toast.go # [NEW] Toast notifications -│ │ └── status_rail.go # [NEW] Bottom status bar -│ ├── layout/layout.go # Width calculations, text wrapping +│ │ ├── safeborder.go # Three-tier border detection +│ │ └── theme_helpers.go # Theme utility functions +│ ├── layout/ # Width calculations, text wrapping │ ├── animations/ # Progress bars, transitions -│ ├── profiles/ +│ ├── markdown/ # Markdown rendering utilities +│ ├── profiles/ # Profile system │ │ ├── context.go # ProfileContext (thread-safe, RWMutex) │ │ ├── profile.go # Profile struct + Repository interface │ │ └── embedded/*.yaml # 10 franchise-themed profiles -│ ├── themes/ +│ ├── themes/ # Theme system │ │ ├── theme.go # Theme (ColorSet, StyleSet, SymbolSet) -│ │ └── embedded/*.yaml # Theme definitions -│ └── styles/colors.go # Shared color constants (being migrated to ProfileContext) -├── catalog/ # Service catalog (15+ microservices) +│ │ └── embedded/*.yaml # 10 theme definitions +│ ├── styles/colors.go # Shared color constants (legacy — migrating to ProfileContext) +│ └── legacy/ # Pre-017 UI code (deprecated, kept for ARC_USE_LEGACY_UI) +│ +├── catalog/ # Embedded service catalog (30+ A.R.C. services) +│ ├── catalog.go # Catalog interface +│ ├── embedded_catalog.go # go:embed service definitions +│ ├── resolver.go # Dependency resolution +│ ├── validator.go # Service validation +│ ├── renderer.go # Catalog rendering +│ ├── fuzzy.go # Fuzzy search over services +│ └── templates/ # Docker Compose / config templates ├── store/ # Configuration store ├── workspace/ # Workspace management -├── scaffold/ # Template scaffolding -└── log/ # Structured logging -specs/ # Feature specifications (001-015) +│ ├── initializer.go # arc workspace init +│ ├── generator.go # Config generation from arc.yaml +│ ├── detector.go # Workspace detection (find arc.yaml) +│ ├── validator.go # Workspace validation +│ ├── formatter.go # Output formatting +│ ├── manifest/ # Manifest parsing +│ ├── services/ # Service orchestration +│ ├── store/ # Workspace state persistence +│ └── template/ # Scaffolding templates +├── scaffold/ # go:embed template scaffolding +├── log/ # Structured logging with rotation (lumberjack) +└── version/ # Public version API + +specs/ # Feature specifications (SpecKit workflow) +├── 001-initial-setup/ # Foundation +├── ... +├── 015-ui-refactor/ # Dashboard, ErrorBoundary, border fixes, profile theming +├── 016-ui-layout-fix/ # Layout width bug fixes +└── 017-ui-engine/ # View-based UI engine (current) + tests/ -├── integration/ # Integration tests -└── unit/ # Unit tests +├── integration/ +├── unit/ +├── performance/ +└── visual/ + +testdata/golden/ # Golden file snapshots for UI tests ``` --- @@ -182,10 +348,17 @@ tests/ ### 4.1 Dependency Injection — `app.Context` -The central DI container. ALL commands receive dependencies through this. +The central DI container. ALL commands receive dependencies through this. Created once in `main.go` and threaded through the command tree. ```go -// CORRECT — Use functional options +// Startup flow: +// main.go → config.Load() → app.NewDefaultContextWithConfig(cfg) → cli.Execute(ctx) + +// Context fields: +// Config, Logger, Store, Prefs, UI, Catalog, BaseDir, NoColor, NoAnimation, +// SafeBorder, Factory, profileContext (lazy) + +// CORRECT — Use functional options for testing ctx, err := app.NewContext( app.WithLogger(logger), app.WithStore(store), @@ -193,7 +366,7 @@ ctx, err := app.NewContext( app.WithNoColor(true), ) -// CORRECT — Access ProfileContext (lazy-loaded, thread-safe) +// CORRECT — Access ProfileContext (lazy-loaded, thread-safe, double-checked locking) pc := ctx.GetProfileContext() if pc != nil { theme := pc.Theme() @@ -218,9 +391,48 @@ if pc == nil { tierNames := pc.TierNames() // Returns copy, safe to modify colors := pc.ThemeColors() // Returns *ColorSet pointer profile := pc.Profile() // Returns *Profile pointer + +// After profile changes, invalidate cache: +ctx.InvalidateProfileContext() +``` + +### 4.3 UI Engine (Spec 017) — View-Based Architecture + +The UI engine is the primary rendering system. Every command renders through `engine.Render()`. + +```go +// Three render modes: +// TUIMode (default) → Full Bubble Tea interactive program +// JSONMode (--json) → Structured JSON output for scripting +// StaticMode (--no-animation) → Plain text, no ANSI, CI-friendly + +// View interface = tea.Model + OnEnter + OnExit + Name + Keybindings +// ViewContext carries: Profile, Theme, Width, Height, Args + +// Standard command pattern with engine: +func servicesListCmd(ctx *app.Context) *cobra.Command { + return &cobra.Command{ + Use: "list", + RunE: func(cmd *cobra.Command, args []string) error { + factory := ui.NewComponentFactory(ctx.GetProfileContext()) + router := engine.NewRouterFromFactory(factory) + view := views.NewServicesListView(factory, ctx.Catalog) + router.Register(view) + router.Navigate("services-list", nil) + + return engine.Render(engine.RenderConfig{ + View: router.Current(), + Mode: engine.TUIMode, + JSONData: catalogData, // for --json + }) + }, + } +} + +// Legacy fallback: ARC_USE_LEGACY_UI=1 skips the engine ``` -### 4.3 ComponentFactory — Themed Component Producer +### 4.4 ComponentFactory — Themed Component Producer ```go // ComponentFactory produces pre-themed components from ProfileContext @@ -235,15 +447,15 @@ errBox := factory.ErrorBox(context, message, hint, severity) style := lipgloss.NewStyle().Foreground(lipgloss.Color("#00ADD8")) // ← FORBIDDEN ``` -### 4.4 ErrorBoundary — Unified Error Pipeline +### 4.5 ErrorBoundary — Unified Error Pipeline ```go // ErrorBoundary wraps Cobra RunE functions // 4 render paths: dashboard toast, TTY ErrorBox, non-TTY plain, JSON -boundary := middleware.NewErrorBoundary(hintRegistry, factory) +boundary := middleware.NewErrorBoundary(factory, uiService, hintRegistry) cmd.RunE = boundary.Wrap(originalRunE) -// Use ArcError for rich errors +// Use ArcError for rich, user-facing errors return errors.New("service not found"). WithContext("arc services start"). WithHint("Run 'arc services list' to see available services"). @@ -251,7 +463,7 @@ return errors.New("service not found"). WithExitCode(1) ``` -### 4.5 SafeBorder — Three-Tier Border Strategy +### 4.6 SafeBorder — Three-Tier Border Strategy ```go // Tier 1: Borderless (DEFAULT) — works everywhere @@ -265,18 +477,17 @@ tier := safeborder.DetectBorderMode() // ALWAYS use SafeBorder to select appropriate border style ``` -### 4.6 Bubble Tea — Model/Update/View +### 4.7 Bubble Tea — Model / Update / View (Elm Architecture) ```go // Flat struct model (no inheritance, no embedding other models as fields) type dashboardModel struct { - activeTab int - width int - height int - factory *ui.ComponentFactory - ctx *app.Context - // child components as concrete types - tabBar *components.TabBar + activeTab int + width int + height int + factory ui.ComponentFactory + ctx *app.Context + tabBar *components.TabBar statusRail *components.StatusRail } @@ -293,7 +504,7 @@ func (m dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "q", "ctrl+c": return m, tea.Quit case "tab": - m.activeTab = (m.activeTab + 1) % 4 + m.activeTab = (m.activeTab + 1) % len(m.tabs) } case tea.WindowSizeMsg: m.width = msg.Width @@ -302,7 +513,7 @@ func (m dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -// View renders — NEVER do I/O in View, pure string composition +// View renders — NEVER do I/O in View, pure string composition only func (m dashboardModel) View() string { return lipgloss.JoinVertical(lipgloss.Left, m.tabBar.Render(m.activeTab, m.width), @@ -312,7 +523,7 @@ func (m dashboardModel) View() string { } ``` -### 4.7 Cobra Command Pattern +### 4.8 Cobra Command Pattern ```go var myCmd = &cobra.Command{ @@ -323,7 +534,7 @@ var myCmd = &cobra.Command{ // 1. Get dependencies from app.Context // 2. Validate input // 3. Execute business logic - // 4. Render output via ComponentFactory or UI Service + // 4. Render output via engine.Render() or UI Service return nil }, } @@ -331,35 +542,56 @@ var myCmd = &cobra.Command{ func init() { rootCmd.AddCommand(myCmd) myCmd.Flags().BoolP("json", "j", false, "Output as JSON") + myCmd.Flags().Bool("no-animation", false, "Disable TUI, use static output") } ``` +### 4.9 Router — View Navigation + +```go +// Router manages named views with history (max 10 depth) +router := engine.NewRouter(profile, theme) +router.Register(views.NewHomeView(factory)) +router.Register(views.NewServiceDetailView(factory)) + +// Navigate with parameters +router.Navigate("service-detail", map[string]any{ + "serviceName": "postgres", +}) + +// Back navigation +router.Back() + +// Current view for rendering +view := router.Current() +``` + --- ## 5. Code Style Rules ### 5.1 Go Conventions -- **Go 1.24.0** — use latest language features (range over int, etc.) +- **Go 1.24.2** — use latest language features (range over int, etc.) - **gofumpt** for formatting (stricter than gofmt) - **gci** for import ordering: stdlib → external → internal - Run `make quality` (fmt + vet + lint) before every commit - 48 linters enabled via `.golangci.yml` — ALL must pass - `//nolint` directives require explanation comments — no silent suppressions -### 5.2 Width Calculations (CRITICAL BUG AWARENESS) +### 5.2 Width Calculations (CRITICAL) ```go -// WRONG — len() counts bytes, not visual width. Breaks with ANSI escape codes -width := len(styledString) // ← BUG -truncated := styledString[:maxWidth] // ← BUG +// WRONG — len() counts bytes, not visual width. Breaks with ANSI escape codes. +width := len(styledString) // ← BUG +truncated := styledString[:maxWidth] // ← BUG // CORRECT — lipgloss.Width() handles ANSI escape sequences -width := lipgloss.Width(styledString) // ← CORRECT +width := lipgloss.Width(styledString) // ← CORRECT truncated := ansi.Truncate(styledString, maxWidth, "") // ← CORRECT ``` ### 5.3 Color Usage ```go -// WRONG — Hardcoded colors violate Profile Theming (US6) +// WRONG — Hardcoded colors violate Profile Theming style := lipgloss.NewStyle().Foreground(lipgloss.Color("#00ADD8")) // CORRECT — Colors from ProfileContext via ComponentFactory @@ -374,13 +606,13 @@ return errors.New("workspace not initialized"). WithHint("Run 'arc init' to create a workspace"). WithContext(cmd.Use) -// CORRECT — Wrap system errors +// CORRECT — Wrap system errors with context if err != nil { return fmt.Errorf("reading config: %w", err) } -// WRONG — Raw error strings without context -return fmt.Errorf("failed") // ← No context, no hint, unhelpful +// WRONG — Raw error strings without context or hints +return fmt.Errorf("failed") ``` ### 5.5 Testing @@ -402,83 +634,132 @@ func TestSafeBorder(t *testing.T) { } } -// Bubble Tea headless testing +// Bubble Tea headless testing via tea.Model interface func TestDashboard_TabSwitch(t *testing.T) { m := newTestDashboardModel() m, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab}) - if m.(dashboardModel).activeTab != 1 { - t.Errorf("expected tab 1, got %d", m.(dashboardModel).activeTab) - } + assert.Equal(t, 1, m.(dashboardModel).activeTab) } +// Golden file testing for UI snapshots (testdata/golden/) +// Use -update flag to regenerate: go test ./... -update + // Coverage targets: // errors/, middleware/: 75%+ -// factory, safeborder: 60%+ -// components, dashboard: 40%+ +// factory, safeborder, engine/: 60%+ +// components, views: 40%+ // width-fix (layout, table): 80%+ ``` ### 5.6 Naming Conventions -- Packages: lowercase, singular (`catalog`, `store`, `workspace`) +- Packages: lowercase, singular (`catalog`, `store`, `workspace`, `engine`) - Files: snake_case (`card_grid.go`, `safe_border.go`, `split_pane.go`) +- View files: lowercase concatenated (`dashboardview.go`, `serviceslistview.go`) - Interfaces: verb or `-er` suffix (`ProfileRepository`, `ProfileRenderer`) -- Constructors: `New*` prefix (`NewComponentFactory`, `NewSafeBorder`) +- Constructors: `New*` prefix (`NewComponentFactory`, `NewSafeBorder`, `NewRouter`) - Test files: `*_test.go` alongside source - Options: `With*` prefix (`WithLogger`, `WithNoColor`) --- -## 6. Known Issues & Technical Debt +## 6. Profiles & Themes + +### 10 Built-in Profiles (franchise-themed) + +| Profile | Theme | Tier 1 | Tier 2 | Tier 3 | +|---------|-------|--------|--------|--------| +| `enterprise` | cyan-purple | Starter | Pro | Ultra | +| `saiyan` | fire | Super Saiyan | Super Saiyan Blue | Ultra Instinct | +| `shinobi` | gruvbox | Genin | Jonin | Hokage | +| `pirate` | ocean | Rookie | Supernova | Yonko | +| `pokemon` | rainbow | Basic | Stage 1 | Stage 2 | +| `triforce` | solarized | Courage | Wisdom | Power | +| `crystal` | dracula | Warrior | Knight | Paladin | +| `jedi` | nord | Padawan | Knight | Master | +| `bending` | matrix | Bender | Avatar | Cosmic | +| `horcrux` | monokai | Student | Auror | Headmaster | + +### 10 Built-in Themes -1. **`init()` in root.go** — Has global side effects (loads preferences, mutates global styles). Known tech debt. Future: move to explicit Bootstrap pattern in main.go -2. **Profile integration gaps** — Only 3/16 commands use ProfileContext. Being fixed in spec 015 -3. **Hardcoded colors** — `lipgloss.Color("#00ADD8")` appears 15+ times. Being migrated to ComponentFactory/ProfileContext -4. **`len()` vs `lipgloss.Width()`** — Multiple files use `len()` for ANSI strings causing border misalignment. Being fixed in spec 015 (T019-T023) -5. **Error handling fragmentation** — 5+ different error rendering patterns. Being unified via ErrorBoundary +`cyan-purple` (default), `dracula`, `fire`, `gruvbox`, `matrix`, `monokai`, `nord`, `ocean`, `rainbow`, `solarized` + +- Profiles: `pkg/ui/profiles/embedded/*.yaml` +- Themes: `pkg/ui/themes/embedded/*.yaml` +- Active profile persisted in `~/.arc/state.json` (via `preferences.Preferences`) +- **Enterprise** is the universal fallback when profile is nil or invalid +- ProfileContext is lazy-loaded via `ctx.GetProfileContext()` with double-checked locking --- -## 7. Build & Quality Gates +## 7. Environment Variables + +| Variable | Purpose | Default | +|----------|---------|---------| +| `NO_COLOR` | Disable all colors | unset | +| `CLICOLOR_FORCE` | Force colors in non-TTY | unset | +| `ARC_NO_ANIMATION` | Disable animations | unset | +| `ARC_ANIMATION_FPS` | Animation target FPS | 60 | +| `ARC_LOG_LEVEL` | Log level (debug/info/warn/error) | info | +| `ARC_LOG_FILE` | Custom log file path | `.arc/logs/arc.log` | +| `ARC_STATE_DIR` | Custom state directory | `.arc` | +| `ARC_BORDER_MODE` | Border tier override (none/block/unicode) | auto-detect | +| `ARC_USE_LEGACY_UI` | Use pre-017 rendering (set to `1`) | unset | +| `ARC_NO_TUI` | Disable TUI entirely | unset | +| `COLORTERM` | True color support detection | auto-detect | +| `TERM` | Terminal type detection | auto-detect | +| `TERM_PROGRAM` | Terminal program (for border detection) | auto-detect | -```bash -make build # Build the binary -make test # Run all tests -make quality # fmt + vet + lint (MUST pass before commit) -make lint # golangci-lint with 48 enabled linters +--- -# Test with race detector -go test -race ./... +## 8. Build & Quality Gates -# Coverage for specific package +```bash +make build # Build binary with version injection via ldflags +make run # Run without building (go run) +make test # Run all tests +make test-race # Run tests with race detector +make test-coverage # Generate coverage report +make quality # fmt + vet + lint (MUST pass before every commit) +make lint # golangci-lint with 48 enabled linters +make fmt # gofumpt + gci formatting +make help # Show all available Makefile targets + +# Manual equivalents +go build -o arc cmd/arc/main.go +go test -race ./... go test -coverprofile=coverage.out ./pkg/cli/errors/ go tool cover -func=coverage.out ``` --- -## 8. Decision Framework +## 9. Decision Framework -When making implementation choices, apply this priority: +When making implementation choices, apply this priority order: 1. **Constitution principles** — Non-negotiable, always win -2. **Existing codebase patterns** — Follow established patterns (DI, Factory, ProfileContext) +2. **Existing codebase patterns** — Follow DI, Factory, ProfileContext, Engine patterns 3. **Charmbracelet ecosystem** — Use bubbletea/bubbles/lipgloss/huh idioms 4. **Go standard library** — Prefer stdlib over third-party when equivalent 5. **Performance targets** — Dashboard startup < 100ms, tab switch < 16ms, memory < 20MB -### When in Doubt -- Does it work offline? (Principle I) -- Does it use `app.Context` for dependencies? (Pattern 4.1) -- Does it get colors from ProfileContext? (Pattern 4.3) -- Does it use `lipgloss.Width()` not `len()`? (Rule 5.2) -- Does it have table-driven tests? (Rule 5.5) -- Does it pass `make quality`? (Section 7) +### Quick Checklist + +- [ ] Does it work offline? (Principle I) +- [ ] Does it use `app.Context` for dependencies? (Pattern 4.1) +- [ ] Does it render through `engine.Render()`? (Pattern 4.3) +- [ ] Does it get colors from ProfileContext, not hardcoded? (Rule 5.3) +- [ ] Does it use `lipgloss.Width()` not `len()`? (Rule 5.2) +- [ ] Does it support `--json` and `--no-animation`? (Principle VIII) +- [ ] Does it have table-driven tests? (Rule 5.5) +- [ ] Does it pass `make quality`? (Section 8) +- [ ] Does it keep Left Brain / Right Brain separation? (Principle III) --- -## 9. Feature Specification System (SpecKit) +## 10. Feature Specification System (SpecKit) -Features are developed through a structured specification workflow: +Features are developed through a structured specification workflow in `specs/`: ``` specs/NNN-feature-name/ @@ -491,31 +772,43 @@ specs/NNN-feature-name/ └── contracts/ # Go interface definitions ``` -- **Specs 001-007**: Foundation (grandfathered from patterns) -- **Specs 008+**: Must follow Factory, XDG, Repository, UI Service patterns -- **Specs 010-014**: Profile system, tiers, init wizard -- **Spec 015**: UI refactor (current — dashboard, error boundary, border fixes, profile theming) +### Spec History + +| Spec | Feature | Status | +|------|---------|--------| +| 001–006 | Foundation, state, tests, animations | Complete | +| 007 | Init wizard | Complete | +| 008 | Workspace config (arc.yaml) | Complete | +| 009 | Service catalog + init wizard fix | Complete | +| 010 | Codebase cleanup | Complete | +| 011 | Workspace orchestration (deep) | Complete | +| 012 | UI error component | Complete | +| 013 | Profile tiers | Complete | +| 014 | Profile init wizard | Complete | +| 015 | UI refactor (dashboard, ErrorBoundary, borders, theming) | Complete | +| 016 | UI layout fix (width bugs) | Complete | +| 017 | UI engine (view-based architecture, TUI/JSON/static) | Complete | + +### Platform Roadmap Context + +The CLI is the **orchestration layer** of the larger A.R.C. platform. Future work includes: + +- **Docker/Container Integration** — `arc run` launching composed services via Docker Compose +- **Service Health Probing** — Deep health checks per the Dr. House principle +- **Chaos Engineering** — T-800/Chaos Mesh integration for resilience testing +- **Python SDK Bridge** — Scaffolding and launching Sherlock (arc-brain) LangGraph agents +- **Remote Deployment** — Cloud-aware `arc deploy` with provider adapters +- **Plugin System** — Third-party service definitions and community profiles --- -## 10. Profiles & Themes +## 11. Known Issues & Technical Debt -The CLI supports 10 franchise-themed developer profiles: - -| Profile | Theme | Tier Names Example | -|---------|-------|-------------------| -| Enterprise | Cyan/Purple | Associate → Principal → Fellow | -| Saiyan | Fire | Saiyan → Super Saiyan → Ultra Instinct | -| Jedi | Nord Blue | Padawan → Knight → Master | -| Pirate | Ocean | Deckhand → Captain → Pirate King | -| ... | ... | ... | - -- Profiles stored in `pkg/ui/profiles/embedded/*.yaml` -- Themes stored in `pkg/ui/themes/embedded/*.yaml` -- Active profile persisted in `~/.arc/state.json` -- **Enterprise** is the universal fallback when profile is nil or invalid -- ProfileContext is lazy-loaded via `app.Context.GetProfileContext()` +1. **`init()` in root.go** — Has global side effects (loads preferences, mutates global styles). Future: move to explicit Bootstrap pattern in main.go +2. **Hardcoded colors** — `lipgloss.Color("#00ADD8")` still appears in places. Being migrated to ComponentFactory/ProfileContext +3. **Legacy UI code** — `pkg/ui/legacy/` and `pkg/cli/dashboard/` are deprecated but kept for `ARC_USE_LEGACY_UI` fallback. Will be removed after 017 stabilizes +4. **Error handling fragmentation** — Being unified via ErrorBoundary but some commands still use raw `fmt.Errorf` --- -*Constitution v1.1.0 | Go 1.24.0 | Branch: 015-ui-refactor* +*Module: github.com/arc-framework/arc-cli | Go 1.24.2 | Constitution v1.2.0* diff --git a/.github/labeler.yml b/.github/labeler.yml index 242ced5..53660fb 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,35 +1,44 @@ -# Add 'feature' label to any PR branch starting with 'feature/' or 'feat/' +# ── Type labels (branch name = developer intent) ──────────────────────────── + feature: - head-branch: ['^feature/.*', '^feat/.*'] -# Add 'bug' label to any PR branch starting with 'bug/' or 'fix/' bug: - head-branch: ['^bug/.*', '^fix/.*'] -# Add 'documentation' label for changes in the 'docs/' folder +performance: + - head-branch: ['^perf/.*'] + +security: + - head-branch: ['^security/.*', '^sec/.*'] + +breaking: + - head-branch: ['^breaking/.*', '^break/.*'] + +chore: + - head-branch: ['^chore/.*'] + +maintenance: + - head-branch: ['^refactor/.*', '^cleanup/.*'] + +# ── Area labels (file changes = automatic) ─────────────────────────────────── + documentation: - changed-files: - - any-glob-to-any-file: ['docs/**'] + - any-glob-to-any-file: ['docs/**', 'docs-site/**', '**/*.md'] -# Add 'ci' label for changes to GitHub Actions workflows ci: - changed-files: - - any-glob-to-any-file: ['.github/workflows/**'] + - any-glob-to-any-file: ['.github/**', '.goreleaser*.yaml'] testing: - changed-files: - - any-glob-to-any-file: ['**/*_test.go', 'tests/**'] - -performance: - - head-branch: ['^perf/'] - -security: - - head-branch: ['^security/', '^sec/'] - -breaking: - - head-branch: ['^breaking/'] - -chore: - - head-branch: ['^chore/'] + - any-glob-to-any-file: ['**/*_test.go', 'tests/**'] +ui: + - changed-files: + - any-glob-to-any-file: ['pkg/ui/**', 'pkg/ui.legacy/**', 'internal/branding/**'] +dependencies: + - changed-files: + - any-glob-to-any-file: ['go.mod', 'go.sum'] diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 5d1a703..d164e9f 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -1,13 +1,13 @@ name: Benchmark on: + push: + branches: [main] workflow_call: permissions: contents: write pull-requests: write - pages: write - id-token: write jobs: benchmark: @@ -17,12 +17,12 @@ jobs: has_benchmarks: ${{ steps.check_results.outputs.has_benchmarks }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version: "1.24" - name: Run benchmarks run: | @@ -61,7 +61,7 @@ jobs: if: steps.check_results.outputs.has_benchmarks == 'true' && ((github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_call') with: name: Go Benchmark - tool: 'go' + tool: "go" output-file-path: benchmark.txt github-token: ${{ secrets.GITHUB_TOKEN }} auto-push: true @@ -73,39 +73,9 @@ jobs: continue-on-error: true with: name: Go Benchmark - tool: 'go' + tool: "go" output-file-path: benchmark.txt github-token: ${{ secrets.GITHUB_TOKEN }} auto-push: false comment-always: true fail-on-alert: false - - - name: Checkout gh-pages branch - if: steps.check_results.outputs.has_benchmarks == 'true' && ((github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_call') - uses: actions/checkout@v6 - with: - ref: gh-pages - path: ./gh-pages - - - name: Upload artifact - if: steps.check_results.outputs.has_benchmarks == 'true' && ((github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_call') - uses: actions/upload-pages-artifact@v3 - with: - path: ./gh-pages - - deploy: - needs: benchmark - if: needs.benchmark.outputs.has_benchmarks == 'true' && (github.event_name == 'workflow_call' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 - - name: Print Deployment Summary - if: always() - run: | - echo "✅ Deployment to GitHub Pages complete." - echo "URL: ${{ steps.deployment.outputs.page_url }}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05422d5..8d19c25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: pull_request: - branches: [ main, develop ] + branches: [main, develop] workflow_call: permissions: @@ -20,19 +20,19 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version: "1.24" cache: true cache-dependency-path: go.sum - name: Go Lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v7 with: - version: v1.64 + version: v2.10.1 args: --timeout=5m --config=.golangci.yml ./cmd/... ./internal/... ./pkg/... only-new-issues: ${{ github.event_name == 'pull_request' }} skip-cache: false @@ -42,12 +42,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version: "1.24" cache: true cache-dependency-path: go.sum @@ -61,7 +61,7 @@ jobs: - name: Run Core Tests run: | - CORE_PKGS="./internal/... ./pkg/store/... ./pkg/log/... ./pkg/ui/themes/... ./pkg/ui/animations/... ./pkg/ui/components/... ./pkg/ui/layout/... ./pkg/ui/markdown/... ./pkg/ui/styles/..." + CORE_PKGS="./internal/... ./pkg/store/... ./pkg/log/... ./pkg/ui/component/... ./pkg/ui/theme/..." if [[ "${{ github.ref }}" == "refs/heads/main" || "${{ github.ref }}" == "refs/heads/develop" ]]; then echo "Running tests with race detector (main/develop branch)" go test -race -coverprofile=coverage-core.txt -covermode=atomic $CORE_PKGS @@ -81,12 +81,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version: "1.24" cache: true cache-dependency-path: go.sum @@ -137,14 +137,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version: "1.24" cache: true cache-dependency-path: go.sum @@ -159,22 +159,6 @@ jobs: permissions: contents: write pull-requests: write - pages: write - id-token: write - - # PR Quality Checks (grouped to reduce workflow runs) - pr_labeler: - name: Label PR - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Label PR - uses: actions/labeler@v5 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} pr_size: name: PR Size Label @@ -185,19 +169,19 @@ jobs: uses: codelytv/pr-size-labeler@v1 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - xs_label: 'size/XS' + xs_label: "size/XS" xs_max_size: 10 - s_label: 'size/S' + s_label: "size/S" s_max_size: 50 - m_label: 'size/M' + m_label: "size/M" m_max_size: 200 - l_label: 'size/L' + l_label: "size/L" l_max_size: 500 - xl_label: 'size/XL' + xl_label: "size/XL" fail_if_xl: false message_if_xl: > ⚠️ This PR is very large (500+ lines). Consider breaking it into smaller PRs for easier review. - files_to_ignore: 'go.sum' + files_to_ignore: "go.sum" spellcheck: name: Spell Check @@ -205,13 +189,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Spell Check in docs/ uses: crate-ci/typos@master with: config: .typos.toml - files: 'docs/ README.md' + files: "docs/ README.md" link_check: name: Link Check @@ -219,12 +203,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Check Links in docs/ uses: lycheeverse/lychee-action@v2 with: - args: --verbose --no-progress --exclude-path '.github' --exclude-path 'specs' 'docs/**/*.md' 'README.md' + args: --verbose --no-progress --exclude-path '.github' --exclude-path 'specs' --exclude 'conventionalcommits\.org' 'docs/**/*.md' 'README.md' fail: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -235,29 +219,29 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout PR - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version: "1.24" cache: true - name: Build PR Binary run: | - go build -o arc-pr ./cmd/arc + go build -ldflags="-s -w" -o arc-pr ./cmd/arc PR_SIZE=$(stat -f%z arc-pr 2>/dev/null || stat -c%s arc-pr) echo "pr_size=$PR_SIZE" >> $GITHUB_OUTPUT id: pr - name: Checkout Base - uses: actions/checkout@v6 + uses: actions/checkout@v4 with: ref: ${{ github.base_ref }} - name: Build Base Binary run: | - go build -o arc-base ./cmd/arc + go build -ldflags="-s -w" -o arc-base ./cmd/arc BASE_SIZE=$(stat -f%z arc-base 2>/dev/null || stat -c%s arc-base) echo "base_size=$BASE_SIZE" >> $GITHUB_OUTPUT id: base @@ -301,17 +285,29 @@ jobs: body: comment }); + - name: Enforce size limit + shell: bash + run: | + PR_SIZE="${{ steps.pr.outputs.pr_size }}" + MAX_SIZE=20971520 # 20 MB hard limit + echo "Binary size: $(( PR_SIZE / 1024 / 1024 )) MB (${PR_SIZE} bytes)" + if [ "${PR_SIZE}" -gt "${MAX_SIZE}" ]; then + echo "::error::Binary exceeds 20 MB hard limit. Current: ${PR_SIZE} bytes." + exit 1 + fi + echo "Size OK ✓" + security: name: Security Scan runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version: "1.24" cache: true cache-dependency-path: go.sum @@ -323,7 +319,7 @@ jobs: - name: Run Gosec uses: securego/gosec@master with: - args: '-exclude=G304,G301,G306 -confidence=high -severity=high -fmt text ./...' + args: "-exclude=G304,G301,G306,G703 -confidence=high -severity=high -fmt text ./..." - name: Dependency Review if: github.event_name == 'pull_request' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..9bb449d --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,30 @@ +name: CodeQL + +on: + workflow_dispatch: # disabled: enable manually if needed + +permissions: + contents: read + security-events: write + +jobs: + analyze: + name: Analyze (Go) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: go + queries: security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform Analysis + uses: github/codeql-action/analyze@v3 + with: + category: /language:go diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml new file mode 100644 index 0000000..6b6133c --- /dev/null +++ b/.github/workflows/compat.yml @@ -0,0 +1,32 @@ +name: Cross-Platform Compat + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + build-and-test: + name: Build & Test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache: true + + - name: Build + run: go build ./cmd/arc + + - name: Test + run: go test ./internal/... ./pkg/... diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index b233ebc..6057f14 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Apply labels uses: actions/labeler@v5 diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..b3c0186 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,35 @@ +name: PR Title + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened] + +permissions: + pull-requests: read + +jobs: + validate: + name: Validate PR Title + runs-on: ubuntu-latest + steps: + - name: Check conventional commit format + uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + docs + chore + refactor + perf + test + build + ci + revert + requireScope: false + subjectPattern: ^.{1,72}$ + subjectPatternError: | + PR title subject must be 1–72 characters. + Example: "feat: add dark mode toggle" diff --git a/.github/workflows/reusable-build.yml b/.github/workflows/reusable-build.yml index 8ccc8af..bf9bdb0 100644 --- a/.github/workflows/reusable-build.yml +++ b/.github/workflows/reusable-build.yml @@ -35,12 +35,12 @@ jobs: attestations: write steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v5 with: go-version: ${{ inputs.go-version }} cache: true diff --git a/.gitignore b/.gitignore index 1a439a3..0859538 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,9 @@ go.work.sum # Arc state directory .arc/ +# Legacy UI (kept as reference during 018-ui-design rebuild) +pkg/ui.legacy/ + # Editor/IDE .idea/ !.idea/watcherTasks.xml # Keep shared file watchers config @@ -62,3 +65,4 @@ test_*.sh dist/ +/arc diff --git a/.golangci.bck.yml b/.golangci.bck.yml new file mode 100644 index 0000000..40ad4c9 --- /dev/null +++ b/.golangci.bck.yml @@ -0,0 +1,227 @@ +# golangci-lint configuration +# Reference: https://golangci-lint.run/usage/configuration/ + +run: + timeout: 5m + go: "1.24" + tests: true + allow-parallel-runners: true + +linters: + disable-all: true + enable: + # Default linters + - errcheck + - govet + - ineffassign + - staticcheck + - unused + + # Formatting & Style + - gofumpt + - gci + - misspell + - whitespace + - nolintlint + + # Quality & Complexity + - gocyclo + - cyclop + - gocritic + - revive + - dupl + - nestif + - unparam + - nakedret + - makezero + + # Security & Correctness + - gosec + - bodyclose + - contextcheck + - nilerr + - errorlint + - copyloopvar + + # CLI Specific / Useful + - goconst + - prealloc + - usestdlibvars + +linters-settings: + gofumpt: + extra-rules: true + + gci: + sections: + - standard + - default + - prefix(github.com/arc-framework/arc-cli) + custom-order: true + + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + - name: if-return + - name: increment-decrement + - name: var-naming + - name: var-declaration + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unreachable-code + - name: redefines-builtin-id + + govet: + enable-all: true + disable: + - fieldalignment + settings: + shadow: + strict: true + + gocyclo: + min-complexity: 15 + + cyclop: + max-complexity: 15 + package-average: 10.0 + skip-tests: true + + gocritic: + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + disabled-checks: + - dupImport + - ifElseChain + - octalLiteral + - whyNoLint + + dupl: + threshold: 100 + + nolintlint: + allow-unused: false + require-explanation: true + require-specific: true + + misspell: + locale: US + + copyloopvar: + check-alias: true + + nakedret: + max-func-lines: 30 + + errorlint: + errorf: true + asserts: true + comparison: true + + nestif: + min-complexity: 4 + + goconst: + min-len: 2 + min-occurrences: 2 + + gosec: + # Exclude specific rules that are false positives in our controlled environment + excludes: + - G304 # File path injection - we use controlled paths from internal constants + - G301 # Directory permissions - 0755 is acceptable for our use case + - G306 # File permissions - 0644 is acceptable for config files + # Confidence levels: LOW, MEDIUM, HIGH + # Only fail on HIGH confidence issues + confidence: high + # Severity levels: LOW, MEDIUM, HIGH + # Only fail on HIGH severity issues + severity: high + +issues: + exclude-use-default: false + max-issues-per-linter: 0 + max-same-issues: 0 + exclude-rules: + # Exclude test files from various linters + - path: _test\.go + linters: + - gosec # Security checks not critical in tests + - errcheck # Unchecked errors OK in tests + - dupl # Duplicate code common in table-driven tests + - funlen # Long test functions are acceptable + - gocyclo # Complex test logic is OK + - cyclop # Complexity checks not needed for tests + - maintidx # Maintainability index less relevant for tests + - gocognit # Cognitive complexity OK in tests + - nestif # Nested ifs OK in test scenarios + - goconst # Repeated strings acceptable in tests + - gocritic # Less strict for test code + - revive # Less strict formatting for tests + - unparam # Unused params OK in test helpers + - nakedret # Naked returns OK in simple test helpers + - prealloc # Pre-allocation hints not critical in tests + + # Exclude shadow checks in test files (common in table-driven tests) + - path: _test\.go + linters: + - govet + text: "shadow:" + + # Exclude errorlint type assertion warnings in test files + - path: _test\.go + linters: + - errorlint + text: "type assertion on error" + + # Exclude internal/testing utilities from strict checks + - path: internal/testing/ + linters: + - gosec + - errcheck + - gocritic + - revive + - gocyclo + - unparam + - funlen + + # Exclude testdata directories + - path: testdata/ + linters: + - all + + # Allow globals in cmd/ (entry points) + - path: cmd/ + linters: + - gochecknoglobals + + exclude-dirs: + - vendor + - .git + - .github + - dist + - bin + - testdata + +output: + formats: + colored-line-number: {} + print-issued-lines: true + print-linter-name: true + sort-results: true diff --git a/.golangci.yml b/.golangci.yml index ec3f508..3531f04 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,229 +1,215 @@ -# golangci-lint configuration -# Reference: https://golangci-lint.run/usage/configuration/ - - +version: "2" run: - timeout: 5m - go: '1.24' + go: "1.24" tests: true allow-parallel-runners: true - linters: - disable-all: true + default: none enable: - # Default linters + - bodyclose + - contextcheck + - copyloopvar + - cyclop + - dupl - errcheck + - errorlint + - goconst + - gocritic + - gocyclo + - gosec - govet - ineffassign - - staticcheck - - unused - - # Formatting & Style - - gofumpt - - gci + - makezero - misspell - - whitespace - - nolintlint - - # Quality & Complexity - - gocyclo - - cyclop - - gocritic - - revive - - dupl - - nestif - - unparam - nakedret - - makezero - - # Security & Correctness - - gosec - - bodyclose - - contextcheck + - nestif - nilerr - - errorlint - - copyloopvar - - # CLI Specific / Useful - - goconst + - nolintlint - prealloc + - revive + - staticcheck + - unparam + - unused - usestdlibvars - -linters-settings: - gofumpt: - extra-rules: true - - gci: - sections: - - standard - - default - - prefix(github.com/arc-framework/arc-cli) - custom-order: true - - revive: + - whitespace + settings: + copyloopvar: + check-alias: true + cyclop: + max-complexity: 15 + package-average: 10 + dupl: + threshold: 100 + errorlint: + errorf: true + asserts: true + comparison: true + goconst: + min-len: 2 + min-occurrences: 2 + gocritic: + disabled-checks: + - dupImport + - ifElseChain + - octalLiteral + - whyNoLint + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + gocyclo: + min-complexity: 15 + gosec: + excludes: + - G304 + - G301 + - G306 + severity: high + confidence: high + govet: + disable: + - fieldalignment + enable-all: true + settings: + shadow: + strict: true + misspell: + locale: US + nakedret: + max-func-lines: 30 + nestif: + min-complexity: 4 + nolintlint: + require-explanation: true + require-specific: true + allow-unused: false + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + - name: if-return + - name: increment-decrement + - name: var-naming + - name: var-declaration + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unreachable-code + - name: redefines-builtin-id + exclusions: + generated: lax rules: - - name: blank-imports - - name: context-as-argument - - name: context-keys-type - - name: dot-imports - - name: error-return - - name: error-strings - - name: error-naming - - name: if-return - - name: increment-decrement - - name: var-naming - - name: var-declaration - - name: range - - name: receiver-naming - - name: time-naming - - name: unexported-return - - name: indent-error-flow - - name: errorf - - name: empty-block - - name: superfluous-else - - name: unreachable-code - - name: redefines-builtin-id - - govet: - enable-all: true - disable: - - fieldalignment - settings: - shadow: - strict: true - - gocyclo: - min-complexity: 15 - - cyclop: - max-complexity: 15 - package-average: 10.0 - skip-tests: true - - gocritic: - enabled-tags: - - diagnostic - - experimental - - opinionated - - performance - - style - disabled-checks: - - dupImport - - ifElseChain - - octalLiteral - - whyNoLint - - dupl: - threshold: 100 - - nolintlint: - allow-unused: false - require-explanation: true - require-specific: true - - misspell: - locale: US - - copyloopvar: - check-alias: true - - nakedret: - max-func-lines: 30 - - errorlint: - errorf: true - asserts: true - comparison: true - - nestif: - min-complexity: 4 - - goconst: - min-len: 2 - min-occurrences: 2 - - gosec: - # Exclude specific rules that are false positives in our controlled environment - excludes: - - G304 # File path injection - we use controlled paths from internal constants - - G301 # Directory permissions - 0755 is acceptable for our use case - - G306 # File permissions - 0644 is acceptable for config files - # Confidence levels: LOW, MEDIUM, HIGH - # Only fail on HIGH confidence issues - confidence: high - # Severity levels: LOW, MEDIUM, HIGH - # Only fail on HIGH severity issues - severity: high - + - linters: + - cyclop + - dupl + - errcheck + - funlen + - gocognit + - goconst + - gocritic + - gocyclo + - gosec + - maintidx + - nakedret + - nestif + - prealloc + - revive + - unparam + path: _test\.go + - linters: + - govet + path: _test\.go + text: "shadow:" + - linters: + - errorlint + path: _test\.go + text: type assertion on error + - linters: + - errcheck + - funlen + - gocritic + - gocyclo + - gosec + - revive + - unparam + path: internal/testing/ + - linters: + - all + path: testdata/ + - linters: + - gochecknoglobals + path: cmd/ + - linters: + - cyclop + path: (.+)_test\.go + + # bubbletea components intentionally use value receivers for immutability + - linters: + - gocritic + path: pkg/ui/ + text: "hugeParam:" + - linters: + - gocritic + path: pkg/ui/ + text: "unnamedResult:" + + # fmt.Fprintf to stderr is best-effort; ignoring the error is acceptable + - linters: + - errcheck + text: 'Error return value of `fmt\.Fprintf` is not checked' + + # pkg/workspace/template clashes with stdlib "template" — package name is intentional + - linters: + - revive + path: pkg/workspace/template/ + text: "avoid package names that conflict" + paths: + - vendor + - .git + - .github + - dist + - bin + - testdata + - third_party$ + - builtin$ + - examples$ issues: - exclude-use-default: false max-issues-per-linter: 0 max-same-issues: 0 - exclude-rules: - # Exclude test files from various linters - - path: _test\.go - linters: - - gosec # Security checks not critical in tests - - errcheck # Unchecked errors OK in tests - - dupl # Duplicate code common in table-driven tests - - funlen # Long test functions are acceptable - - gocyclo # Complex test logic is OK - - cyclop # Complexity checks not needed for tests - - maintidx # Maintainability index less relevant for tests - - gocognit # Cognitive complexity OK in tests - - nestif # Nested ifs OK in test scenarios - - goconst # Repeated strings acceptable in tests - - gocritic # Less strict for test code - - revive # Less strict formatting for tests - - unparam # Unused params OK in test helpers - - nakedret # Naked returns OK in simple test helpers - - prealloc # Pre-allocation hints not critical in tests - - # Exclude shadow checks in test files (common in table-driven tests) - - path: _test\.go - linters: - - govet - text: "shadow:" - - # Exclude errorlint type assertion warnings in test files - - path: _test\.go - linters: - - errorlint - text: "type assertion on error" - - # Exclude internal/testing utilities from strict checks - - path: internal/testing/ - linters: - - gosec - - errcheck - - gocritic - - revive - - gocyclo - - unparam - - funlen - - # Exclude testdata directories - - path: testdata/ - linters: - - all - - # Allow globals in cmd/ (entry points) - - path: cmd/ - linters: - - gochecknoglobals - - exclude-dirs: - - vendor - - .git - - .github - - dist - - bin - - testdata - - examples - -output: - formats: - - format: colored-line-number - print-issued-lines: true - print-linter-name: true - sort-results: true +formatters: + enable: + - gci + - gofumpt + settings: + gci: + sections: + - standard + - default + - prefix(github.com/arc-framework/arc-cli) + custom-order: true + gofumpt: + extra-rules: true + exclusions: + generated: lax + paths: + - vendor + - .git + - .github + - dist + - bin + - testdata + - third_party$ + - builtin$ + - examples$ diff --git a/.typos.toml b/.typos.toml index c001751..c2d8600 100644 --- a/.typos.toml +++ b/.typos.toml @@ -25,6 +25,7 @@ Cobra = "Cobra" Chroma = "Chroma" Gomega = "Gomega" Ginkgo = "Ginkgo" +Ratatui = "Ratatui" bubbletea = "bubbletea" lipgloss = "lipgloss" glamour = "glamour" diff --git a/Makefile b/Makefile index adac3c0..1d1ef56 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ # Define Go packages to be used in tests ALL_PKGS := $(shell go list ./cmd/... ./internal/... ./pkg/...) -CORE_PKGS := $(shell go list ./internal/... ./pkg/store/... ./pkg/log/... ./pkg/ui/themes/... ./pkg/ui/animations/... ./pkg/ui/components/... ./pkg/ui/layout/... ./pkg/ui/markdown/... ./pkg/ui/styles/...) +CORE_PKGS := $(shell go list ./internal/... ./pkg/store/... ./pkg/log/... ./pkg/ui/...) CLI_PKGS := $(shell go list ./pkg/cli/...) # Build flags @@ -278,7 +278,7 @@ test-bench: $(call log_info,Running benchmarks on critical packages only...) @go test -v -bench=. -benchmem -benchtime=100ms -timeout=5m \ ./pkg/catalog/... \ - ./pkg/ui/profiles/... \ + ./pkg/ui/theme/... \ ./pkg/workspace/... \ ./internal/preferences/... $(call log_success,Benchmarks complete) diff --git a/README.md b/README.md index dfc8ec5..2a9ec3e 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Until we fix the automated installer, please download manually: 3. **Extract and install:** **macOS/Linux:** + ```bash # Using gh cli (easiest): gh release download [TAG] -p "arc-cli_darwin_arm64_v8.0.tar.gz" -R arc-framework/arc-cli @@ -88,11 +89,13 @@ arc --help ### For Developers **Run without building:** + ```bash go run cmd/arc/main.go ``` **Build from source:** + ```bash make build # or @@ -114,7 +117,7 @@ go build -o arc cmd/arc/main.go ## Features - 🌀 **Beautiful Interactive UI** with smooth animations and styled output -- 🖥️ **New UI Engine (017)** — unified view-based architecture with TUI, JSON, and static output modes; profile-aware theming across all commands; `ARC_USE_LEGACY_UI=1` for rollback (see [specs/017-ui-engine/quickstart.md](specs/017-ui-engine/quickstart.md)) +- 🖥️ **New UI Engine (017)** — unified view-based architecture with TUI, JSON, and static output modes; profile-aware theming across all commands; `ARC_USE_LEGACY_UI=1` for rollback - 🎨 **5+ Animated Theme Schemes** with live previews - Cyan→Purple (default), Rainbow, Fire, Ocean, Matrix - Character-by-character rainbow option for maximum color! @@ -189,6 +192,7 @@ arc workspace history ``` **Quick Start:** + ```bash # 1. Create a new workspace arc workspace init ./my-project @@ -206,13 +210,13 @@ arc workspace run --detached **Workspace Commands:** -| Command | Description | -|---------|-------------| -| `arc workspace init [path]` | Initialize a new workspace | -| `arc workspace run` | Generate configs and launch platform | -| `arc workspace run --generate-only` | Generate configs without launching | -| `arc workspace info` | Show workspace state and configuration | -| `arc workspace history` | Show operation history | +| Command | Description | +| ----------------------------------- | -------------------------------------- | +| `arc workspace init [path]` | Initialize a new workspace | +| `arc workspace run` | Generate configs and launch platform | +| `arc workspace run --generate-only` | Generate configs without launching | +| `arc workspace info` | Show workspace state and configuration | +| `arc workspace history` | Show operation history | See [Workspace Quickstart](docs/user-guides/workspace.md) for detailed documentation. @@ -233,20 +237,21 @@ arc config get-profile **Built-in Profiles:** -| Profile | Tier 1 | Tier 2 | Tier 3 | Theme | -|---------|--------|--------|--------|-------| -| `enterprise` | Starter | Pro | Ultra | Professional | -| `saiyan` | Super Saiyan | Super Saiyan Blue | Ultra Instinct | Dragon Ball Z | -| `shinobi` | Genin | Jonin | Hokage | Naruto | -| `pirate` | Rookie | Supernova | Yonko | One Piece | -| `pokemon` | Basic | Stage 1 | Stage 2 | Pokemon | -| `triforce` | Courage | Wisdom | Power | Zelda | -| `crystal` | Warrior | Knight | Paladin | Final Fantasy | -| `jedi` | Padawan | Knight | Master | Star Wars | -| `bending` | Bender | Avatar | Cosmic | Avatar | -| `horcrux` | Student | Auror | Headmaster | Harry Potter | +| Profile | Tier 1 | Tier 2 | Tier 3 | Theme | +| ------------ | ------------ | ----------------- | -------------- | ------------- | +| `enterprise` | Starter | Pro | Ultra | Professional | +| `saiyan` | Super Saiyan | Super Saiyan Blue | Ultra Instinct | Dragon Ball Z | +| `shinobi` | Genin | Jonin | Hokage | Naruto | +| `pirate` | Rookie | Supernova | Yonko | One Piece | +| `pokemon` | Basic | Stage 1 | Stage 2 | Pokemon | +| `triforce` | Courage | Wisdom | Power | Zelda | +| `crystal` | Warrior | Knight | Paladin | Final Fantasy | +| `jedi` | Padawan | Knight | Master | Star Wars | +| `bending` | Bender | Avatar | Cosmic | Avatar | +| `horcrux` | Student | Auror | Headmaster | Harry Potter | **Example:** + ```bash arc config set-profile jedi # Set Star Wars theme arc workspace init # Tiers now show as Padawan/Knight/Master @@ -255,6 +260,7 @@ arc config set-profile enterprise # Reset to default **Custom Profiles:** Create `~/.config/arc/profiles/custom.yaml`: + ```yaml id: custom name: My Custom Profile @@ -280,6 +286,7 @@ arc init ``` **Features:** + - Interactive TUI with keyboard navigation - Multiple tier options for different use cases - Quick environment setup @@ -287,6 +294,7 @@ arc init **Note**: For workspace-based development, use `arc workspace init` instead. **Available Themes:** + - `cyan-purple` (default) - Modern & Professional gradient - `rainbow` - Full spectrum rainbow - `fire` - Yellow to red gradient @@ -295,6 +303,7 @@ arc init - `character-rainbow` - Ultimate colors! (every character is different) **Example:** + ```bash arc theme set rainbow # Switch to rainbow theme arc # See the rainbow banner! @@ -347,6 +356,7 @@ CLICOLOR_FORCE=1 arc --verbose A.R.C. CLI 0.0.1-dev Reliable Components for Resilient Architecture ``` + (With beautiful cyan→purple gradient colors in your terminal!) ## Environment Variables @@ -514,9 +524,10 @@ Features are developed using a spec-driven approach: 1. **Spec Directory**: Each feature has a directory in `specs/` (e.g., `002-state-management/`) 2. **Branch Naming**: Branches use PR numbers (e.g., `007-state-management`) 3. **Documentation**: Each spec includes: - - `spec.md` - Feature specification - - `plan.md` - Implementation plan - - `tasks.md` - Detailed task breakdown + +- `spec.md` - Feature specification +- `plan.md` - Implementation plan +- `tasks.md` - Detailed task breakdown **Current Features:** @@ -562,6 +573,7 @@ go build -o arc cmd/arc/main.go ``` The new tagline will automatically appear in: + - Banner display - Help text - Version output @@ -586,6 +598,7 @@ arc theme show The theme is automatically saved to `~/.arc/state.json` and persists across all sessions. **Available Themes:** + - 🌀 **cyan-purple** (default) - Professional gradient - 🌈 **rainbow** - Full spectrum - 🔥 **fire** - Yellow to red @@ -596,6 +609,7 @@ The theme is automatically saved to `~/.arc/state.json` and persists across all **Advanced Way:** Modify the code If you want to create custom themes: + 1. Open `pkg/cli/banner.go` 2. Add your theme to `AvailableThemes()` function 3. Rebuild: `make build` @@ -612,6 +626,7 @@ If you want to create custom themes: ### Why do commands exit immediately? This is **normal behavior** for CLI tools! The A.R.C. CLI is designed like `git`, `docker`, or `ls` - it: + 1. Runs a command 2. Shows output 3. Exits @@ -619,6 +634,7 @@ This is **normal behavior** for CLI tools! The A.R.C. CLI is designed like `git` **This is NOT a server or daemon** - it's a command-line tool that performs tasks and completes. **Examples:** + ```bash ./arc version # Shows version and exits ✅ ./arc --help # Shows help and exits ✅ @@ -665,20 +681,20 @@ git push origin v1.0.0-beta1 A.R.C. CLI supports two conventions: -| Convention | Format | Example | Use Case | -|-----------|--------|---------|----------| -| **Specify** (⭐ Recommended) | `###-name` | `042-add-auth` | Spec-driven development | -| **Legacy** | `feature/*` or `feat/*` | `feature/quick-fix` | Quick features | +| Convention | Format | Example | Use Case | +| ---------------------------- | ----------------------- | ------------------- | ----------------------- | +| **Specify** (⭐ Recommended) | `###-name` | `042-add-auth` | Spec-driven development | +| **Legacy** | `feature/*` or `feat/*` | `feature/quick-fix` | Quick features | ### Quick Guide -| What | How | Creates | -|------|-----|---------| -| **Stable Release** | Push `v*` tag | Official release | -| **Feature Release (Specify)** | Push `###-*` branch | Pre-release | -| **Feature Release (Legacy)** | Push `feature/**` branch | Pre-release | -| **Beta Release** | Push `v*-beta*` tag | Pre-release | -| **Local Test** | `make release-test` | Local build only | +| What | How | Creates | +| ----------------------------- | ------------------------ | ---------------- | +| **Stable Release** | Push `v*` tag | Official release | +| **Feature Release (Specify)** | Push `###-*` branch | Pre-release | +| **Feature Release (Legacy)** | Push `feature/**` branch | Pre-release | +| **Beta Release** | Push `v*-beta*` tag | Pre-release | +| **Local Test** | `make release-test` | Local build only | 📚 **See [RELEASE_SYSTEM.md](docs/contributing/releases.md) for the complete release guide** Includes: Branch conventions, workflows, troubleshooting, and step-by-step guides @@ -699,6 +715,7 @@ If you need a long-running process, that would be a different command (like `arc ### How do I actually use the CLI? **Quick start:** + ```bash # Option 1: Use the demo script ./demo.sh diff --git a/arc b/arc deleted file mode 100755 index 3317601..0000000 Binary files a/arc and /dev/null differ diff --git a/arc-test b/arc-test deleted file mode 100755 index a26fd86..0000000 Binary files a/arc-test and /dev/null differ diff --git a/docs/developer/018-ui-rewrite-plan.md b/docs/developer/018-ui-rewrite-plan.md new file mode 100644 index 0000000..d938a2a --- /dev/null +++ b/docs/developer/018-ui-rewrite-plan.md @@ -0,0 +1,354 @@ +# A.R.C. CLI v2 — UI Rewrite Plan +> **Date**: March 3, 2026 +> **Branch**: `018-ui-rewrite` +> **Status**: FINAL — Aligned and ready for implementation + +## Table of Contents +1. [Decisions Summary](#decisions) +2. [Architecture](#architecture) +3. [Engine Design](#engine) +4. [Component System](#components) +5. [Design System — React for CLI](#design-system) +6. [Theme and Skin System](#theme) +7. [Task Breakdown](#tasks) +8. [Appendix A: Discussion & Alignment Notes](#appendix) + +--- + +## Decisions Summary + +- **Language**: Go (Keep backend, Charmbracelet ecosystem) +- **Repo strategy**: Same repo, git worktree +- **Engine**: Fix and improve (React-inspired Shell + Router + Views) +- **arc bare**: Opens full dashboard (TUI, Home tab) +- **arc **: Focused TUI view for that command +- **arc --json**: JSON output (scripting mode) + +--- + +## Architecture Overview + +The engine manages the app shell with persistent header, navigation, and control bar. Views are pluggable and theme-aware. + +Backend services (catalog, workspace, store, scaffold, config, log) remain **unchanged**. UI communicates through clean interfaces. + +--- + +## Engine Design — The Shell + +The Shell is the one Bubble Tea model per `arc` invocation. It owns the persistent frame and delegates content rendering to active Views. + +**Critical Fix**: Router.Navigate() **always** calls OnEnter() before any render. This prevents blank views. + +### View Interface + +```go +type View interface { + Init() tea.Cmd + Update(msg tea.Msg) (View, tea.Cmd) + View() string + OnEnter(ctx *ViewContext) tea.Cmd // GUARANTEED before first View() + OnExit() tea.Cmd + Name() string + Keybindings() []KeyBinding +} +``` + +--- + +## Component System + +**Philosophy**: +1. One component, one file, one package (`pkg/ui/component/`) +2. Stateless by default — render functions +3. Stateful when needed — spinners, progress, search +4. Theme-aware always +5. Use libraries (bubbles, huh, lipgloss, glamour) + +**16 components total** (down from 30+): +- Render functions: header, navigation, controlbar, table, card, panel, hero, badge, tree, markdown +- Stateful: list, search, spinner, progress, form, viewport + +--- + +## 5. Design System — React for CLI + +> Think React components for the terminal. Every component has padding, margin, color, font, typography, emoji — all controlled by a centralized design system. +> Inspired by **gh-dash** — delightful, keyboard-driven, composable boxes. + +### Design Tokens (The CSS Variables) + +Everything flows from one struct — the single source of truth: + +```go +type DesignTokens struct { + // Colors + Primary, Secondary, Success, Error, Warning, Info lipgloss.Color + Text, TextMuted, TextInverse lipgloss.Color + BgBase, BgSurface, BgHighlight lipgloss.Color + Border, BorderFocus lipgloss.Color + + // Spacing + PaddingX, PaddingY, MarginX, MarginY, Gap int + + // Typography + Bold, Dim, Italic func(s string) string + + // Icons + Icons IconSet +} + +type IconSet struct { + Success, Error, Warning, Info string + Arrow, Bullet, Star string + Folder, File, Check, Cross string +} +``` + +### Default Theme (Dark — gh-dash Inspired) + +```go +var DefaultTokens = DesignTokens{ + Primary: "#7C3AED", + Secondary: "#A78BFA", + Success: "#34D399", + Error: "#F87171", + Warning: "#FBBF24", + Info: "#60A5FA", + Text: "#E4E4E7", + TextMuted: "#71717A", + TextInverse: "#18181B", + BgBase: "#09090B", + BgSurface: "#18181B", + BgHighlight: "#27272A", + Border: "#3F3F46", + BorderFocus: "#7C3AED", + + PaddingX: 2, Gap: 1, + Icons: IconSet{ + Success: "✓", Error: "✗", Warning: "⚠", + Info: "●", Arrow: "→", Bullet: "•", Star: "★", + Folder: "📁", File: "📄", Check: "✔", Cross: "✘", + }, +} +``` + +### Style Factory (Like React `styled-components`) + +```go +type Styles struct { + tokens DesignTokens + Page, Section, SectionHeader, SectionBody lipgloss.Style + Title, Subtitle, Body, Muted, Label, Value lipgloss.Style + StatusSuccess, StatusError, StatusWarning, StatusInfo lipgloss.Style + TableHeader, TableRow, TableRowAlt, TableCell lipgloss.Style + Prompt, Input, Selected, HelpKey, HelpValue lipgloss.Style + Card, Banner, Divider lipgloss.Style +} + +func NewStyles(t DesignTokens) Styles { + return Styles{ + tokens: t, + Page: lipgloss.NewStyle(). + Padding(t.PaddingY, t.PaddingX). + Margin(t.MarginY, t.MarginX), + Section: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.Border). + Padding(t.PaddingY, t.PaddingX). + MarginBottom(t.Gap), + // ... more styles + } +} +``` + +### Component Implementations + +Every component is a **pure function** — takes data + styles, returns a string. + +```go +func Banner(s Styles, emoji string, title string, subtitle string) string { + t := s.tokens + titleLine := fmt.Sprintf("%s %s", emoji, s.Title.Render(title)) + subtitleLine := s.Muted.Render(subtitle) + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.Primary). + Padding(1, 2). + MarginBottom(t.Gap). + Render(lipgloss.JoinVertical(lipgloss.Left, titleLine, subtitleLine)) +} + +func StatusBadge(s Styles, status string) string { + t := s.tokens + switch status { + case "success", "active", "running": + return s.StatusSuccess.Render(t.Icons.Success + " " + status) + case "error", "failed", "terminated": + return s.StatusError.Render(t.Icons.Error + " " + status) + default: + return s.StatusInfo.Render(t.Icons.Info + " " + status) + } +} + +func Section(s Styles, title string, content string) string { + header := s.SectionHeader.Render(title) + body := s.SectionBody.Render(content) + return s.Section.Render(lipgloss.JoinVertical(lipgloss.Left, header, body)) +} + +func HelpBar(s Styles, bindings [][]string) string { + parts := make([]string, len(bindings)) + for i, b := range bindings { + parts[i] = s.HelpKey.Render(b[0]) + " " + s.HelpValue.Render(b[1]) + } + return lipgloss.JoinHorizontal(lipgloss.Top, parts...) +} +``` + +### How It All Connects + +``` +arc.yaml → profile.Theme = "default" + → tokens := themes["default"] // DesignTokens struct + → styles := NewStyles(tokens) // Styles struct + → components.Banner(styles, ...) + → components.Table(styles, ...) +``` + +**One theme → one tokens struct → one styles struct → passed to every component.** +No globals. Like React context. + +### Design System Component Inventory + +| Component | Type | Description | Stateless? | +|-----------|------|-------------|-----------| +| Banner | Container | App header | ✅ | +| Section | Container | Bordered box | ✅ | +| StatusBadge | Inline | Colored status with icon | ✅ | +| HelpBar | Control | Footer with keybindings | ✅ | +| Table | Data | Column-aligned data | ✅ | +| Card | Container | Bordered card | ✅ | +| ProgressBar | Feedback | Animated progress | ❌ | +| List | Input | Selection list | ❌ | + +--- + +## 6. Theme and Skin System + +One package: `pkg/ui/theme/` (merged from 4 old packages). + +```go +type Context struct { + profile, theme, skin *Data + registry *Registry +} + +type Profile struct { + ID, Name, Description, Logo string + TierNames [3]string + ThemeID string +} + +type Theme struct { + ID, Name string + Colors ColorSet +} + +type Skin struct { + ID, Name string + Layout, Navigation, Borders, Density string +} +``` + +**Simplified**: One profile field → one DesignTokens struct → every component reads from that. + +--- + +## 7. Task Breakdown + +**Phase 1**: Foundation (37 tasks, ~55h) +- Theme system, component library, engine shell, bootstrap + +**Phase 2**: Theme + Skin (7 tasks, ~18h) +- Profile switching, skin rendering, golden tests + +**Phase 3**: Home + Services (7 tasks, ~19h) +- Dashboard views, router wiring, focused mode + +**Phase 4**: Workspace + Config (8 tasks, ~18h) +- Remaining tabs with real data + +**Phase 5**: Wizards + Commands (6 tasks, ~11.5h) +- Interactive flows, remaining commands + +**Phase 6**: Cleanup (8 tasks, ~14h) +- Delete old UI, lint, update docs + +**Total**: ~73 tasks, ~135 hours (~7 weeks @ 20h/week) + +--- + +## Appendix A: Discussion & Alignment Notes + +> These are the 10 design principles discussed and aligned before finalizing this plan. + +### Point 1: Abstraction is NOT Bad + +Keep one clean interface boundary between backend and UI. Backend stays stable; UI is swappable. + +### Point 2: Engine Was a Good Idea — Fix It + +Fix the lifecycle bug (OnEnter guarantee), consolidate components, keep the engine concept. + +### Point 3: gh-dash + Versionable UI + Worktree + +- Git worktree for clean rewrite +- gh-dash as V1 design reference +- UI skin system for swappable looks +- Tab-based navigation + +### Point 4: Simpler Folder Structure + +Max 2 directory hops. Flat: `pkg/ui/` has `theme/`, `component/`, `engine/`, `view/`. + +### Point 5: Linting — Smart Exclusions + +Keep all 30 linters. Path exclusions for UI layers. +Backend stays enterprise-strict. + +### Point 6: Testing — Interfaces First + +- Must test: Backend interfaces, theme loading, profile switching +- Golden files: Table, card, hero output +- Skip: View rendering, animation timing +- Later: Headless Bubble Tea integration + +### Point 7: Don't Discard Components + +Consolidate duplicates to one implementation. Keep factory pattern. + +### Point 8: Proper Engineering Plan, Go + Libraries + +Stick with Go + Charmbracelet: lipgloss, bubbles, huh, glamour, harmonica. + +### Point 9: Swappable UI, State-Driven Theming + +- Core untouched, UI replaceable +- Profile change = cascading update +- State is the brain +- Workspace scope: ready later, global first + +### Point 10: React-Inspired Engine Design + +``` +Engine: Shell + Router + ViewRegistry + StateManager +View: OnEnter (props) → Update → View (pure string) +``` + +Props = ViewContext, State = internal state, Component tree = Shell → TabBar → View → Components. + +--- + +*"The CLI should feel like it was built from the heart."* +*— A.R.C. CLI v2 Rewrite Plan, March 2026* diff --git a/docs/developer/019-ui-rewrite-plan.md b/docs/developer/019-ui-rewrite-plan.md new file mode 100644 index 0000000..3ae4dc5 --- /dev/null +++ b/docs/developer/019-ui-rewrite-plan.md @@ -0,0 +1,761 @@ +# A.R.C. CLI v2 — UI Rewrite Plan +> **Date**: March 3, 2026 +> **Branch**: `018-ui-rewrite` +> **Status**: FINAL — Aligned and ready for implementation +> **Approach**: Same repo, git worktree, rebuild UI from scratch, keep backend +--- +## Table of Contents +1. [Decisions Summary](#1-decisions-summary) +2. [Architecture Overview](#2-architecture-overview) +3. [Engine Design — The Shell](#3-engine-design--the-shell) +4. [Component System](#4-component-system) +5. [Theme and Skin System](#5-theme-and-skin-system) +6. [State Management](#6-state-management) +7. [Command Strategy](#7-command-strategy) +8. [Folder Structure](#8-folder-structure) +9. [Linting Constitution](#9-linting-constitution) +10. [Testing Strategy](#10-testing-strategy) +11. [Phase-by-Phase Implementation](#11-phase-by-phase-implementation) +12. [Task Breakdown](#12-task-breakdown) +--- +## 1. Decisions Summary +These are locked in. No more discussion on these — we build. +| Decision | Choice | Notes | +|----------|--------|-------| +| Language | **Go** | Keep existing backend, Charmbracelet ecosystem | +| Repo strategy | **Same repo, worktree** | `git worktree add ../arc-cli-v2 -b 018-ui-rewrite` | +| Engine | **Fix and improve**, not delete | React-inspired Shell + Router + Views | +| `arc` (bare) | **Opens dashboard** | Full interactive TUI, Home tab active | +| `arc ` | **Focused TUI view** | Rich interactive view for that command | +| `arc --json` | **JSON output** | Scripting/piping mode, no TUI | +| Skins | **Level 2+** | Theme + Layout Variant, interface designed for Level 3 later | +| Workspace prefs | **Later** | Global first, interface ready for workspace scope | +| Components | **Consolidate** | One implementation per concept, factory pattern kept | +| Linting | **Keep 30, smart exclusions** | Path-based exclusions for UI layer | +| Testing | **Interfaces first** | Backend contracts + golden files for key components | +| Priority | **C then D then A then B** | Components, Theme, Dashboard, All commands | +| Control bar | **Unified, 3 bars** | Header + Navigation + Control bar, shared across all views | +| Commands | **Most become views** | Reduce standalone commands, show as dashboard tabs/views | +--- +## 2. Architecture Overview +### The Big Picture +``` ++------------------------------------------------------------------+ +| arc binary | ++------------------------------------------------------------------+ +| | +| +---------------+ +--------------------------------------+ | +| | Cobra CLI |--->| UI Engine (Shell) | | +| | (minimal) | | | | +| | | | +----------+ +------------------+ | | +| | arc | | | Header | | State Manager | | | +| | arc services | | | TabBar | | (profile, theme, | | | +| | arc --json | | | ViewArea | | skin, workspace)| | | +| | | | | Controls | | | | | +| +-------+-------+ | +----------+ +------------------+ | | +| | | | | +| | | +----------------------------------+| | +| | | | Router + View Registry || | +| | | | home | services | workspace | || | +| | | | config | info | init | theme || | +| | | +----------------------------------+| | +| | +--------------------------------------+ | +| | | | +| | +-------------v--------------+ | +| | | Component Library | | +| | | table | card | hero | tree | | +| | | badge | panel | search | | +| | | spinner | progress | form | | +| | +-------------+--------------+ | +| | | | +| | +-------------v--------------+ | +| | | Theme + Skin System | | +| | | colors | styles | layout | | +| | | profiles | skins (YAML) | | +| | +----------------------------+ | +| | | +| +-------v----------------------------------------------------+ | +| | Backend Services | | +| | catalog | workspace | store | scaffold | config | log | | +| | (UNCHANGED) | | +| +-------------------------------------------------------------+ | ++-------------------------------------------------------------------+ +``` +### Data Flow +``` +User types "arc" or "arc services list" + | + v +Cobra parses command + flags + | + +-- --json flag? --> Backend fetch -> JSON stdout -> exit + | + +-- TUI mode --> Engine.Start(mode) + | + +-- mode = "dashboard" (bare "arc") + | -> Shell with all tabs, Home active + | + +-- mode = "focused:services-list" ("arc services list") + -> Shell with single view, no tab bar + -> q to quit back to terminal +``` +### Two Launch Modes +The engine supports two modes — this is how we satisfy Option C: +| Mode | Triggered by | Tab bar visible | Back to terminal on q | +|------|-------------|----------------|----------------------| +| **Dashboard** | `arc` (bare) | Yes — all tabs | Yes | +| **Focused** | `arc services list` | No — single view | Yes | +Both modes share the same Shell (header + control bar). The only difference is whether +the tab bar renders and whether the router allows navigation. This means: +- Components are identical in both modes +- Theme/skin applies to both +- The engine is ONE Bubble Tea program, not two separate systems +--- +## 3. Engine Design — The Shell +### Mental Model: React for the Terminal +Think of the Shell as a React App component: +``` + <- The persistent frame (Bubble Tea model) +
<- Brand, workspace, profile — always visible + <- Tab bar OR sidebar (depends on skin) + <- The area that changes + <- Whatever view the router says is current + + <- Keybindings from active view + profile info + +``` +### Shell Struct (the one and only Bubble Tea model) +```go +// Shell is the root Bubble Tea model. There is exactly ONE Shell +// per arc invocation. It owns the persistent frame and delegates +// content rendering to the active View. +type Shell struct { + // Engine internals + router *Router + state *StateManager + mode LaunchMode // Dashboard or Focused + // Persistent frame components + header *component.Header + navigation *component.Navigation + controlBar *component.ControlBar + // Dimensions + width int + height int + // The active view (set by router) + activeView View +} +``` +### Shell Lifecycle +``` +Shell.Init() + +-- Load state (profile, theme, skin, workspace) + +-- Initialize persistent components (header, nav, controlbar) + +-- Router.Navigate(initialRoute) <- "home" for dashboard, target for focused + +-- Return tea.WindowSize command +Shell.Update(msg) + +-- tea.WindowSizeMsg -> update dimensions, propagate to activeView + +-- tea.KeyMsg + | +-- Global keys (q/ctrl+c = quit, tab = next tab, shift+tab = prev) + | +-- Everything else -> delegate to activeView.Update(msg) + +-- NavigateMsg -> Router.Navigate(target), call OnExit/OnEnter + +-- StateChangedMsg -> reload theme/skin, re-render persistent frame +Shell.View() + +-- header.Render(width, state) + +-- navigation.Render(width, activeTab) <- only in Dashboard mode + +-- activeView.View() <- the content area + +-- controlBar.Render(width, activeView.Keybindings()) +``` +### View Interface (simplified from current) +```go +// View is a pluggable content panel. It receives context via OnEnter, +// handles its own key events, and returns a rendered string from View(). +type View interface { + // Init is called once when the view is first created. + Init() tea.Cmd + // Update handles key events and messages delegated from the Shell. + Update(msg tea.Msg) (View, tea.Cmd) + // View renders the content area. Pure string composition, no I/O. + View() string + // OnEnter is called by the Router when this view becomes active. + // The Shell GUARANTEES this is called before the first View(). + OnEnter(ctx *ViewContext) tea.Cmd + // OnExit is called when navigating away. Cleanup resources. + OnExit() tea.Cmd + // Name returns the unique identifier (e.g., "services-list", "home"). + Name() string + // Keybindings returns keyboard shortcuts for the ControlBar to display. + Keybindings() []KeyBinding +} +``` +### The Critical Fix: Shell calls OnEnter() automatically +```go +// Router.Navigate — called by Shell, ALWAYS calls OnEnter +func (r *Router) Navigate(name string, args map[string]any) (View, tea.Cmd) { + // Exit current view + var exitCmd tea.Cmd + if r.current != nil { + exitCmd = r.current.OnExit() + } + // Look up target view + view, ok := r.views[name] + if !ok { + return r.current, nil + } + // Build context from state manager + ctx := r.state.BuildViewContext(args) + // THIS IS THE FIX — OnEnter always called before any render + enterCmd := view.OnEnter(ctx) + r.current = view + return view, tea.Batch(exitCmd, enterCmd) +} +``` +This is the single most important fix. The current engine launches views without calling +OnEnter, so components are nil, View() returns empty string, terminal goes black. +In the new engine, Navigate() always calls OnEnter() before any render happens. +It is impossible to see a blank view. +### ViewContext (props passed to views) +```go +// ViewContext is the props object passed to every view on OnEnter. +type ViewContext struct { + // Theming + Theme *theme.Theme + Profile *theme.Profile + Skin *theme.Skin + // Dimensions (content area only — Shell already subtracted frame) + Width int + Height int + // Route parameters (e.g., {"serviceName": "postgres"}) + Args map[string]any + // Backend services (so views can fetch data) + Catalog catalog.Catalog + Workspace *workspace.Manager + Store *store.Store + // State reference (for views that need to trigger state changes) + State *StateManager +} +``` +ViewContext includes backend services directly. Views don't need to go through app.Context. +The engine bridges that gap. This is the clean interface boundary between backend and UI. +--- +## 4. Component System +### Philosophy +1. **One component, one file, one package** — `pkg/ui/component/` +2. **Stateless by default** — render functions that take theme + data, return string +3. **Stateful when needed** — spinners, progress, search are structs with Update() +4. **Theme-aware always** — every component receives `*theme.Context` +5. **Use libraries** — wrap bubbles/huh/lipgloss/glamour, don't rewrite them +### Component Inventory (16 total, down from 30+) +| Component | Type | Library Used | Purpose | +|-----------|------|-------------|---------| +| `header.go` | Render fn | lipgloss | Top bar: brand + workspace + profile | +| `navigation.go` | Render fn | lipgloss | Tab bar or sidebar (skin-dependent) | +| `controlbar.go` | Render fn | lipgloss | Bottom bar: keybindings + context | +| `table.go` | Render fn | lipgloss + bubbles/table | Themed data tables | +| `card.go` | Render fn | lipgloss | Bordered card with title + body | +| `panel.go` | Render fn | lipgloss | Bordered content panel | +| `hero.go` | Render fn | lipgloss | ASCII logo + profile branding | +| `badge.go` | Render fn | lipgloss | Colored status badges | +| `tree.go` | Render fn | lipgloss | Dependency tree visualization | +| `list.go` | Struct | bubbles/list | Interactive filterable list | +| `search.go` | Struct | bubbles/textinput | Search input with fuzzy matching | +| `spinner.go` | Struct | bubbles/spinner | Animated loading indicator | +| `progress.go` | Struct | bubbles/progress | Progress bar | +| `form.go` | Struct | huh | Multi-step form wizard | +| `viewport.go` | Struct | bubbles/viewport | Scrollable content area | +| `markdown.go` | Render fn | glamour | Render markdown in terminal | +### Key Principle: Wrap Libraries, Don't Rewrite +The old codebase has 338-line tab_bar.go, 266-line progress.go, 406-line status_rail.go +all reimplemented from scratch. In the new system: +- `List` wraps `bubbles/list` (10-20 lines of glue code) +- `Spinner` wraps `bubbles/spinner` (10 lines) +- `Progress` wraps `bubbles/progress` (10 lines) +- `Form` wraps `huh` (thin adapter) +- `Viewport` wraps `bubbles/viewport` (thin adapter) +Our custom code focuses only on ARC-unique components: +header, navigation, controlbar, hero, badge, tree. +--- +## 5. Theme and Skin System +### New Design: One Package `pkg/ui/theme/` +Replaces the current 4 packages (themes, profiles, styles, factory). +```go +package theme +// Context is the single source of truth for all visual styling. +// Every component receives this. +type Context struct { + profile *Profile + theme *Theme + skin *Skin + registry *Registry // Cached lipgloss.Style objects +} +// Profile defines branding: logo, tier names, identity. +type Profile struct { + ID string + Name string + Description string + TierNames [3]string + ThemeID string + Logo string +} +// Theme defines colors. +type Theme struct { + ID string + Name string + Colors ColorSet +} +// ColorSet is the full color palette. +type ColorSet struct { + Primary lipgloss.Color + Secondary lipgloss.Color + Accent lipgloss.Color + Success lipgloss.Color + Warning lipgloss.Color + Error lipgloss.Color + Info lipgloss.Color + Muted lipgloss.Color + Background lipgloss.Color + Foreground lipgloss.Color + Border lipgloss.Color + HeaderBg lipgloss.Color + HeaderFg lipgloss.Color +} +// Skin defines layout rules (Level 2). +type Skin struct { + ID string + Name string + Layout LayoutType // sidebar-left | tabs-top | minimal + Navigation NavType // sidebar | tab-bar | breadcrumb + Borders BorderType // rounded | sharp | none | half-block + Density Density // compact | comfortable | spacious +} +``` +### Skin YAML Examples +```yaml +# gh-dash.yaml +id: gh-dash +name: "GitHub Dashboard" +layout: sidebar-left +navigation: sidebar +borders: rounded +density: comfortable +# minimal.yaml +id: minimal +name: "Minimal" +layout: full-width +navigation: tab-bar +borders: none +density: compact +``` +### How Skin Affects Rendering +Components don't know about skins — they read values from theme.Context: +```go +// component/navigation.go — the ONE place skin layout matters +func Navigation(tc *theme.Context, tabs []Tab, activeIdx int, width int) string { + switch tc.Skin().Navigation { + case NavSidebar: + return renderSidebar(tc, tabs, activeIdx, width) + case NavTabBar: + return renderTabBar(tc, tabs, activeIdx, width) + } +} +``` +All other components are skin-agnostic. They just use colors and borders from Context. +### Level 3 Ready: The Interface +```go +// Renderer interface for future Level 3 skins. +// Right now, only DefaultRenderer exists. +type Renderer interface { + RenderTable(tc *Context, headers []string, rows [][]string) string + RenderCard(tc *Context, title, body string) string + RenderHero(tc *Context, width int) string +} +``` +We don't build this now. But components go through theme.Context, so swapping +to a Renderer interface later is a one-line change, not a rewrite. +--- +## 6. State Management +### StateManager +```go +// StateManager is the brain. It loads preferences, resolves the active +// profile/theme/skin, and builds ViewContext for views. +// Initialized ONCE during bootstrap (not in init()). +type StateManager struct { + prefs *preferences.Preferences + profile *theme.Profile + themeCtx *theme.Context + catalog catalog.Catalog + workspace *workspace.Manager + store *store.Store +} +func (sm *StateManager) BuildViewContext(args map[string]any) *ViewContext +func (sm *StateManager) ChangeProfile(profileID string) tea.Cmd +func (sm *StateManager) ChangeTheme(themeID string) tea.Cmd +func (sm *StateManager) ChangeSkin(skinID string) tea.Cmd +``` +### Bootstrap (replaces init() side effects) +```go +// internal/app/bootstrap.go — called ONCE from main.go +func Bootstrap() (*app.Context, error) { + cfg, _ := config.Load() + prefs, _ := preferences.Load() + tc, _ := theme.LoadFromPreferences(prefs) + cat := catalog.NewEmbedded() + store := store.New(xdg.DataDir()) + state := engine.NewStateManager(prefs, tc, cat, store) + return &app.Context{Config: cfg, State: state, Logger: log.New(cfg.LogLevel)}, nil +} +``` +No global variables. No init(). No lazy loading. Just a function. +### Workspace-Scoped Preferences (interface ready, built later) +```go +// PreferenceProvider interface — global for now, workspace-scoped later +type PreferenceProvider interface { + ProfileID() string + ThemeID() string + SkinID() string +} +// GlobalPreferences implements PreferenceProvider (current) +// WorkspacePreferences implements PreferenceProvider (future) +``` +--- +## 7. Command Strategy +### New Command Tree +``` +arc -> Dashboard (Home tab) +arc dashboard -> Dashboard (explicit alias) +arc init -> Init wizard (huh form, focused mode) +arc workspace init [path] -> Workspace wizard (huh form, focused mode) +arc workspace run -> Workspace runner (progress view, focused) +arc workspace info -> Workspace info (focused TUI or --json) +arc services list -> Services list (focused TUI or --json) +arc version -> Version info (focused TUI or --json) +arc completion -> Shell completion (stdout, no TUI) +arc help -> Help text (stdout, no TUI) +``` +### What Moved Into Dashboard Tabs +| Old Command | New Location | +|-------------|-------------| +| `arc info` | Dashboard -> Home tab | +| `arc services list/info/deps/ports` | Dashboard -> Services tab | +| `arc theme list/set/preview` | Dashboard -> Config tab | +| `arc config list-profiles/set-profile` | Dashboard -> Config tab | +| `arc workspace info/history` | Dashboard -> Workspace tab | +--- +## 8. Folder Structure +``` +arc-cli/ +|-- cmd/arc/ +| +-- main.go # Entry: bootstrap -> root -> execute +| +|-- internal/ # Private packages (KEPT, minimal changes) +| |-- app/ +| | |-- context.go # Slimmed: Config + State + Logger +| | +-- bootstrap.go # NEW: explicit init, replaces init() +| |-- config/ # KEPT +| |-- preferences/ # KEPT (add skin_id field) +| |-- terminal/ # KEPT +| |-- xdg/ # KEPT +| +-- version/ # KEPT +| +|-- pkg/ +| |-- catalog/ # KEPT ENTIRELY +| |-- workspace/ # KEPT ENTIRELY +| |-- store/ # KEPT ENTIRELY +| |-- scaffold/ # KEPT ENTIRELY +| |-- log/ # KEPT ENTIRELY +| |-- version/ # KEPT ENTIRELY +| | +| |-- ui/ # REBUILT +| | |-- theme/ # Theme + Profile + Skin (merged) +| | | |-- context.go +| | | |-- theme.go +| | | |-- profile.go +| | | |-- skin.go +| | | |-- loader.go +| | | |-- registry.go +| | | +-- embedded/ +| | | |-- themes/ # 10 theme YAMLs +| | | |-- profiles/ # 10 profile YAMLs +| | | +-- skins/ # gh-dash.yaml, minimal.yaml +| | | +| | |-- component/ # ALL components (ONE location) +| | | |-- header.go +| | | |-- navigation.go +| | | |-- controlbar.go +| | | |-- table.go +| | | |-- card.go +| | | |-- panel.go +| | | |-- hero.go +| | | |-- badge.go +| | | |-- tree.go +| | | |-- list.go +| | | |-- search.go +| | | |-- spinner.go +| | | |-- progress.go +| | | |-- form.go +| | | |-- viewport.go +| | | +-- markdown.go +| | | +| | |-- engine/ # Shell + Router + State +| | | |-- shell.go +| | | |-- router.go +| | | |-- state.go +| | | |-- view.go +| | | |-- context.go +| | | |-- launch.go +| | | +-- keys.go +| | | +| | +-- view/ # View implementations +| | |-- home.go +| | |-- services_list.go +| | |-- service_detail.go +| | |-- workspace_info.go +| | |-- workspace_history.go +| | |-- workspace_run.go +| | |-- config_overview.go +| | |-- version.go +| | +-- init_wizard.go +| | +| +-- cli/ # Cobra commands (THIN layer) +| |-- root.go +| |-- services.go +| |-- workspace.go +| |-- version.go +| |-- init.go +| +-- completion.go +| +|-- testdata/golden/ +|-- tests/ +| |-- integration/ +| +-- component/ +| +|-- .golangci.yml +|-- Makefile +|-- go.mod ++-- go.sum +``` +**Dependency direction (one-way, no cycles):** +``` +cli -> engine -> view -> component -> theme + | + (bubbles, huh, lipgloss, glamour) +``` +--- +## 9. Linting Constitution +Keep all 30 linters. Add path-based exclusions for UI: +```yaml +issues: + exclude-rules: + # Existing test exclusions (KEEP all) + - path: _test\.go + linters: [gosec, errcheck, dupl, funlen, gocyclo, cyclop, nestif, + goconst, gocritic, revive, unparam, nakedret, prealloc] + # NEW: UI views — Bubble Tea boilerplate is structurally identical + - path: pkg/ui/view/ + linters: [dupl] + # NEW: Engine — complex Update() switch statements are inherent + - path: pkg/ui/engine/ + linters: [gocyclo, cyclop] + # NEW: Components — lipgloss API passes types by value + - path: pkg/ui/component/ + linters: [gocritic] + text: "hugeParam" +``` +Backend stays enterprise-strict. Zero `//nolint` target for all new code. +--- +## 10. Testing Strategy +| Layer | What to Test | How | When | +|-------|-------------|-----|------| +| **Backend** | catalog, workspace, store | Unit tests + mocks | Must have | +| **Theme** | Profile/theme/skin loading | Unit tests | Must have | +| **Components** | Table, card, hero output | Golden file snapshots | Should have | +| **Engine** | Shell lifecycle, router | Headless Bubble Tea | Later | +| **Views** | Full view rendering | Golden files | Later | +| **Integration** | `arc services list --json` | CLI execution tests | Later | +--- +## 11. Phase-by-Phase Implementation +### Phase 1: Foundation (Week 1-2) +Build theme system + component library + engine shell. +- Week 1: `pkg/ui/theme/` (context, theme, profile, skin, loader, registry) +- Week 1: `pkg/ui/component/` first batch (header, controlbar, navigation, table, card) +- Week 2: `pkg/ui/component/` remaining (hero, badge, tree, list, spinner, viewport, etc.) +- Week 2: `pkg/ui/engine/` (shell, router, state, view interface, launch) +- Week 2: `internal/app/bootstrap.go` — kill init() side effects +**Milestone**: `arc` opens empty shell with themed header + tabs + controlbar. `q` quits. +### Phase 2: Theme + Skin System (Week 3) +Wire profile/theme/skin switching live. +- StateManager.ChangeProfile() — atomic switch +- StateChangedMsg handling in Shell — re-render frame +- Skin rendering in navigation (sidebar vs tab-bar) +- Placeholder Config view for profile switching +**Milestone**: Change profile -> entire UI updates live (colors, borders, logo, layout). +### Phase 3: Dashboard — Home + Services (Week 4) +First two real tabs. +- view/home.go — Hero + quick actions + system info +- view/services_list.go — Catalog table with search/filter +- view/service_detail.go — Service info card + deps tree +- Router wiring for tab switching +- Focused mode: `arc services list` +- JSON mode: `arc services list --json` +**Milestone**: 2-tab dashboard with real data. Services searchable from catalog. +### Phase 4: Workspace + Config Tabs (Week 5) +Complete the 4-tab dashboard. +- view/workspace_info.go + workspace_history.go +- view/config_overview.go (profile, theme, skin pickers) +- view/version.go +- Focused mode for remaining commands +**Milestone**: Full 4-tab dashboard, all tabs with real data. +### Phase 5: Wizards + Remaining Commands (Week 6) +Interactive flows. +- view/init_wizard.go (huh form) +- view/workspace_run.go (progress) +- Wire all remaining Cobra commands +**Milestone**: All commands work. +### Phase 6: Cleanup + Polish (Week 7) +Ship it. +- Delete all old UI code +- Final lint pass (zero //nolint target) +- Update README, agent doc, CLAUDE.md +- Performance validation (<100ms startup, <16ms tab switch, <20MB memory) +- Merge to develop +--- +## 12. Task Breakdown +### Phase 1: Foundation (37 tasks) +| # | Task | Depends | Est | +|---|------|---------|-----| +| T001 | Create worktree, scaffold folders | — | 1h | +| T002 | Delete old pkg/ui/ contents | T001 | 30m | +| T003 | theme/theme.go — Theme + ColorSet | T001 | 2h | +| T004 | theme/profile.go — Profile struct | T003 | 1h | +| T005 | theme/skin.go — Skin + enums | T003 | 2h | +| T006 | theme/context.go — theme.Context | T003-T005 | 2h | +| T007 | theme/registry.go — Style cache | T006 | 2h | +| T008 | theme/loader.go — YAML loader | T006 | 3h | +| T009 | Port theme YAML files | T008 | 30m | +| T010 | Port profile YAML files | T008 | 30m | +| T011 | Create skin YAML files (gh-dash, minimal) | T005 | 1h | +| T012 | component/header.go | T006 | 2h | +| T013 | component/controlbar.go | T006 | 2h | +| T014 | component/navigation.go (skin-dependent) | T006, T005 | 4h | +| T015 | component/table.go | T006 | 3h | +| T016 | component/card.go | T006 | 1h | +| T017 | component/panel.go | T006 | 1h | +| T018 | component/hero.go | T006 | 2h | +| T019 | component/badge.go | T006 | 1h | +| T020 | component/tree.go | T006 | 2h | +| T021 | component/list.go (wraps bubbles) | T006 | 2h | +| T022 | component/search.go (wraps bubbles) | T006 | 1h | +| T023 | component/spinner.go (wraps bubbles) | T006 | 1h | +| T024 | component/progress.go (wraps bubbles) | T006 | 1h | +| T025 | component/form.go (wraps huh) | T006 | 2h | +| T026 | component/viewport.go (wraps bubbles) | T006 | 1h | +| T027 | component/markdown.go (wraps glamour) | T006 | 1h | +| T028 | engine/view.go — View interface | — | 1h | +| T029 | engine/context.go — ViewContext | T006 | 1h | +| T030 | engine/state.go — StateManager | T006, T008 | 3h | +| T031 | engine/router.go — Router + OnEnter guarantee | T028-T030 | 3h | +| T032 | engine/keys.go — Global keys | — | 30m | +| T033 | engine/shell.go — Shell model | T012-T014, T031 | 5h | +| T034 | engine/launch.go — Start() entry | T033 | 2h | +| T035 | internal/app/bootstrap.go | T030 | 2h | +| T036 | Update cmd/arc/main.go | T035 | 1h | +| T037 | MILESTONE: Empty shell renders | T036 | 1h | +### Phase 2: Theme + Skin (7 tasks) +| # | Task | Depends | Est | +|---|------|---------|-----| +| T038 | StateManager.ChangeProfile() | T030 | 3h | +| T039 | StateChangedMsg in Shell | T033, T038 | 2h | +| T040 | Skin rendering in navigation | T014, T005 | 3h | +| T041 | Placeholder Config view | T038 | 3h | +| T042 | Theme system golden tests | T008 | 3h | +| T043 | Component golden tests | T015-T018 | 3h | +| T044 | MILESTONE: Profile switch works live | T041 | 1h | +### Phase 3: Home + Services (7 tasks) +| # | Task | Depends | Est | +|---|------|---------|-----| +| T045 | view/home.go | T018, T016 | 4h | +| T046 | view/services_list.go | T015, T021, T022 | 5h | +| T047 | view/service_detail.go | T016, T020 | 4h | +| T048 | Router: Home <-> Services tab switching | T031, T045, T046 | 2h | +| T049 | Focused mode: arc services list | T034, T046 | 2h | +| T050 | JSON mode: arc services list --json | T034 | 1h | +| T051 | MILESTONE: 2-tab dashboard with real data | T048 | 1h | +### Phase 4: Workspace + Config (8 tasks) +| # | Task | Depends | Est | +|---|------|---------|-----| +| T052 | view/workspace_info.go | T016, T015 | 3h | +| T053 | view/workspace_history.go | T015 | 3h | +| T054 | Complete view/config_overview.go | T041, T021 | 4h | +| T055 | view/version.go | T016 | 2h | +| T056 | Router: all 4 tabs wired | T045-T054 | 2h | +| T057 | Focused mode: workspace info, version | T034 | 2h | +| T058 | JSON mode: workspace info, version | T034 | 1h | +| T059 | MILESTONE: Full 4-tab dashboard | T056 | 1h | +### Phase 5: Wizards + Commands (6 tasks) +| # | Task | Depends | Est | +|---|------|---------|-----| +| T060 | view/init_wizard.go | T025 | 4h | +| T061 | view/workspace_run.go | T023, T024 | 3h | +| T062 | Wire arc init | T060 | 1h | +| T063 | Wire arc workspace init/run | T060, T061 | 2h | +| T064 | arc completion (keep current) | — | 30m | +| T065 | MILESTONE: All commands work | T062-T064 | 1h | +### Phase 6: Cleanup (8 tasks) +| # | Task | Depends | Est | +|---|------|---------|-----| +| T066 | Delete all old UI code | T065 | 2h | +| T067 | Update .golangci.yml | T066 | 1h | +| T068 | Lint pass — zero //nolint target | T067 | 3h | +| T069 | Update README.md | T065 | 2h | +| T070 | Update agent doc | T065 | 2h | +| T071 | Update CLAUDE.md | T065 | 1h | +| T072 | Performance validation | T065 | 2h | +| T073 | MILESTONE: Merge to develop | T072 | 1h | +### Totals +| Phase | Tasks | Hours | +|-------|-------|-------| +| 1. Foundation | 37 | ~55h | +| 2. Theme + Skin | 7 | ~18h | +| 3. Home + Services | 7 | ~19h | +| 4. Workspace + Config | 8 | ~18h | +| 5. Wizards + Commands | 6 | ~11.5h | +| 6. Cleanup | 8 | ~14h | +| **Total** | **73** | **~135h (~7 weeks @ 20h/week)** | +--- +## What Gets Deleted (Phase 6, T066) +``` +pkg/ui/components/ # Old component tree +pkg/ui/views/ # Old view files +pkg/ui/engine/ # Old engine (replaced by new) +pkg/ui/legacy/ # Legacy UI +pkg/ui/factory.go # 622-line factory +pkg/ui/service.go # Old UI service +pkg/ui/styles/ # Global mutable colors +pkg/ui/animations/ # Animation framework +pkg/ui/layouts/ # Empty directory +pkg/ui/markdown/ # Folded into component/ +pkg/ui/profiles/ # Folded into theme/ +pkg/ui/themes/ # Folded into theme/ +pkg/cli/dashboard/ # Old dashboard +pkg/cli/middleware/ # ErrorBoundary +pkg/cli/errors/ # ArcError +pkg/cli/banner.go # Old banner +pkg/cli/init_profile_ui.go # Old init UI +internal/branding/ # Folded into theme/profile +``` +## What Stays Untouched +``` +pkg/catalog/ # Service catalog +pkg/workspace/ # Workspace management +pkg/store/ # Config store +pkg/scaffold/ # Templates +pkg/log/ # Logging +pkg/version/ # Version API +internal/config/ # arc.yaml parsing +internal/preferences/ # state.json +internal/terminal/ # TTY detection +internal/xdg/ # XDG directories +specs/ # All 17 spec histories +``` +--- +*"The CLI should feel like it was built from the heart."* +*— A.R.C. CLI v2 Rewrite Plan, March 2026* diff --git a/docs/developer/cli-rewrite-analysis.md b/docs/developer/cli-rewrite-analysis.md new file mode 100644 index 0000000..f8b8319 --- /dev/null +++ b/docs/developer/cli-rewrite-analysis.md @@ -0,0 +1,856 @@ +# If A.R.C. CLI — Honest Analysis & Rewrite Plan + +> **Date**: March 3, 2026 +> **Author**: Architecture Review +> **Status**: Proposal +> **Scope**: Complete CLI rewrite strategy + +--- + +## Table of Contents + +1. [Executive Summary](#1-executive-summary) +2. [What's Wrong — Honest Analysis](#2-whats-wrong--honest-analysis) +3. [Language & Framework Decision](#3-language--framework-decision) +4. [The New Architecture](#4-the-new-architecture) +5. [New Folder Structure](#5-new-folder-structure) +6. [UI Component System](#6-ui-component-system) +7. [Migration Strategy](#7-migration-strategy) +8. [New Constitution (Linting & Standards)](#8-new-constitution-linting--standards) +9. [Implementation Phases](#9-implementation-phases) +10. [Repository Strategy](#10-repository-strategy) + +--- + +## 1. Executive Summary + +The A.R.C. CLI has 17 completed specs, ~120+ Go source files, 30+ component/view files, and a rich service catalog. **The backend logic is solid** — workspace management, service catalog, scaffold templates, config parsing — this is good work. + +**What's broken is the UI layer.** It was over-engineered across 3 specs (015, 016, 017) into an architecture that looks impressive on paper but delivers a terrible user experience: black screens, unresponsive terminals, placeholder content, and two competing dashboard systems. + +**The recommendation: Stay in Go. Rewrite the UI layer. Keep the backend.** + +Don't throw away the baby with the bathwater — `pkg/catalog/`, `pkg/workspace/`, `pkg/store/`, `pkg/scaffold/`, `internal/config/` are battle-tested and well-structured. What needs to die is the over-abstracted UI engine, the triple-layered component system, and the "every command needs a full-screen TUI" philosophy. + +--- + +## 2. What's Wrong — Honest Analysis + +### 🔴 CRITICAL: Terminal Goes Black + +**Root Cause**: `engine.Render()` in `pkg/ui/engine/render.go` launches a Bubble Tea program with `tea.WithAltScreen()` but **never calls `view.OnEnter(ctx)`**. The `OnEnter` method is where views initialize their child components (hero, sidebar, status bar). + +```go +// root.go line 123 — HomeView launched WITHOUT OnEnter +homeView := views.NewHomeView(appContext.Factory) +engine.Render(engine.RenderConfig{ + View: homeView, // hero == nil, statusBar == nil + Mode: engine.TUIMode, +}) + +// homeview.go line 80 — Returns empty string when components are nil +func (v *HomeView) View() string { + if v.hero == nil || v.statusBar == nil { + return "" // ← BLACK SCREEN + } +} +``` + +Running bare `arc` = alt screen (terminal goes black) + empty view = user must Ctrl+C. This is the #1 UX killer. + +### 🔴 CRITICAL: Two Dashboards, Neither Works + + +| System | Location | LOC | State | +| ---------------- | ------------------------------- | --- | ----------------------------- | +| Legacy Dashboard | `pkg/cli/dashboard/app.go` | 692 | Functional but deprecated | +| New Dashboard | `pkg/ui/views/dashboardview.go` | 362 | Broken — placeholder content | + +The legacy dashboard has working tabs (Dashboard/Services/Workspace/Config), a toast system, and real data. The new dashboard has a sidebar with hardcoded strings like "No services available" and "Configure your settings here." **The migration downgraded the product.** + +### 🟠 HIGH: Triple Component System + +Three parallel implementations exist for the same UI concepts: + + +| Concept | Location 1 (flat) | Location 2 (subdir) | Location 3 (factory inline) | +| ---------- | ---------------------------------------- | --------------------------------------------- | --------------------------- | +| Table | `components/table.go` (hardcoded colors) | `components/table/datatable.go` (themed) | `factory.go` (own impl) | +| Split Pane | `components/split_pane.go` | `components/splitpane/splitpane.go` | `factory.go` (own impl) | +| Status Bar | `components/status_rail.go` (406 lines) | `components/status/statusbar.go` (194 lines) | `factory.go` (own impl) | +| Progress | `components/progress.go` (266 lines) | `components/progress/progress.go` (230 lines) | — | +| Tab Bar | `components/tab_bar.go` (338 lines) | — | `factory.go` (own impl) | + +`factory.go` alone is **622 lines** with inline implementations that duplicate what the component files already do. Nobody knows which one to use. + +### 🟠 HIGH: Over-Engineering Disease + +The codebase has: + +- A **View** interface with 7 methods (`Init`, `Update`, `View`, `OnEnter`, `OnExit`, `Name`, `Keybindings`) +- A **Router** with history stack (max 10 depth) — but no command actually uses `Back()` navigation +- A **ComponentCache** with LRU eviction (max 50 entries) — but views are created once and destroyed +- A **ComponentFactory** interface — but most commands construct components directly +- A **StyleRegistry** — but colors are still hardcoded in 15+ places +- An **ErrorBoundary** with 4 render paths — but most commands use `fmt.Errorf` + +This is architecture for architecture's sake. The CLI has ~20 commands, not a web application. The overhead of Router → ViewContext → ComponentFactory → StyleRegistry → SafeBorder → ProfileContext for rendering a simple table is absurd. + +### 🟠 HIGH: `init()` Global Side Effects + +`root.go` has TWO `init()` functions that: + +1. Load preferences from disk +2. Mutate global `styles` package variables +3. Create hidden dependencies between packages + +This runs before `main()` with no test control. The code itself documents this as tech debt: + +> *"Hampers testability, violates Dependency Inversion Principle, makes parallel test execution risky"* + +### 🟡 MODERATE: Linting Pain (But Not 48 Linters) + +Actual count: **30 linters** enabled (the agent doc says 48 — wrong). But the `dupl` linter at threshold 100 is the main pain point: + +- Every Bubble Tea view has identical `WindowSizeMsg` handling → flagged as duplicate +- Every list view has identical `Update` dispatch → flagged as duplicate +- Result: 20+ `//nolint:dupl` directives with explanatory comments + +The `gocyclo`/`cyclop` at max 15 is too strict for UI update loops which are inherently switch-heavy. `gocritic` with ALL tags enabled flags stylistic preferences as errors. + +### 🟡 MODERATE: Documentation Lies + + +| Document says | Reality | +| ------------------------------------------------------ | ------------------------------------------- | +| `CLAUDE.md`: bubbles v0.21.0, bubbletea v1.3.4 | `go.mod`: bubbles v1.0.0, bubbletea v1.3.10 | +| Agent doc: 48 linters | `.golangci.yml`: 30 linters | +| Agent doc: Branch 015-ui-refactor | Current branch: 017-ui-engine | +| 017 plan: subdirectory-per-view (`views/home/home.go`) | Reality: flat files (`views/homeview.go`) | +| 017 plan:`pkg/ui/layouts/` with layout containers | Reality: empty directory with`.gitkeep` | + +### 🟡 MODERATE: `lipgloss` Pinned to Unreleased Commit + +``` +charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 +``` + +This is a pre-release commit hash, not a stable tag. Could introduce instability. + +### 📊 By the Numbers + + +| Metric | Count | +| ---------------------------------------- | ---------------- | +| View files (`pkg/ui/views/`) | 33 (incl. tests) | +| Component flat files | 20+ | +| Component subdirectories | 10 | +| Factory.go inline implementations | 622 lines | +| `//nolint` directives (non-test) | 30+ | +| Legacy dashboard files | 5 | +| Commands that actually use Engine | ~8 of 20 | +| Commands that work correctly with new UI | ~5 | + +--- + +## 3. Language & Framework Decision + +### Evaluation + + +| Option | Startup | Binary | Dev Productivity | Risk | +| -------------------------------------- | -------- | ------- | -------------------- | --------- | +| **Go + Charmbracelet (refactored)** | 5-15ms | 15-20MB | Medium | Low | +| **Go Hybrid** (static + selective TUI) | 5-15ms | 12-18MB | High | Low | +| **Rust + Ratatui** | 1-5ms | 3-8MB | Low (learning curve) | High | +| **TypeScript/Bun + Ink** | 50-150ms | 50-90MB | Highest | Very High | + +### Verdict: **Stay in Go. Use the Hybrid approach.** + +**Why not switch languages:** + +1. **The backend is good.** `pkg/catalog/`, `pkg/workspace/`, `pkg/store/`, `internal/config/` — thousands of lines of tested, working Go code. Rewriting in Rust means 3-6 months before you're back to parity. +2. **The problem isn't Go or Charmbracelet.** The problem is an over-abstracted UI architecture layered on top. Bubble Tea itself is excellent — `huh` for forms, `lipgloss` for styling, `glamour` for markdown. The libraries are fine; the usage is wrong. +3. **Team expertise is in Go.** Switching to Rust means 2-4 months of ramp-up before anyone is productive on UI code. +4. **Single binary + <100ms startup.** Only Go and Rust deliver this. Bun compile produces 50MB+ binaries with variable startup. + +### The Hybrid Approach + +**Most commands don't need a full-screen TUI.** When you run `arc version`, you want instant output, not an alt-screen with keyboard navigation. The key insight: + + +| Command Type | Rendering | Example | +| ----------------- | -------------------------------------- | -------------------------------------------- | +| **Info commands** | Styled static output (lipgloss) | `arc version`, `arc info`, `arc theme list` | +| **CRUD commands** | Styled static output + prompts | `arc config set-profile`, `arc theme set` | +| **Wizards** | Interactive TUI (huh forms) | `arc init`, `arc workspace init` | +| **Dashboard** | Full-screen TUI (bubbletea) | `arc dashboard` | +| **List/Browse** | Styled table + optional`--interactive` | `arc services list`, `arc workspace history` | + +Only **2-3 commands** actually need full bubbletea. The rest are simpler and better as styled prints. + +--- + +## 4. The New Architecture + +### Core Principles (Updated) + +1. **Instant output by default** — Commands print styled results and exit. No alt-screen unless explicitly interactive. +2. **Progressive interactivity** — `--interactive` / `-i` flag upgrades to TUI mode. Default is static. +3. **One component, one location** — No duplicates. Period. +4. **Backend unchanged** — `pkg/catalog/`, `pkg/workspace/`, `pkg/store/`, `internal/config/` stay as-is. +5. **Convention over configuration** — Folder structure IS the documentation. + +### Rendering Strategy + +``` +┌─────────────────────────────────────────────────────┐ +│ Command Layer │ +│ (Cobra commands in cmd/arc/...) │ +├─────────────┬─────────────┬─────────────────────────┤ +│ Static │ Form │ TUI │ +│ Renderer │ Renderer │ Renderer │ +│ (lipgloss │ (huh │ (bubbletea │ +│ + glamour)│ forms) │ full-screen) │ +├─────────────┴─────────────┴─────────────────────────┤ +│ UI Components Library │ +│ (table, card, panel, hero, status, badge, tree) │ +├─────────────────────────────────────────────────────┤ +│ Theme & Profile System │ +│ (ProfileContext, Theme, Colors — single source) │ +├─────────────────────────────────────────────────────┤ +│ Backend Services │ +│ (catalog, workspace, store, config, scaffold) │ +└─────────────────────────────────────────────────────┘ +``` + +### Output Modes (Simpler than Engine) + +```go +// Instead of engine.Render() with 3 modes, use direct output functions: + +// Static output (default for most commands) +func PrintStyled(ctx *app.Context, content string) +func PrintTable(ctx *app.Context, headers []string, rows [][]string) +func PrintCard(ctx *app.Context, title, body string) +func PrintJSON(data any) // when --json flag is set + +// Interactive (explicit opt-in) +func RunForm(ctx *app.Context, form *huh.Form) error // huh wizard +func RunTUI(ctx *app.Context, model tea.Model) error // bubbletea full-screen +``` + +No Router. No ViewContext. No ComponentCache. No RenderConfig. Just functions. + +--- + +## 5. New Folder Structure + +``` +arc-cli/ +├── cmd/ +│ └── arc/ +│ └── main.go # Entry: config → context → root.Execute() +│ +├── internal/ # Private packages (KEEP as-is, minimal changes) +│ ├── app/ +│ │ ├── context.go # DI container (simplified — no lazy ProfileContext) +│ │ └── bootstrap.go # Explicit init (replaces init() side effects) +│ ├── config/ # arc.yaml parsing (KEEP) +│ ├── preferences/ # state.json (KEEP) +│ ├── terminal/ # TTY detection (KEEP) +│ └── xdg/ # XDG dirs (KEEP) +│ +├── pkg/ +│ ├── catalog/ # Service catalog (KEEP entirely) +│ ├── workspace/ # Workspace management (KEEP entirely) +│ ├── store/ # Config store (KEEP entirely) +│ ├── scaffold/ # Templates (KEEP entirely) +│ ├── version/ # Version info (KEEP) +│ ├── log/ # Logging (KEEP) +│ │ +│ ├── ui/ # ✨ NEW — Single unified UI package +│ │ ├── theme/ # Theme + Profile system (merged) +│ │ │ ├── theme.go # Theme struct, ColorSet +│ │ │ ├── profile.go # Profile struct, ProfileContext +│ │ │ ├── loader.go # Load themes + profiles from embedded YAML +│ │ │ ├── registry.go # StyleRegistry (cached lipgloss styles) +│ │ │ └── embedded/ # YAML files (themes + profiles together) +│ │ │ ├── themes/ +│ │ │ └── profiles/ +│ │ │ +│ │ ├── component/ # ✨ ONE location for all components +│ │ │ ├── table.go # Themed table (single implementation) +│ │ │ ├── card.go # Card component +│ │ │ ├── panel.go # Bordered panel +│ │ │ ├── hero.go # Hero/banner component +│ │ │ ├── badge.go # Status badges +│ │ │ ├── tree.go # Tree visualization +│ │ │ ├── spinner.go # Animated spinner +│ │ │ ├── progress.go # Progress bar +│ │ │ ├── header.go # Page header +│ │ │ ├── footer.go # Page footer +│ │ │ └── search.go # Fuzzy search input +│ │ │ +│ │ ├── render/ # Output rendering utilities +│ │ │ ├── static.go # PrintStyled, PrintTable, PrintCard +│ │ │ ├── json.go # PrintJSON +│ │ │ ├── tui.go # RunTUI (bubbletea wrapper) +│ │ │ ├── form.go # RunForm (huh wrapper) +│ │ │ └── markdown.go # Glamour markdown rendering +│ │ │ +│ │ └── layout/ # Layout utilities +│ │ ├── width.go # ANSI-aware width calculations +│ │ └── flex.go # Simple flex layout helpers +│ │ +│ └── cli/ # ✨ NEW — Command layer +│ ├── root.go # Root command + subcommand registration +│ ├── middleware.go # Error handling, flag parsing +│ │ +│ ├── version/ # arc version +│ │ └── cmd.go # Static output, no TUI +│ │ +│ ├── info/ # arc info +│ │ └── cmd.go # Static styled output +│ │ +│ ├── init/ # arc init +│ │ ├── cmd.go # Command entry +│ │ └── wizard.go # huh form wizard (interactive) +│ │ +│ ├── theme/ # arc theme {list,set,preview} +│ │ └── cmd.go # Static table output +│ │ +│ ├── config/ # arc config {get,set,list-profiles} +│ │ └── cmd.go # Static output + prompts +│ │ +│ ├── services/ # arc services {list,info,deps,ports} +│ │ └── cmd.go # Static table output +│ │ +│ ├── workspace/ # arc workspace {init,run,info,history} +│ │ ├── cmd.go # Subcommand registration +│ │ ├── init.go # huh form wizard +│ │ ├── run.go # Progress spinner +│ │ ├── info.go # Static output +│ │ └── history.go # Static table +│ │ +│ └── dashboard/ # arc dashboard (the ONLY full-screen TUI) +│ ├── cmd.go # Bubbletea launcher +│ ├── model.go # Dashboard model (tabs, state) +│ └── views.go # Tab content renderers +│ +├── testdata/golden/ # Golden file snapshots +├── tests/ +│ ├── integration/ +│ └── e2e/ +│ +├── .golangci.yml # Pragmatic linting config +├── Makefile # Build, test, lint +├── go.mod +└── go.sum +``` + +### Key Differences from Current Structure + + +| Current (Problem) | New (Solution) | +| ------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `pkg/ui/components/` (20+ flat files) + `pkg/ui/components/hero/`, `sidebar/`, `table/`, etc. (10 subdirs) + `factory.go` (622-line inline impls) | `pkg/ui/component/` — ONE flat package, ONE implementation per concept | +| `pkg/ui/engine/` (view, router, cache, context, factory) | Deleted. Functions in`pkg/ui/render/` replace the entire engine | +| `pkg/ui/views/` (33 files, one per command screen) | Deleted. Each command renders directly — no View interface | +| `pkg/ui/profiles/` + `pkg/ui/themes/` (separate packages) | `pkg/ui/theme/` — merged into one package | +| `pkg/ui/styles/colors.go` (global mutable colors) | Deleted. Colors always from`theme.ProfileContext` | +| `pkg/ui/legacy/` (deprecated) | Deleted | +| `pkg/ui/animations/` (spring physics, framerate) | Deleted (spinner/progress in`component/`, no banner animations) | +| `pkg/cli/dashboard/` (legacy) + `pkg/ui/views/dashboardview.go` (new) | `pkg/cli/dashboard/` — ONE dashboard, works properly | +| `pkg/cli/errors/` + `pkg/cli/middleware/` | `pkg/cli/middleware.go` — single file, simple error wrapping | + +**Total package count**: Current ~25+ UI packages → New ~5 UI packages + +--- + +## 6. UI Component System + +### Design Philosophy + +Every component follows this contract: + +```go +// Components are functions, not types (for simple cases) +func RenderTable(ctx *theme.Context, headers []string, rows [][]string) string + +// Components are structs only when they need state (spinner, progress) +type Spinner struct { + theme *theme.Context + // ... +} +``` + +### Component API Examples + +```go +package component + +import "github.com/arc-framework/arc-cli/pkg/ui/theme" + +// Table renders a themed, auto-sized table +func Table(tc *theme.Context, headers []string, rows [][]string) string { + // Use lipgloss for styling, tc.Colors() for theming + // Use lipgloss.Width() for column sizing (NEVER len()) + // Returns a styled string ready for printing +} + +// Card renders a bordered card with title and body +func Card(tc *theme.Context, title, body string) string + +// Hero renders the profile banner/logo +func Hero(tc *theme.Context, width int) string + +// Badge renders a colored status badge +func Badge(tc *theme.Context, text string, level BadgeLevel) string + +// Tree renders a dependency tree +func Tree(tc *theme.Context, root string, children map[string][]string) string + +// Panel renders a bordered content panel +func Panel(tc *theme.Context, title, content string) string +``` + +### When to Use Structs vs Functions + + +| Use Function | Use Struct | +| ------------------- | --------------------------------------------- | +| Table (render once) | Spinner (animated, needs Update loop) | +| Card (render once) | Progress (animated, needs Update loop) | +| Badge (render once) | SearchInput (interactive, needs key handling) | +| Tree (render once) | Dashboard model (full-screen TUI) | +| Panel (render once) | Wizard form (multi-step interactive) | + +### Build Order + +1. **`theme/`** — Theme, Profile, Colors, StyleRegistry (everything else depends on this) +2. **`component/table.go`** — Used by most commands +3. **`component/card.go`** + **`component/panel.go`** — Used by info/status displays +4. **`component/hero.go`** — Used by banner/dashboard +5. **`component/badge.go`** — Used by service status +6. **`render/static.go`** — Wire components to stdout +7. **`render/json.go`** — JSON output mode + +--- + +## 7. Migration Strategy + +### Phase 0: Setup (Week 1) + +**Same repo, new branch.** Don't create a new repo — you need access to existing backend packages. + +```bash +git checkout -b 018-ui-rewrite +``` + +Create the new folder structure alongside the old one. Both UI systems coexist during migration. + +### Phase 1: Foundation (Week 1-2) + +Build from the bottom up: + +1. **`pkg/ui/theme/`** — Extract from `pkg/ui/themes/` + `pkg/ui/profiles/`. Merge into one package. Keep embedded YAML files. Simplify `ProfileContext` (remove double-checked locking — just initialize eagerly in bootstrap). +2. **`internal/app/bootstrap.go`** — Replace `init()` in root.go. Explicit initialization: + + ```go + func Bootstrap(cfg *config.Config) (*Context, error) { + prefs := preferences.Load() + tc := theme.LoadFromPreferences(prefs) + // ... build context with everything initialized + } + ``` +3. **`pkg/ui/component/`** — Port the BEST version of each component. Use theme.Context for colors. No hardcoded values. One file per component. +4. **`pkg/ui/render/`** — Simple output functions. No engine, no router. + +### Phase 2: Migrate One Command (Week 2-3) + +**Start with `arc version`** — it's the simplest: + +```go +// pkg/cli/version/cmd.go +package version + +func NewCmd(ctx *app.Context) *cobra.Command { + var jsonFlag bool + var verbose bool + + cmd := &cobra.Command{ + Use: "version", + Short: "Show version information", + RunE: func(cmd *cobra.Command, args []string) error { + if jsonFlag { + return render.JSON(map[string]string{ + "version": version.Version, + "commit": version.Commit, + "date": version.BuildDate, + "go": runtime.Version(), + }) + } + + tc := ctx.ThemeContext() + if verbose { + fmt.Println(component.Table(tc, + []string{"Field", "Value"}, + [][]string{ + {"Version", version.Version}, + {"Commit", version.Commit}, + {"Build Date", version.BuildDate}, + {"Go", runtime.Version()}, + {"OS/Arch", runtime.GOOS + "/" + runtime.GOARCH}, + }, + )) + } else { + fmt.Printf("arc %s (%s)\n", version.Version, version.Commit) + } + return nil + }, + } + cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON") + cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Show detailed info") + return cmd +} +``` + +**That's it.** No View interface, no OnEnter, no Router, no ViewContext, no ComponentCache. Just Cobra + styled output. Fast, testable, readable. + +### Phase 3: Migrate All Static Commands (Week 3-4) + +In order (easiest first): + +1. `arc version` ✓ (done in Phase 2) +2. `arc info` — System info table +3. `arc theme list` — Theme table with color swatches +4. `arc theme set` — One prompt + confirmation +5. `arc config get-profile` / `set-profile` / `list-profiles` +6. `arc services list` — Catalog table +7. `arc services info` — Service detail card +8. `arc services deps` — Dependency tree +9. `arc services ports` — Port table +10. `arc workspace info` — Workspace status card +11. `arc workspace history` — Operation history table + +Each command takes 1-2 hours to migrate. They're all "fetch data → render styled output." + +### Phase 4: Migrate Interactive Commands (Week 4-5) + +1. `arc init` — huh form wizard (already uses huh, just reorganize) +2. `arc workspace init` — huh form wizard +3. `arc workspace run` — Spinner/progress output + +### Phase 5: Build New Dashboard (Week 5-6) + +One bubbletea model. Not a View + Router + Engine. Just: + +- `model.go` — Dashboard state (active tab, data) +- `views.go` — Tab content renderers +- `cmd.go` — Launch command + +The dashboard is the ONLY full-screen TUI in the entire CLI. + +### Phase 6: Cleanup (Week 6-7) + +1. Delete `pkg/ui/engine/` +2. Delete `pkg/ui/views/` +3. Delete `pkg/ui/legacy/` +4. Delete `pkg/ui/components/` (old) +5. Delete `pkg/ui/factory.go` +6. Delete `pkg/ui/styles/` +7. Delete `pkg/ui/animations/` +8. Delete `pkg/cli/dashboard/` (old) +9. Update agent doc, CLAUDE.md, README + +--- + +## 8. New Constitution (Linting & Standards) + +### Philosophy + +**Pragmatic enterprise Go.** Lint rules should catch real bugs, not fight the language or the framework. + +### New `.golangci.yml` + +```yaml +run: + timeout: 5m + go: '1.24' + +linters: + disable-all: true + enable: + # === Correctness (non-negotiable) === + - errcheck # Unchecked errors + - govet # Go vet checks + - staticcheck # Comprehensive static analysis + - unused # Dead code + - ineffassign # Useless assignments + - bodyclose # HTTP response body not closed + - nilerr # Returning nil when err != nil + - errorlint # Error wrapping patterns + - copyloopvar # Loop variable capture bugs + + # === Formatting (automated, never argue about) === + - gofumpt # Strict formatting + - gci # Import ordering + - whitespace # Trailing whitespace + - misspell # Typos + + # === Security === + - gosec # Security vulnerabilities + + # === Quality (with sane thresholds) === + - gocritic # Opinionated but useful (with exclusions) + - revive # Better golint + - goconst # Repeated string literals + + # === Removed vs Current === + # REMOVED: dupl (fights Bubble Tea patterns) + # REMOVED: gocyclo, cyclop (UI update loops are inherently complex) + # REMOVED: nestif (sometimes nesting is clearer than extraction) + # REMOVED: unparam (false positives on interface implementations) + # REMOVED: nakedret (Go convention, not a bug) + # REMOVED: prealloc (premature optimization) + # REMOVED: makezero (unnecessary for our patterns) + # REMOVED: contextcheck (too many false positives) + # REMOVED: nolintlint (we shouldn't need nolint directives anymore) + # REMOVED: usestdlibvars (marginal value) + +linters-settings: + gofumpt: + extra-rules: true + + gci: + sections: + - standard + - default + - prefix(github.com/arc-framework/arc-cli) + + govet: + enable-all: true + disable: + - fieldalignment # Premature optimization + settings: + shadow: + strict: false # Relaxed — strict shadow checking is noisy + + gocritic: + enabled-tags: + - diagnostic + - performance + disabled-tags: + - experimental # Too noisy + - opinionated # Fights Go conventions + - style # Handled by gofumpt + + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: error-return + - name: error-strings + - name: error-naming + - name: var-naming + - name: receiver-naming + - name: unexported-return + - name: unreachable-code + + gosec: + excludes: + - G304 # File path injection (controlled paths) + - G301 # Directory perms (0755 is fine) + - G306 # File perms (0644 is fine) + confidence: high + severity: high + + goconst: + min-len: 3 + min-occurrences: 3 + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + exclude-rules: + - path: _test\.go + linters: + - gosec + - errcheck + - gocritic + - revive + - goconst + - path: testdata/ + linters: + - all +``` + +### Linter Count: 20 (down from 30) + +**Removed 10 linters** that cause the most friction with minimal value: + +- `dupl` — The #1 source of `//nolint` directives. Bubble Tea patterns are inherently repetitive. +- `gocyclo`/`cyclop` — Switch statements in `Update()` methods are naturally complex. +- `nestif` — Sometimes nested `if` is clearer than extracting helper functions. +- `nolintlint` — If we don't need `//nolint` directives, we don't need a linter to police them. + +**Target: ZERO `//nolint` directives** in the new codebase. + +### Code Standards + +```go +// === Import order (enforced by gci) === +import ( + "fmt" // stdlib + "os" + + "github.com/charmbracelet/lipgloss" // external + "github.com/spf13/cobra" + + "github.com/arc-framework/arc-cli/internal/app" // internal + "github.com/arc-framework/arc-cli/pkg/ui/theme" +) + +// === Error handling === +// Simple wrapping for most cases +if err != nil { + return fmt.Errorf("loading config: %w", err) +} + +// === Width calculations === +// ALWAYS use lipgloss.Width() for styled strings +width := lipgloss.Width(styledText) // ✅ +width := len(styledText) // ❌ NEVER + +// === Colors === +// ALWAYS from theme context +color := tc.Colors().Primary // ✅ +color := lipgloss.Color("#00ADD8") // ❌ NEVER + +// === Testing === +// Table-driven tests, testify assertions +func TestTable(t *testing.T) { + tests := []struct{ + name string + // ... + }{ + // ... + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // ... + }) + } +} +``` + +--- + +## 9. Implementation Phases + +``` +Week 1 ┃ Phase 0+1: Setup + Foundation + ┃ - New branch 018-ui-rewrite + ┃ - pkg/ui/theme/ (merge profiles + themes) + ┃ - internal/app/bootstrap.go (kill init()) + ┃ - pkg/ui/component/ (port best components) + ┃ - pkg/ui/render/ (static, json, tui, form) + ┃ +Week 2-3 ┃ Phase 2+3: Migrate Static Commands + ┃ - arc version (proof of concept) + ┃ - arc info, theme, config, services, workspace + ┃ - Each command: 1-2 hours + ┃ - Kill old views as each migrates + ┃ +Week 4-5 ┃ Phase 4: Interactive Commands + ┃ - arc init (huh wizard) + ┃ - arc workspace init (huh wizard) + ┃ - arc workspace run (spinner) + ┃ +Week 5-6 ┃ Phase 5: Dashboard + ┃ - Single bubbletea model + ┃ - Tabs: Home, Services, Workspace, Config + ┃ - Real data, not placeholders + ┃ +Week 6-7 ┃ Phase 6: Cleanup + Polish + ┃ - Delete old UI packages + ┃ - Update docs, agent, README + ┃ - Final lint pass (zero nolint target) + ┃ - Performance validation +``` + +--- + +## 10. Repository Strategy + +### **Same repo, new branch. Not a new repo.** + +Reasons: + +1. **Backend packages stay untouched** — `pkg/catalog/`, `pkg/workspace/`, etc. are imported directly +2. **Git history preserved** — 17 specs of work, valuable for context +3. **CI/CD intact** — GitHub Actions, release pipeline, Homebrew formula all reference this repo +4. **Contributors don't get lost** — One repo, one source of truth +5. **Gradual migration** — Both UI systems coexist during transition + +The branch `018-ui-rewrite` becomes the working branch. When Phase 6 is done, merge to `develop`. + +### What Gets Deleted (in Phase 6) + +``` +DELETED: +├── pkg/ui/engine/ # Entire engine package +├── pkg/ui/views/ # All 33 view files +├── pkg/ui/legacy/ # Legacy UI +├── pkg/ui/components/ # Old component tree (replaced by component/) +├── pkg/ui/factory.go # 622-line factory +├── pkg/ui/styles/ # Global mutable colors +├── pkg/ui/animations/ # Animation framework +├── pkg/cli/dashboard/ # Legacy dashboard +├── pkg/cli/middleware/ # ErrorBoundary (replaced by simple middleware.go) +└── pkg/cli/errors/ # ArcError (replaced by standard error wrapping) +``` + +### What Gets Kept + +``` +KEPT (unchanged): +├── pkg/catalog/ # Service catalog +├── pkg/workspace/ # Workspace management +├── pkg/store/ # Config store +├── pkg/scaffold/ # Templates +├── pkg/log/ # Logging +├── pkg/version/ # Version info +├── internal/config/ # Config parsing +├── internal/preferences/ # State persistence +├── internal/terminal/ # TTY detection +├── internal/xdg/ # XDG directories +├── specs/ # All spec history +└── testdata/ # Golden files (regenerated) +``` + +--- + +## Summary + + +| Aspect | Current | New | +| --------------------- | -------------------------------------- | ---------------------------- | +| Language | Go | Go (stay) | +| UI Framework | Charmbracelet full-stack | Charmbracelet selective | +| Commands needing TUI | 20 (all) | 2-3 (dashboard, wizards) | +| UI packages | ~25 | ~5 | +| Component locations | 3 (flat + subdir + factory) | 1 | +| View files | 33 | 0 (commands render directly) | +| Engine complexity | Router + ViewContext + Cache + Factory | Functions | +| `//nolint` directives | 30+ | Target: 0 | +| Linters | 30 (fighting framework) | 20 (catching real bugs) | +| Build approach | Same repo, new branch | Same repo, new branch | +| Migration time | — | ~7 weeks | + +**The goal isn't to build a more complex CLI. It's to build a simpler one that feels like it was built from the heart.** + +--- + +*"Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away." — Antoine de Saint-Exupéry* diff --git a/docs/developer/cli-rewrite-discussion.md b/docs/developer/cli-rewrite-discussion.md new file mode 100644 index 0000000..ca568b3 --- /dev/null +++ b/docs/developer/cli-rewrite-discussion.md @@ -0,0 +1,287 @@ +# A.R.C. CLI Rewrite — Alignment Discussion + +> **Date**: March 3, 2026 +> **Status**: Discussion — awaiting your answers before final plan + +--- + +## ✅ Aligned Points (No Action Needed) + +### Point 1: Abstraction — One Clean Interface Layer + +- Keep abstraction between backend and UI +- Backend (stable) → Interface Contract → UI Layer (swappable) +- Engine stays, gets fixed + +### Point 2: Engine — Fix, Don't Kill + +- Keep the engine concept (View + Render modes) +- Fix `OnEnter()` lifecycle bug +- Wire views to real data +- Consolidate duplicate components + +### Point 4: Folder Structure + +- Flatter, max 2 directory hops to find anything +- Will propose in final plan + +### Point 5: Linting — Keep Rules, Smart Exclusions + +- Keep all 30 linters +- Add path exclusions for `pkg/ui/` (dupl, gocyclo, cyclop) +- Backend stays enterprise-strict +- UI gets breathing room + +### Point 6: Testing — Interfaces First + +- Must test: backend interfaces, theme loading, profile switching, state +- Golden files: key UI components (snapshot-based) +- Skip for now: individual view rendering, animation timing +- Later: integration tests for full TUI flows + +### Point 7: Components — Consolidate, Don't Discard + +- Pick best implementation of each component +- Delete duplicates +- Wire factory to the one real component +- Keep ComponentFactory pattern + +### Point 8: Go + Charmbracelet — Use Libraries Smartly + +- huh for forms, bubbles for widgets, lipgloss for styling, glamour for markdown +- Don't reinvent what libraries provide + +### Point 10: Better Engine Design + +- React-inspired: Shell + Router + ViewRegistry + StateManager +- Views receive ViewContext (props), manage internal state +- Unified lifecycle: Mount → Update → Render → Unmount + +--- + +## ❓ Questions — Need Your Input + +### Question 1: Command Behavior — How should `arc ` work? + +When a user runs `arc services list`, what should happen? + +**Option A**: Opens the full dashboard app and auto-navigates to the Services tab + +``` +$ arc services list +→ Full dashboard opens, Services tab is active, list is showing +→ User can tab to other sections, press q to quit +``` + +**Option B**: Each command opens its own focused TUI (like gh-dash opens directly to its view) + +``` +$ arc services list +→ Focused services list view opens (no dashboard tabs) +→ Keyboard navigation within that view only +→ Press q to quit +``` + +**Option C**: Bare `arc` opens full dashboard; `arc services list` can be both + +``` +$ arc +→ Full dashboard with tabs (Home, Services, Workspace, Config) + +$ arc services list +→ Rich TUI view of services list (focused, not full dashboard) + +$ arc services list --json +→ JSON output for scripting/piping +``` + +**Your pick (A / B / C or something else)?** + +> _Answer here:_ lets go with c + +--- + +### Question 2: Workspace-Scoped Preferences + +Should each workspace have its own appearance settings that override the global defaults? + +**Proposed behavior**: + +``` +~/.arc/state.json ← Global defaults (theme: cyan-purple, profile: enterprise) +./my-project/.arc/prefs.yaml ← Workspace override (theme: fire, profile: saiyan) + +$ cd ~/random-folder +$ arc info ← Uses global: cyan-purple + enterprise + +$ cd ~/my-project +$ arc info ← Auto-detects workspace, uses: fire + saiyan +``` + +**Questions**: + +- Do you want this workspace-scoped override? (yes / no / later) +- If yes, should it auto-detect on `cd` or require `arc workspace use`? +- File format: `.arc/prefs.yaml` inside workspace dir? + +> _Answer here:_ Yes, but later — global first, workspace scope in follow-up spec but have interface or way so it easy to do later + +--- + +### Question 3: UI Skin Versioning — How Far Do You Want to Go? + +You mentioned "create a version system, first version is gh-dash." + +**Level 1 — Theme Only** (colors, styles change; layout stays the same): + +```yaml +# profile: saiyan → fire theme +# Changes: colors, logo, tier names +# Layout stays: sidebar + content + control bar +``` + +**Level 2 — Theme + Layout Variant** (different arrangements per skin): + +```yaml +# skin: gh-dash → sidebar left + content right + status bottom +# skin: minimal → no sidebar, full-width content + status bottom +# skin: hacker → top tabs + split pane + no borders +``` + +**Level 3 — Full Skin Engine** (completely different component rendering per skin): + +```yaml +# skin: v1-ghdash → uses sidebar navigation +# skin: v2-modern → uses top tab bar + breadcrumbs +# Components render differently based on active skin +``` + +**Your pick (Level 1 / 2 / 3)?** + +> _Answer here:_ Level 2 — Theme + Layout Variant (same components, different layout rules via YAML) will it be easy if we chose 3 + +--- + +### Question 4: Banner / Home Screen + +Currently bare `arc` shows an ASCII banner + help text (or tries to open HomeView which breaks). + +With the new design, what should bare `arc` do? + +**Option A**: Opens full interactive dashboard (Home tab active, shows hero/logo + quick actions) +**Option B**: Opens full dashboard (Services tab active — most useful default) +**Option C**: Shows styled banner + enters dashboard automatically after 1-2 seconds + +**Your pick?** + +> _Answer here:_ here since we are moving towards gh based ui we can remove most of cmds and show them as view keeping only needed cmds arc should show dashboard + +--- + +### Question 5: Control Bar Design + +You want a unified control bar across all views. What should it show? + +**Proposed layout**: + +``` +┌──────────────────────────────────────────────────────────────┐ +│ [Tab1] [Tab2] [Tab3] [Tab4] ← Tab navigation │ +├──────────────────────────────────────────────────────────────┤ +│ │ +│ View Content Area │ +│ (swappable per tab) │ +│ │ +├──────────────────────────────────────────────────────────────┤ +│ ←/→ navigate │ ↑/↓ scroll │ / search │ q quit │ ? help │ +│ 🎨 saiyan │ 🔥 fire │ workspace: my-project │ +└──────────────────────────────────────────────────────────────┘ +``` + +- **Top**: Tab bar (always visible) +- **Middle**: View content (changes per tab/command) +- **Bottom**: Keybindings + active profile + active workspace + +**Does this match your vision? Anything to add/remove?** + +> **⭐ Recommended: Yes, with one tweak — add a header bar too** +> +> ``` +> ┌──────────────────────────────────────────────────────────────┐ +> │ ⚡ A.R.C. │ workspace: my-project │ profile: saiyan 🔥 │ ← Header (branding + context) +> ├──────────────────────────────────────────────────────────────┤ +> │ [🏠 Home] [📦 Services] [🔧 Workspace] [⚙ Config] │ ← Tab bar / Sidebar +> ├──────────────────────────────────────────────────────────────┤ +> │ │ +> │ View Content Area │ +> │ │ +> ├──────────────────────────────────────────────────────────────┤ +> │ ←/→ tab │ ↑/↓ scroll │ / search │ q quit │ ? help │ ← Control bar (unified) +> └──────────────────────────────────────────────────────────────┘ +> ``` +> +> Three persistent bars: +> +> - **Header** (1 line): Brand + workspace + profile — always visible, sets identity +> - **Navigation** (1 line): Tabs or sidebar depending on skin +> - **Control bar** (1-2 lines): Context-aware keybindings that change per active view +> +> The control bar is the key insight — it's ONE component that reads keybindings +> from the active view. Views don't render their own controls. They just expose +> `Keybindings() []KeyBinding` and the shell renders them. + +> _Your answer (agree / disagree / modify): _ Recommended: Yes, with one tweak — add a header bar too + +--- + +### Question 6: Worktree Strategy + +You mentioned using a git worktree. Two approaches: + +**Option A**: Worktree from current branch + +```bash +git worktree add ../arc-cli-v2 018-ui-rewrite +# Work in ../arc-cli-v2, share git history with main repo +# Backend packages accessible, merge back when done +``` + +**Option B**: Worktree from clean state + +```bash +git worktree add ../arc-cli-v2 --detach +# Start fresh, copy over only backend packages +# More isolation, harder to merge back +``` + +**Your pick (A / B)?** + +> _Answer here:_ + +Option A — From current branch, backend stays importable--- + +### Question 7: Priority Order + +If we can only ship one thing first, what matters most? + +- [ ] **A**: Working dashboard with real data (even if only 2-3 tabs) +- [ ] **B**: All commands working with basic TUI (even if not polished) +- [ ] **C**: Perfect component library (tested, themed, reusable) then build views +- [ ] **D**: Theme/skin system working end-to-end (profile switch = full visual change) + +**Rank these 1-4 (1 = first priority):** + +> _Answer here:_ + +C → D → A → B — Components first, then theme, then dashboard, then all commands--- + +## Next Steps + +Once you answer these 7 questions, I will: + +1. Rewrite the full analysis + plan at `docs/developer/cli-rewrite-analysis.md` +2. Create the new engine design document +3. Set up the worktree +4. Start building — components first, then one command, then expand + +**No code until we're aligned. Take your time with the answers.** diff --git a/docs/developer/ui-design.md b/docs/developer/ui-design.md new file mode 100644 index 0000000..254c2b0 --- /dev/null +++ b/docs/developer/ui-design.md @@ -0,0 +1,1500 @@ +# A.R.C. CLI v2 — UI Design & Implementation Plan + +> **Date**: March 3, 2026 +> **Branch**: `018-ui-rewrite` +> **Status**: FINAL — Aligned and ready for implementation +> **Approach**: Same repo, git worktree, rebuild UI from scratch, keep backend + +--- + +## Table of Contents + +1. [Decisions Summary](#1-decisions-summary) +2. [Architecture Overview](#2-architecture-overview) +3. [Engine Design — The Shell](#3-engine-design--the-shell) +4. [Component System](#4-component-system) +5. [Design System — React for CLI](#5-design-system--react-for-cli) +6. [Theme and Skin System](#6-theme-and-skin-system) +7. [State Management](#7-state-management) +8. [Command Strategy](#8-command-strategy) +9. [Folder Structure](#9-folder-structure) +10. [Linting Constitution](#10-linting-constitution) +11. [Testing Strategy](#11-testing-strategy) +12. [Phase-by-Phase Implementation](#12-phase-by-phase-implementation) +13. [Task Breakdown](#13-task-breakdown) +14. [Appendix A: Discussion & Alignment Notes](#appendix-a-discussion--alignment-notes) + +--- + +## 1. Decisions Summary + +These are locked in. No more discussion on these — we build. + +| Decision | Choice | Notes | +| ------------------ | ------------------------------- | ------------------------------------------------------------ | +| Language | **Go** | Keep existing backend, Charmbracelet ecosystem | +| Repo strategy | **Same repo, worktree** | `git worktree add ../arc-cli-v2 -b 018-ui-rewrite` | +| Engine | **Fix and improve**, not delete | React-inspired Shell + Router + Views | +| `arc` (bare) | **Opens dashboard** | Full interactive TUI, Home tab active | +| `arc ` | **Focused TUI view** | Rich interactive view for that command | +| `arc --json` | **JSON output** | Scripting/piping mode, no TUI | +| Skins | **Level 2+** | Theme + Layout Variant, interface designed for Level 3 later | +| Workspace prefs | **Later** | Global first, interface ready for workspace scope | +| Components | **Consolidate** | One implementation per concept, factory pattern kept | +| Linting | **Keep 30, smart exclusions** | Path-based exclusions for UI layer | +| Testing | **Interfaces first** | Backend contracts + golden files for key components | +| Priority | **C then D then A then B** | Components, Theme, Dashboard, All commands | +| Control bar | **Unified, 3 bars** | Header + Navigation + Control bar, shared across all views | +| Commands | **Most become views** | Reduce standalone commands, show as dashboard tabs/views | + +--- + +## 2. Architecture Overview + +### The Big Picture + +``` ++------------------------------------------------------------------+ +| arc binary | ++------------------------------------------------------------------+ +| | +| +---------------+ +--------------------------------------+ | +| | Cobra CLI |--->| UI Engine (Shell) | | +| | (minimal) | | | | +| | | | +----------+ +------------------+ | | +| | arc | | | Header | | State Manager | | | +| | arc services | | | TabBar | | (profile, theme, | | | +| | arc --json | | | ViewArea | | skin, workspace)| | | +| | | | | Controls | | | | | +| +-------+-------+ | +----------+ +------------------+ | | +| | | | | +| | | +----------------------------------+| | +| | | | Router + View Registry || | +| | | | home | services | workspace | || | +| | | | config | info | init | theme || | +| | | +----------------------------------+| | +| | +--------------------------------------+ | +| | | | +| | +-------------v--------------+ | +| | | Component Library | | +| | | table | card | hero | tree | | +| | | badge | panel | search | | +| | | spinner | progress | form | | +| | +-------------+--------------+ | +| | | | +| | +-------------v--------------+ | +| | | Theme + Skin System | | +| | | colors | styles | layout | | +| | | profiles | skins (YAML) | | +| | +----------------------------+ | +| | | +| +-------v----------------------------------------------------+ | +| | Backend Services | | +| | catalog | workspace | store | scaffold | config | log | | +| | (UNCHANGED) | | +| +-------------------------------------------------------------+ | ++-------------------------------------------------------------------+ +``` + +### Data Flow + +``` +User types "arc" or "arc services list" + | + v +Cobra parses command + flags + | + +-- --json flag? --> Backend fetch -> JSON stdout -> exit + | + +-- TUI mode --> Engine.Start(mode) + | + +-- mode = "dashboard" (bare "arc") + | -> Shell with all tabs, Home active + | + +-- mode = "focused:services-list" ("arc services list") + -> Shell with single view, no tab bar + -> q to quit back to terminal +``` + +### Two Launch Modes + +The engine supports two modes — this is how we satisfy Option C: + +| Mode | Triggered by | Tab bar visible | Back to terminal on q | +| ------------- | ------------------- | ---------------- | --------------------- | +| **Dashboard** | `arc` (bare) | Yes — all tabs | Yes | +| **Focused** | `arc services list` | No — single view | Yes | + +Both modes share the same Shell (header + control bar). The only difference is whether the tab bar renders and whether the router allows navigation. This means: + +- Components are identical in both modes +- Theme/skin applies to both +- The engine is ONE Bubble Tea program, not two separate systems + +--- + +## 3. Engine Design — The Shell + +### Mental Model: React for the Terminal + +Think of the Shell as a React App component: + +``` + <- The persistent frame (Bubble Tea model) +
<- Brand, workspace, profile — always visible + <- Tab bar OR sidebar (depends on skin) + <- The area that changes + <- Whatever view the router says is current + + <- Keybindings from active view + profile info + +``` + +### Shell Struct (the one and only Bubble Tea model) + +```go +// Shell is the root Bubble Tea model. There is exactly ONE Shell +// per arc invocation. It owns the persistent frame and delegates +// content rendering to the active View. +type Shell struct { + // Engine internals + router *Router + state *StateManager + mode LaunchMode // Dashboard or Focused + + // Persistent frame components + header *component.Header + navigation *component.Navigation + controlBar *component.ControlBar + + // Dimensions + width int + height int + + // The active view (set by router) + activeView View +} +``` + +### Shell Lifecycle + +``` +Shell.Init() + +-- Load state (profile, theme, skin, workspace) + +-- Initialize persistent components (header, nav, controlbar) + +-- Router.Navigate(initialRoute) <- "home" for dashboard, target for focused + +-- Return tea.WindowSize command + +Shell.Update(msg) + +-- tea.WindowSizeMsg -> update dimensions, propagate to activeView + +-- tea.KeyMsg + | +-- Global keys (q/ctrl+c = quit, tab = next tab, shift+tab = prev) + | +-- Everything else -> delegate to activeView.Update(msg) + +-- NavigateMsg -> Router.Navigate(target), call OnExit/OnEnter + +-- StateChangedMsg -> reload theme/skin, re-render persistent frame + +Shell.View() + +-- header.Render(width, state) + +-- navigation.Render(width, activeTab) <- only in Dashboard mode + +-- activeView.View() <- the content area + +-- controlBar.Render(width, activeView.Keybindings()) +``` + +### View Interface (simplified from current) + +```go +// View is a pluggable content panel. It receives context via OnEnter, +// handles its own key events, and returns a rendered string from View(). +type View interface { + // Init is called once when the view is first created. + Init() tea.Cmd + + // Update handles key events and messages delegated from the Shell. + Update(msg tea.Msg) (View, tea.Cmd) + + // View renders the content area. Pure string composition, no I/O. + View() string + + // OnEnter is called by the Router when this view becomes active. + // The Shell GUARANTEES this is called before the first View(). + OnEnter(ctx *ViewContext) tea.Cmd + + // OnExit is called when navigating away. Cleanup resources. + OnExit() tea.Cmd + + // Name returns the unique identifier (e.g., "services-list", "home"). + Name() string + + // Keybindings returns keyboard shortcuts for the ControlBar to display. + Keybindings() []KeyBinding +} +``` + +### The Critical Fix: Shell calls OnEnter() automatically + +```go +// Router.Navigate — called by Shell, ALWAYS calls OnEnter +func (r *Router) Navigate(name string, args map[string]any) (View, tea.Cmd) { + // Exit current view + var exitCmd tea.Cmd + if r.current != nil { + exitCmd = r.current.OnExit() + } + + // Look up target view + view, ok := r.views[name] + if !ok { + return r.current, nil + } + + // Build context from state manager + ctx := r.state.BuildViewContext(args) + + // THIS IS THE FIX — OnEnter always called before any render + enterCmd := view.OnEnter(ctx) + + r.current = view + return view, tea.Batch(exitCmd, enterCmd) +} +``` + +This is the single most important fix. The current engine launches views without calling OnEnter, so components are nil, View() returns empty string, terminal goes black. + +In the new engine, Navigate() always calls OnEnter() before any render happens. It is impossible to see a blank view. + +### ViewContext (props passed to views) + +```go +// ViewContext is the props object passed to every view on OnEnter. +type ViewContext struct { + // Theming + Theme *theme.Theme + Profile *theme.Profile + Skin *theme.Skin + + // Dimensions (content area only — Shell already subtracted frame) + Width int + Height int + + // Route parameters (e.g., {"serviceName": "postgres"}) + Args map[string]any + + // Backend services (so views can fetch data) + Catalog catalog.Catalog + Workspace *workspace.Manager + Store *store.Store + + // State reference (for views that need to trigger state changes) + State *StateManager +} +``` + +ViewContext includes backend services directly. Views don't need to go through app.Context. The engine bridges that gap. This is the clean interface boundary between backend and UI. + +--- + +## 4. Component System + +### Philosophy + +1. **One component, one file, one package** — `pkg/ui/component/` +2. **Stateless by default** — render functions that take theme + data, return string +3. **Stateful when needed** — spinners, progress, search are structs with Update() +4. **Theme-aware always** — every component receives `*theme.Context` +5. **Use libraries** — wrap bubbles/huh/lipgloss/glamour, don't rewrite them + +### Component Inventory (17 total, down from 30+) + +| Component | Type | Library Used | Purpose | +| --------------- | --------- | ------------------------ | ------------------------------------ | +| `header.go` | Render fn | lipgloss | Top bar: brand + workspace + profile | +| `navigation.go` | Render fn | lipgloss | Tab bar or sidebar (skin-dependent) | +| `controlbar.go` | Render fn | lipgloss | Bottom bar: keybindings + context | +| `table.go` | Render fn | lipgloss + bubbles/table | Themed data tables | +| `card.go` | Render fn | lipgloss | Bordered card with title + body | +| `panel.go` | Render fn | lipgloss | Bordered content panel | +| `hero.go` | Render fn | lipgloss | ASCII logo + profile branding | +| `badge.go` | Render fn | lipgloss | Colored status badges | +| `tree.go` | Render fn | lipgloss | Dependency tree visualization | +| `error.go` | Render fn | lipgloss | Themed error boxes & inline errors | +| `list.go` | Struct | bubbles/list | Interactive filterable list | +| `search.go` | Struct | bubbles/textinput | Search input with fuzzy matching | +| `spinner.go` | Struct | bubbles/spinner | Animated loading indicator | +| `progress.go` | Struct | bubbles/progress | Progress bar | +| `form.go` | Struct | huh | Multi-step form wizard | +| `viewport.go` | Struct | bubbles/viewport | Scrollable content area | +| `markdown.go` | Render fn | glamour | Render markdown in terminal | + +### Key Principle: Wrap Libraries, Don't Rewrite + +The old codebase has 338-line tab_bar.go, 266-line progress.go, 406-line status_rail.go all reimplemented from scratch. In the new system: + +- `List` wraps `bubbles/list` (10-20 lines of glue code) +- `Spinner` wraps `bubbles/spinner` (10 lines) +- `Progress` wraps `bubbles/progress` (10 lines) +- `Form` wraps `huh` (thin adapter) +- `Viewport` wraps `bubbles/viewport` (thin adapter) + +Our custom code focuses only on ARC-unique components: header, navigation, controlbar, hero, badge, tree. + +### Error Handling & Shell Command Execution + +**Problem**: When views execute shell commands or backend operations, errors need to be captured, wrapped, and displayed gracefully in the TUI. We need a unified strategy to handle errors across all components and views. + +**Solution**: A three-layer error handling system: + +1. **Shell Command Wrapper** — `pkg/ui/shell/executor.go` +2. **Error Component** — `pkg/ui/component/error.go` +3. **Error Messages** — Bubble Tea messages for async errors + +#### Layer 1: Shell Command Executor (Wrapper) + +All shell command execution goes through a single wrapper that captures stdout, stderr, and exit codes: + +```go +// pkg/ui/shell/executor.go +package shell + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "time" +) + +// Result wraps the output of a shell command execution. +type Result struct { + Command string + Stdout string + Stderr string + ExitCode int + Duration time.Duration + Err error +} + +// Executor provides a safe wrapper for executing shell commands +// with timeout, cancellation, and error capture. +type Executor struct { + timeout time.Duration +} + +// NewExecutor creates a new shell command executor with default 30s timeout. +func NewExecutor() *Executor { + return &Executor{timeout: 30 * time.Second} +} + +// Run executes a shell command and captures all output. +// Returns a Result with stdout, stderr, exit code, and any errors. +func (e *Executor) Run(ctx context.Context, name string, args ...string) Result { + start := time.Now() + + // Create command with timeout context + timeoutCtx, cancel := context.WithTimeout(ctx, e.timeout) + defer cancel() + + cmd := exec.CommandContext(timeoutCtx, name, args...) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + result := Result{ + Command: fmt.Sprintf("%s %v", name, args), + } + + // Execute + err := cmd.Run() + result.Stdout = stdout.String() + result.Stderr = stderr.String() + result.Duration = time.Since(start) + + // Capture exit code + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + result.ExitCode = exitErr.ExitCode() + } else { + // Command failed to start or was killed + result.Err = err + result.ExitCode = -1 + } + } + + return result +} + +// IsSuccess returns true if the command executed successfully (exit code 0). +func (r Result) IsSuccess() bool { + return r.ExitCode == 0 && r.Err == nil +} + +// ErrorMessage returns a formatted error message for display in UI. +func (r Result) ErrorMessage() string { + if r.IsSuccess() { + return "" + } + + if r.Err != nil { + return fmt.Sprintf("Command failed: %s\n%v", r.Command, r.Err) + } + + return fmt.Sprintf( + "Command exited with code %d: %s\nStderr: %s", + r.ExitCode, + r.Command, + r.Stderr, + ) +} +``` + +#### Layer 2: Error Display Component + +A themed error component that shows errors with context, severity, and optional actions: + +```go +// pkg/ui/component/error.go +package component + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/yourorg/arc/pkg/ui/theme" +) + +// ErrorLevel defines the severity of an error. +type ErrorLevel int + +const ( + ErrorLevelInfo ErrorLevel = iota + ErrorLevelWarning + ErrorLevelError + ErrorLevelCritical +) + +// ErrorDisplay renders a themed error message box. +func ErrorDisplay(tc *theme.Context, level ErrorLevel, title, message string, details []string) string { + colors := tc.Theme().Colors + + // Select icon and color based on level + var icon string + var borderColor lipgloss.Color + + switch level { + case ErrorLevelInfo: + icon = tc.Tokens().Icons.Info + borderColor = colors.Info + case ErrorLevelWarning: + icon = tc.Tokens().Icons.Warning + borderColor = colors.Warning + case ErrorLevelError: + icon = tc.Tokens().Icons.Error + borderColor = colors.Error + case ErrorLevelCritical: + icon = tc.Tokens().Icons.Cross + borderColor = colors.Error + } + + // Build header + header := lipgloss.NewStyle(). + Foreground(borderColor). + Bold(true). + Render(fmt.Sprintf("%s %s", icon, title)) + + // Build message body + body := lipgloss.NewStyle(). + Foreground(colors.Foreground). + Render(message) + + // Build details section if provided + var detailsSection string + if len(details) > 0 { + detailLines := make([]string, len(details)) + for i, detail := range details { + detailLines[i] = lipgloss.NewStyle(). + Foreground(colors.Muted). + Render(fmt.Sprintf(" • %s", detail)) + } + detailsSection = "\n\n" + strings.Join(detailLines, "\n") + } + + // Compose the error box + content := lipgloss.JoinVertical( + lipgloss.Left, + header, + "", + body, + detailsSection, + ) + + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Padding(1, 2). + Width(60). + Render(content) +} + +// InlineError renders a compact single-line error for tables/lists. +func InlineError(tc *theme.Context, message string) string { + colors := tc.Theme().Colors + icon := tc.Tokens().Icons.Error + + return lipgloss.NewStyle(). + Foreground(colors.Error). + Render(fmt.Sprintf("%s %s", icon, message)) +} +``` + +#### Layer 3: Async Error Messages + +For operations that run asynchronously (like workspace initialization, service health checks), use Bubble Tea messages: + +```go +// pkg/ui/engine/messages.go +package engine + +// ErrorMsg carries error information from async operations to views. +type ErrorMsg struct { + Context string // What was being done (e.g., "Starting PostgreSQL") + Err error // The actual error + Severity ErrorLevel + Timestamp time.Time + Dismissible bool // Can user dismiss this error? +} + +// View handles ErrorMsg in Update(): +func (v *ServicesView) Update(msg tea.Msg) (View, tea.Cmd) { + switch msg := msg.(type) { + case ErrorMsg: + // Store error in view state + v.lastError = msg + return v, nil + case tea.KeyMsg: + if msg.String() == "d" && v.lastError != nil && v.lastError.Dismissible { + // Dismiss error + v.lastError = nil + } + } + // ... rest of update +} + +// In View(), render the error if present: +func (v *ServicesView) View() string { + var sections []string + + // Show error banner if present + if v.lastError != nil { + errorBox := component.ErrorDisplay( + v.themeCtx, + v.lastError.Severity, + v.lastError.Context, + v.lastError.Err.Error(), + []string{ + v.lastError.Timestamp.Format("15:04:05"), + "Press 'd' to dismiss", + }, + ) + sections = append(sections, errorBox) + } + + // Rest of view content + sections = append(sections, v.renderContent()) + + return lipgloss.JoinVertical(lipgloss.Left, sections...) +} +``` + +#### Usage Example: Workspace Init View + +```go +// pkg/ui/view/workspace_init.go +func (v *WorkspaceInitView) runInitCommand(path string) tea.Cmd { + return func() tea.Msg { + executor := shell.NewExecutor() + result := executor.Run( + context.Background(), + "arc-workspace-init", + "--path", path, + ) + + if !result.IsSuccess() { + return ErrorMsg{ + Context: "Initializing workspace", + Err: fmt.Errorf(result.ErrorMessage()), + Severity: ErrorLevelError, + Timestamp: time.Now(), + Dismissible: true, + } + } + + return WorkspaceInitSuccessMsg{Path: path} + } +} +``` + +#### Error Component in Inventory + +Add to the component inventory table: + +| Component | Type | Library Used | Purpose | +| ---------- | --------- | ------------ | ---------------------------------- | +| `error.go` | Render fn | lipgloss | Themed error boxes & inline errors | + +#### Integration Points + +1. **Views**: All views that execute commands use `shell.Executor` +2. **State Manager**: Backend errors (catalog load, config parse) converted to `ErrorMsg` +3. **Shell**: Command execution errors automatically wrapped +4. **Components**: Error component used by all views for consistent error display + +#### Error Handling Philosophy + +- **Never panic** — all errors bubble up as messages +- **Context first** — always show _what_ was being done when error occurred +- **Actionable** — suggest next steps or dismiss option +- **Themed** — errors match the current theme/profile +- **Non-blocking** — errors don't freeze the UI, views remain interactive + +--- + +## 5. Design System — React for CLI + +> Think React components for the terminal. Every component has padding, margin, color, font, typography, emoji — all controlled by a centralized design system. +> Inspired by **gh-dash** — delightful, keyboard-driven, composable boxes. + +### Design Tokens (The CSS Variables) + +Everything flows from one struct — the single source of truth: + +```go +type DesignTokens struct { + // Colors + Primary, Secondary, Success, Error, Warning, Info lipgloss.Color + Text, TextMuted, TextInverse lipgloss.Color + BgBase, BgSurface, BgHighlight lipgloss.Color + Border, BorderFocus lipgloss.Color + + // Spacing + PaddingX, PaddingY, MarginX, MarginY, Gap int + + // Typography + Bold, Dim, Italic func(s string) string + + // Icons + Icons IconSet +} + +type IconSet struct { + Success, Error, Warning, Info string + Arrow, Bullet, Star string + Folder, File, Check, Cross string +} +``` + +### Default Theme (Dark — gh-dash Inspired) + +```go +var DefaultTokens = DesignTokens{ + Primary: "#7C3AED", + Secondary: "#A78BFA", + Success: "#34D399", + Error: "#F87171", + Warning: "#FBBF24", + Info: "#60A5FA", + Text: "#E4E4E7", + TextMuted: "#71717A", + TextInverse: "#18181B", + BgBase: "#09090B", + BgSurface: "#18181B", + BgHighlight: "#27272A", + Border: "#3F3F46", + BorderFocus: "#7C3AED", + + PaddingX: 2, Gap: 1, + Icons: IconSet{ + Success: "✓", Error: "✗", Warning: "⚠", + Info: "●", Arrow: "→", Bullet: "•", Star: "★", + Folder: "📁", File: "📄", Check: "✔", Cross: "✘", + }, +} +``` + +### Style Factory (Like React `styled-components`) + +```go +type Styles struct { + tokens DesignTokens + Page, Section, SectionHeader, SectionBody lipgloss.Style + Title, Subtitle, Body, Muted, Label, Value lipgloss.Style + StatusSuccess, StatusError, StatusWarning, StatusInfo lipgloss.Style + TableHeader, TableRow, TableRowAlt, TableCell lipgloss.Style + Prompt, Input, Selected, HelpKey, HelpValue lipgloss.Style + Card, Banner, Divider lipgloss.Style +} + +func NewStyles(t DesignTokens) Styles { + return Styles{ + tokens: t, + Page: lipgloss.NewStyle(). + Padding(t.PaddingY, t.PaddingX). + Margin(t.MarginY, t.MarginX), + Section: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.Border). + Padding(t.PaddingY, t.PaddingX). + MarginBottom(t.Gap), + // ... more styles + } +} +``` + +### Component Implementations + +Every component is a **pure function** — takes data + styles, returns a string. + +```go +func Banner(s Styles, emoji string, title string, subtitle string) string { + t := s.tokens + titleLine := fmt.Sprintf("%s %s", emoji, s.Title.Render(title)) + subtitleLine := s.Muted.Render(subtitle) + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.Primary). + Padding(1, 2). + MarginBottom(t.Gap). + Render(lipgloss.JoinVertical(lipgloss.Left, titleLine, subtitleLine)) +} + +func StatusBadge(s Styles, status string) string { + t := s.tokens + switch status { + case "success", "active", "running": + return s.StatusSuccess.Render(t.Icons.Success + " " + status) + case "error", "failed", "terminated": + return s.StatusError.Render(t.Icons.Error + " " + status) + default: + return s.StatusInfo.Render(t.Icons.Info + " " + status) + } +} + +func Section(s Styles, title string, content string) string { + header := s.SectionHeader.Render(title) + body := s.SectionBody.Render(content) + return s.Section.Render(lipgloss.JoinVertical(lipgloss.Left, header, body)) +} + +func HelpBar(s Styles, bindings [][]string) string { + parts := make([]string, len(bindings)) + for i, b := range bindings { + parts[i] = s.HelpKey.Render(b[0]) + " " + s.HelpValue.Render(b[1]) + } + return lipgloss.JoinHorizontal(lipgloss.Top, parts...) +} +``` + +### How It All Connects + +``` +arc.yaml → profile.Theme = "default" + → tokens := themes["default"] // DesignTokens struct + → styles := NewStyles(tokens) // Styles struct + → components.Banner(styles, ...) + → components.Table(styles, ...) +``` + +**One theme → one tokens struct → one styles struct → passed to every component.** +No globals. Like React context. + +### Design System Component Inventory + +| Component | Type | Description | Stateless? | +| ----------- | --------- | ------------------------ | ---------- | +| Banner | Container | App header | ✅ | +| Section | Container | Bordered box | ✅ | +| StatusBadge | Inline | Colored status with icon | ✅ | +| HelpBar | Control | Footer with keybindings | ✅ | +| Table | Data | Column-aligned data | ✅ | +| Card | Container | Bordered card | ✅ | +| ProgressBar | Feedback | Animated progress | ❌ | +| List | Input | Selection list | ❌ | + +--- + +## 6. Theme and Skin System + +### New Design: One Package `pkg/ui/theme/` + +Replaces the current 4 packages (themes, profiles, styles, factory). + +```go +package theme + +// Context is the single source of truth for all visual styling. +// Every component receives this. +type Context struct { + profile *Profile + theme *Theme + skin *Skin + registry *Registry // Cached lipgloss.Style objects +} + +// Profile defines branding: logo, tier names, identity. +type Profile struct { + ID string + Name string + Description string + TierNames [3]string + ThemeID string + Logo string +} + +// Theme defines colors. +type Theme struct { + ID string + Name string + Colors ColorSet +} + +// ColorSet is the full color palette. +type ColorSet struct { + Primary lipgloss.Color + Secondary lipgloss.Color + Accent lipgloss.Color + Success lipgloss.Color + Warning lipgloss.Color + Error lipgloss.Color + Info lipgloss.Color + Muted lipgloss.Color + Background lipgloss.Color + Foreground lipgloss.Color + Border lipgloss.Color + HeaderBg lipgloss.Color + HeaderFg lipgloss.Color +} + +// Skin defines layout rules (Level 2). +type Skin struct { + ID string + Name string + Layout LayoutType // sidebar-left | tabs-top | minimal + Navigation NavType // sidebar | tab-bar | breadcrumb + Borders BorderType // rounded | sharp | none | half-block + Density Density // compact | comfortable | spacious +} +``` + +### Skin YAML Examples + +```yaml +# gh-dash.yaml +id: gh-dash +name: "GitHub Dashboard" +layout: sidebar-left +navigation: sidebar +borders: rounded +density: comfortable + +# minimal.yaml +id: minimal +name: "Minimal" +layout: full-width +navigation: tab-bar +borders: none +density: compact +``` + +### How Skin Affects Rendering + +Components don't know about skins — they read values from theme.Context: + +```go +// component/navigation.go — the ONE place skin layout matters +func Navigation(tc *theme.Context, tabs []Tab, activeIdx int, width int) string { + switch tc.Skin().Navigation { + case NavSidebar: + return renderSidebar(tc, tabs, activeIdx, width) + case NavTabBar: + return renderTabBar(tc, tabs, activeIdx, width) + } +} +``` + +All other components are skin-agnostic. They just use colors and borders from Context. + +### Level 3 Ready: The Interface + +```go +// Renderer interface for future Level 3 skins. +// Right now, only DefaultRenderer exists. +type Renderer interface { + RenderTable(tc *Context, headers []string, rows [][]string) string + RenderCard(tc *Context, title, body string) string + RenderHero(tc *Context, width int) string +} +``` + +We don't build this now. But components go through theme.Context, so swapping to a Renderer interface later is a one-line change, not a rewrite. + +--- + +## 7. State Management + +### StateManager + +```go +// StateManager is the brain. It loads preferences, resolves the active +// profile/theme/skin, and builds ViewContext for views. +// Initialized ONCE during bootstrap (not in init()). +type StateManager struct { + prefs *preferences.Preferences + profile *theme.Profile + themeCtx *theme.Context + catalog catalog.Catalog + workspace *workspace.Manager + store *store.Store +} + +func (sm *StateManager) BuildViewContext(args map[string]any) *ViewContext +func (sm *StateManager) ChangeProfile(profileID string) tea.Cmd +func (sm *StateManager) ChangeTheme(themeID string) tea.Cmd +func (sm *StateManager) ChangeSkin(skinID string) tea.Cmd +``` + +### Bootstrap (replaces init() side effects) + +```go +// internal/app/bootstrap.go — called ONCE from main.go +func Bootstrap() (*app.Context, error) { + cfg, _ := config.Load() + prefs, _ := preferences.Load() + tc, _ := theme.LoadFromPreferences(prefs) + cat := catalog.NewEmbedded() + store := store.New(xdg.DataDir()) + state := engine.NewStateManager(prefs, tc, cat, store) + return &app.Context{Config: cfg, State: state, Logger: log.New(cfg.LogLevel)}, nil +} +``` + +No global variables. No init(). No lazy loading. Just a function. + +### Workspace-Scoped Preferences (interface ready, built later) + +```go +// PreferenceProvider interface — global for now, workspace-scoped later +type PreferenceProvider interface { + ProfileID() string + ThemeID() string + SkinID() string +} + +// GlobalPreferences implements PreferenceProvider (current) +// WorkspacePreferences implements PreferenceProvider (future) +``` + +--- + +## 8. Command Strategy + +### New Command Tree + +``` +arc -> Dashboard (Home tab) +arc dashboard -> Dashboard (explicit alias) +arc init -> Init wizard (huh form, focused mode) +arc workspace init [path] -> Workspace wizard (huh form, focused mode) +arc workspace run -> Workspace runner (progress view, focused) +arc workspace info -> Workspace info (focused TUI or --json) +arc services list -> Services list (focused TUI or --json) +arc version -> Version info (focused TUI or --json) +arc completion -> Shell completion (stdout, no TUI) +arc help -> Help text (stdout, no TUI) +``` + +### What Moved Into Dashboard Tabs + +| Old Command | New Location | +| -------------------------------------- | -------------------------- | +| `arc info` | Dashboard -> Home tab | +| `arc services list/info/deps/ports` | Dashboard -> Services tab | +| `arc theme list/set/preview` | Dashboard -> Config tab | +| `arc config list-profiles/set-profile` | Dashboard -> Config tab | +| `arc workspace info/history` | Dashboard -> Workspace tab | + +--- + +## 9. Folder Structure + +``` +arc-cli/ +|-- cmd/arc/ +| +-- main.go # Entry: bootstrap -> root -> execute +| +|-- internal/ # Private packages (KEPT, minimal changes) +| |-- app/ +| | |-- context.go # Slimmed: Config + State + Logger +| | +-- bootstrap.go # NEW: explicit init, replaces init() +| |-- config/ # KEPT +| |-- preferences/ # KEPT (add skin_id field) +| |-- terminal/ # KEPT +| |-- xdg/ # KEPT +| +-- version/ # KEPT +| +|-- pkg/ +| |-- catalog/ # KEPT ENTIRELY +| |-- workspace/ # KEPT ENTIRELY +| |-- store/ # KEPT ENTIRELY +| |-- scaffold/ # KEPT ENTIRELY +| |-- log/ # KEPT ENTIRELY +| |-- version/ # KEPT ENTIRELY +| | +| |-- ui/ # REBUILT +| | |-- theme/ # Theme + Profile + Skin (merged) +| | | |-- context.go +| | | |-- theme.go +| | | |-- profile.go +| | | |-- skin.go +| | | |-- loader.go +| | | |-- registry.go +| | | +-- embedded/ +| | | |-- themes/ # 10 theme YAMLs +| | | |-- profiles/ # 10 profile YAMLs +| | | +-- skins/ # gh-dash.yaml, minimal.yaml +| | | +| | |-- component/ # ALL components (ONE location) +| | | |-- header.go +| | | |-- navigation.go +| | | |-- controlbar.go +| | | |-- table.go +| | | |-- card.go +| | | |-- panel.go +| | | |-- hero.go +| | | |-- badge.go +| | | |-- tree.go +| | | |-- error.go # NEW: Error display component +| | | |-- list.go +| | | |-- search.go +| | | |-- spinner.go +| | | |-- progress.go +| | | |-- form.go +| | | |-- viewport.go +| | | +-- markdown.go +| | | +| | |-- shell/ # NEW: Shell command execution +| | | +-- executor.go # Command wrapper with error capture +| | | +| | |-- engine/ # Shell + Router + State +| | | |-- shell.go +| | | |-- router.go +| | | |-- state.go +| | | |-- view.go +| | | |-- context.go +| | | |-- launch.go +| | | |-- messages.go # NEW: ErrorMsg and other messages +| | | +-- keys.go +| | | +| | +-- view/ # View implementations +| | |-- home.go +| | |-- services_list.go +| | |-- service_detail.go +| | |-- workspace_info.go +| | |-- workspace_history.go +| | |-- workspace_run.go +| | |-- config_overview.go +| | |-- version.go +| | +-- init_wizard.go +| | +| +-- cli/ # Cobra commands (THIN layer) +| |-- root.go +| |-- services.go +| |-- workspace.go +| |-- version.go +| |-- init.go +| +-- completion.go +| +|-- testdata/golden/ +|-- tests/ +| |-- integration/ +| +-- component/ +| +|-- .golangci.yml +|-- Makefile +|-- go.mod ++-- go.sum +``` + +**Dependency direction (one-way, no cycles):** + +``` +cli -> engine -> view -> component -> theme + | | | + | | +-> shell (executor) + | | + | +-> shell (executor) + | + (bubbles, huh, lipgloss, glamour) +``` + +**Notes:** + +- Views and Engine can use shell.Executor directly +- Components are pure rendering functions (no shell access) +- All error handling flows through ErrorMsg to views + +--- + +## 10. Linting Constitution + +Keep all 30 linters. Add path-based exclusions for UI: + +```yaml +issues: + exclude-rules: + # Existing test exclusions (KEEP all) + - path: _test\.go + linters: + [ + gosec, + errcheck, + dupl, + funlen, + gocyclo, + cyclop, + nestif, + goconst, + gocritic, + revive, + unparam, + nakedret, + prealloc, + ] + + # NEW: UI views — Bubble Tea boilerplate is structurally identical + - path: pkg/ui/view/ + linters: [dupl] + + # NEW: Engine — complex Update() switch statements are inherent + - path: pkg/ui/engine/ + linters: [gocyclo, cyclop] + + # NEW: Components — lipgloss API passes types by value + - path: pkg/ui/component/ + linters: [gocritic] + text: "hugeParam" +``` + +Backend stays enterprise-strict. Zero `//nolint` target for all new code. + +--- + +## 11. Testing Strategy + +| Layer | What to Test | How | When | +| --------------- | -------------------------- | --------------------- | ----------- | +| **Backend** | catalog, workspace, store | Unit tests + mocks | Must have | +| **Theme** | Profile/theme/skin loading | Unit tests | Must have | +| **Components** | Table, card, hero output | Golden file snapshots | Should have | +| **Engine** | Shell lifecycle, router | Headless Bubble Tea | Later | +| **Views** | Full view rendering | Golden files | Later | +| **Integration** | `arc services list --json` | CLI execution tests | Later | + +--- + +## 12. Phase-by-Phase Implementation + +### Phase 1: Foundation (Week 1-2) + +Build theme system + component library + engine shell + error handling. + +- Week 1: `pkg/ui/theme/` (context, theme, profile, skin, loader, registry) +- Week 1: `pkg/ui/component/` first batch (header, controlbar, navigation, table, card) +- Week 1: `pkg/ui/shell/` (executor for shell command wrapping) +- Week 2: `pkg/ui/component/` remaining (hero, badge, tree, error, list, spinner, viewport, etc.) +- Week 2: `pkg/ui/engine/` (shell, router, state, view interface, launch, messages) +- Week 2: `internal/app/bootstrap.go` — kill init() side effects + +**Milestone**: `arc` opens empty shell with themed header + tabs + controlbar. `q` quits. + +### Phase 2: Theme + Skin System (Week 3) + +Wire profile/theme/skin switching live. + +- StateManager.ChangeProfile() — atomic switch +- StateChangedMsg handling in Shell — re-render frame +- Skin rendering in navigation (sidebar vs tab-bar) +- Placeholder Config view for profile switching + +**Milestone**: Change profile -> entire UI updates live (colors, borders, logo, layout). + +### Phase 3: Dashboard — Home + Services (Week 4) + +First two real tabs with error handling integrated. + +- view/home.go — Hero + quick actions + system info +- view/services_list.go — Catalog table with search/filter + error display +- view/service_detail.go — Service info card + deps tree +- Router wiring for tab switching +- Focused mode: `arc services list` +- JSON mode: `arc services list --json` + +**Milestone**: 2-tab dashboard with real data. Services searchable from catalog. Errors shown gracefully. + +### Phase 4: Workspace + Config Tabs (Week 5) + +Complete the 4-tab dashboard. + +- view/workspace_info.go + workspace_history.go +- view/config_overview.go (profile, theme, skin pickers) +- view/version.go +- Focused mode for remaining commands + +**Milestone**: Full 4-tab dashboard, all tabs with real data. + +### Phase 5: Wizards + Remaining Commands (Week 6) + +Interactive flows with full error handling. + +- view/init_wizard.go (huh form) +- view/workspace_run.go (progress + error capture) +- Wire all remaining Cobra commands + +**Milestone**: All commands work. Shell command errors captured and displayed. + +### Phase 6: Cleanup + Polish (Week 7) + +Ship it. + +- Delete all old UI code +- Final lint pass (zero //nolint target) +- Update README, agent doc, CLAUDE.md +- Performance validation (<100ms startup, <16ms tab switch, <20MB memory) +- Merge to develop + +--- + +## 13. Task Breakdown + +### Phase 1: Foundation (40 tasks, ~59.5h) + +| # | Task | Depends | Est | +| ---- | --------------------------------------------- | --------------- | ---- | +| T001 | Create worktree, scaffold folders | — | 1h | +| T002 | Delete old pkg/ui/ contents | T001 | 30m | +| T003 | theme/theme.go — Theme + ColorSet | T001 | 2h | +| T004 | theme/profile.go — Profile struct | T003 | 1h | +| T005 | theme/skin.go — Skin + enums | T003 | 2h | +| T006 | theme/context.go — theme.Context | T003-T005 | 2h | +| T007 | theme/registry.go — Style cache | T006 | 2h | +| T008 | theme/loader.go — YAML loader | T006 | 3h | +| T009 | Port theme YAML files | T008 | 30m | +| T010 | Port profile YAML files | T008 | 30m | +| T011 | Create skin YAML files (gh-dash, minimal) | T005 | 1h | +| T012 | component/header.go | T006 | 2h | +| T013 | component/controlbar.go | T006 | 2h | +| T014 | component/navigation.go (skin-dependent) | T006, T005 | 4h | +| T015 | component/table.go | T006 | 3h | +| T016 | component/card.go | T006 | 1h | +| T017 | component/panel.go | T006 | 1h | +| T018 | component/hero.go | T006 | 2h | +| T019 | component/badge.go | T006 | 1h | +| T020 | component/tree.go | T006 | 2h | +| T021 | shell/executor.go — Shell command wrapper | — | 2h | +| T022 | component/error.go — Error display | T006, T021 | 2h | +| T023 | component/list.go (wraps bubbles) | T006 | 2h | +| T024 | component/search.go (wraps bubbles) | T006 | 1h | +| T025 | component/spinner.go (wraps bubbles) | T006 | 1h | +| T026 | component/progress.go (wraps bubbles) | T006 | 1h | +| T027 | component/form.go (wraps huh) | T006 | 2h | +| T028 | component/viewport.go (wraps bubbles) | T006 | 1h | +| T029 | component/markdown.go (wraps glamour) | T006 | 1h | +| T030 | engine/view.go — View interface | — | 1h | +| T031 | engine/context.go — ViewContext | T006 | 1h | +| T032 | engine/state.go — StateManager | T006, T008 | 3h | +| T033 | engine/messages.go — ErrorMsg + others | T022 | 1.5h | +| T034 | engine/router.go — Router + OnEnter guarantee | T030-T032 | 3h | +| T035 | engine/keys.go — Global keys | — | 30m | +| T036 | engine/shell.go — Shell model | T012-T014, T034 | 5h | +| T037 | engine/launch.go — Start() entry | T036 | 2h | +| T038 | internal/app/bootstrap.go | T032 | 2h | +| T039 | Update cmd/arc/main.go | T038 | 1h | +| T040 | MILESTONE: Empty shell renders | T039 | 1h | + +### Phase 2: Theme + Skin (7 tasks, ~18h) + +| # | Task | Depends | Est | +| ---- | ------------------------------------ | ---------- | --- | +| T041 | StateManager.ChangeProfile() | T032 | 3h | +| T042 | StateChangedMsg in Shell | T036, T041 | 2h | +| T043 | Skin rendering in navigation | T014, T005 | 3h | +| T044 | Placeholder Config view | T041 | 3h | +| T045 | Theme system golden tests | T008 | 3h | +| T046 | Component golden tests | T015-T018 | 3h | +| T047 | MILESTONE: Profile switch works live | T044 | 1h | + +### Phase 3: Home + Services (7 tasks, ~19h) + +| # | Task | Depends | Est | +| ---- | ------------------------------------------- | ---------------------- | --- | +| T048 | view/home.go | T018, T016 | 4h | +| T049 | view/services_list.go (with error handling) | T015, T023, T024, T022 | 5h | +| T050 | view/service_detail.go | T016, T020 | 4h | +| T051 | Router: Home <-> Services tab switching | T034, T048, T049 | 2h | +| T052 | Focused mode: arc services list | T037, T049 | 2h | +| T053 | JSON mode: arc services list --json | T037 | 1h | +| T054 | MILESTONE: 2-tab dashboard with real data | T051 | 1h | + +### Phase 4: Workspace + Config (8 tasks, ~18h) + +| # | Task | Depends | Est | +| ---- | ------------------------------------- | ---------- | --- | +| T055 | view/workspace_info.go | T016, T015 | 3h | +| T056 | view/workspace_history.go | T015 | 3h | +| T057 | Complete view/config_overview.go | T044, T023 | 4h | +| T058 | view/version.go | T016 | 2h | +| T059 | Router: all 4 tabs wired | T048-T057 | 2h | +| T060 | Focused mode: workspace info, version | T037 | 2h | +| T061 | JSON mode: workspace info, version | T037 | 1h | +| T062 | MILESTONE: Full 4-tab dashboard | T059 | 1h | + +### Phase 5: Wizards + Commands (6 tasks, ~11.5h) + +| # | Task | Depends | Est | +| ---- | ------------------------------------------- | ---------------- | --- | +| T063 | view/init_wizard.go | T027 | 4h | +| T064 | view/workspace_run.go (with error handling) | T025, T026, T022 | 3h | +| T065 | Wire arc init | T063 | 1h | +| T066 | Wire arc workspace init/run | T063, T064 | 2h | +| T067 | arc completion (keep current) | — | 30m | +| T068 | MILESTONE: All commands work | T065-T067 | 1h | + +### Phase 6: Cleanup (8 tasks, ~14h) + +| # | Task | Depends | Est | +| ---- | -------------------------------- | ------- | --- | +| T069 | Delete all old UI code | T068 | 2h | +| T070 | Update .golangci.yml | T069 | 1h | +| T071 | Lint pass — zero //nolint target | T070 | 3h | +| T072 | Update README.md | T068 | 2h | +| T073 | Update agent doc | T068 | 2h | +| T074 | Update CLAUDE.md | T068 | 1h | +| T075 | Performance validation | T068 | 2h | +| T076 | MILESTONE: Merge to develop | T075 | 1h | + +### Totals + +| Phase | Tasks | Hours | +| --------------------- | ------ | ------------------------------- | +| 1. Foundation | 40 | ~59.5h | +| 2. Theme + Skin | 7 | ~18h | +| 3. Home + Services | 7 | ~19h | +| 4. Workspace + Config | 8 | ~18h | +| 5. Wizards + Commands | 6 | ~11.5h | +| 6. Cleanup | 8 | ~14h | +| **Total** | **76** | **~140h (~7 weeks @ 20h/week)** | + +--- + +## Appendix A: Discussion & Alignment Notes + +> These are the 10 design principles discussed and aligned before finalizing this plan. + +### Point 1: Abstraction is NOT Bad + +Keep one clean interface boundary between backend and UI. Backend stays stable; UI is swappable. + +### Point 2: Engine Was a Good Idea — Fix It + +Fix the lifecycle bug (OnEnter guarantee), consolidate components, keep the engine concept. + +### Point 3: gh-dash + Versionable UI + Worktree + +- Git worktree for clean rewrite +- gh-dash as V1 design reference +- UI skin system for swappable looks +- Tab-based navigation + +### Point 4: Simpler Folder Structure + +Max 2 directory hops. Flat: `pkg/ui/` has `theme/`, `component/`, `engine/`, `view/`. + +### Point 5: Linting — Smart Exclusions + +Keep all 30 linters. Path exclusions for UI layers. Backend stays enterprise-strict. + +### Point 6: Testing — Interfaces First + +- Must test: Backend interfaces, theme loading, profile switching +- Golden files: Table, card, hero output +- Skip: View rendering, animation timing +- Later: Headless Bubble Tea integration + +### Point 7: Don't Discard Components + +Consolidate duplicates to one implementation. Keep factory pattern. + +### Point 8: Proper Engineering Plan, Go + Libraries + +Stick with Go + Charmbracelet: lipgloss, bubbles, huh, glamour, harmonica. + +### Point 9: Swappable UI, State-Driven Theming + +- Core untouched, UI replaceable +- Profile change = cascading update +- State is the brain +- Workspace scope: ready later, global first + +### Point 10: React-Inspired Engine Design + +``` +Engine: Shell + Router + ViewRegistry + StateManager +View: OnEnter (props) → Update → View (pure string) +``` + +Props = ViewContext, State = internal state, Component tree = Shell → TabBar → View → Components. + +--- + +## What Gets Deleted (Phase 6, T066) + +``` +pkg/ui/components/ # Old component tree +pkg/ui/views/ # Old view files +pkg/ui/engine/ # Old engine (replaced by new) +pkg/ui/legacy/ # Legacy UI +pkg/ui/factory.go # 622-line factory +pkg/ui/service.go # Old UI service +pkg/ui/styles/ # Global mutable colors +pkg/ui/animations/ # Animation framework +pkg/ui/layouts/ # Empty directory +pkg/ui/markdown/ # Folded into component/ +pkg/ui/profiles/ # Folded into theme/ +pkg/ui/themes/ # Folded into theme/ +pkg/cli/dashboard/ # Old dashboard +pkg/cli/middleware/ # ErrorBoundary +pkg/cli/errors/ # ArcError +pkg/cli/banner.go # Old banner +pkg/cli/init_profile_ui.go # Old init UI +internal/branding/ # Folded into theme/profile +``` + +## What Stays Untouched + +``` +pkg/catalog/ # Service catalog +pkg/workspace/ # Workspace management +pkg/store/ # Config store +pkg/scaffold/ # Templates +pkg/log/ # Logging +pkg/version/ # Version API +internal/config/ # arc.yaml parsing +internal/preferences/ # state.json +internal/terminal/ # TTY detection +internal/xdg/ # XDG directories +specs/ # All 17 spec histories +``` + +--- + +_"The CLI should feel like it was built from the heart."_ +_— A.R.C. CLI v2 UI Design & Implementation Plan, March 2026_ diff --git a/docs/user-guides/workspace-tiers.md b/docs/user-guides/workspace-tiers.md index fd8dced..887846e 100644 --- a/docs/user-guides/workspace-tiers.md +++ b/docs/user-guides/workspace-tiers.md @@ -7,6 +7,7 @@ Understanding workspace tiers helps you choose the right starting configuration A **tier** is a pre-defined collection of services with specific resource requirements. Think of it as a "starting point" or "template" for your workspace configuration. **Key Concepts**: + - Tiers are NOT functional modes (all tiers run services the same way) - Tiers are labels for pre-selected service groups - You can customize the service list after initialization by editing `arc.yaml` @@ -21,6 +22,7 @@ A **tier** is a pre-defined collection of services with specific resource requir **Total Services**: 13 base + 4 tier-specific + optional features **Resource Requirements**: + - **CPU**: Minimum 4 cores (8 recommended) - **RAM**: Minimum 8GB (16GB recommended) - **Storage**: 20GB minimum @@ -30,48 +32,49 @@ A **tier** is a pre-defined collection of services with specific resource requir #### Base Infrastructure (13 services - always included) -| Service | Codename | Purpose | Ports | -|---------|----------|---------|-------| -| `arc-gateway` | Heimdall | API gateway and reverse proxy | 80, 443, 8080 | -| `arc-db-sql` | Oracle | PostgreSQL relational database | 5432 | -| `arc-db-cache` | Sonic | Redis in-memory cache | 6379 | -| `arc-db-vector` | Cerebro | Qdrant vector database for embeddings | 6333, 6334 | -| `arc-storage` | Tardis | MinIO object storage (S3-compatible) | 9000, 9001 | -| `arc-flags` | Mystique | Unleash feature flag server | 4242 | -| `arc-stream` | Dr. Strange | Apache Pulsar event streaming | 6650, 8081 | -| `arc-pulse` | The Flash | NATS messaging broker | 4222, 8222 | -| `arc-mailer` | Hedwig | Email delivery service | 1025, 8025 | -| `arc-migrate` | Pathfinder | Database migration runner | - | -| `arc-brain` | Sherlock | Core AI reasoning engine | 8000 | -| `arc-janitor` | The Wolf | Operations and cleanup service | 8007 | -| `arc-billing` | Alfred | Usage tracking and billing | 8008 | +| Service | Codename | Purpose | Ports | +| --------------- | ----------- | ------------------------------------- | ------------- | +| `arc-gateway` | Heimdall | API gateway and reverse proxy | 80, 443, 8080 | +| `arc-db-sql` | Oracle | PostgreSQL relational database | 5432 | +| `arc-db-cache` | Sonic | Redis in-memory cache | 6379 | +| `arc-db-vector` | Cerebro | Qdrant vector database for embeddings | 6333, 6334 | +| `arc-storage` | Tardis | MinIO object storage (S3-compatible) | 9000, 9001 | +| `arc-flags` | Mystique | Unleash feature flag server | 4242 | +| `arc-stream` | Dr. Strange | Apache Pulsar event streaming | 6650, 8081 | +| `arc-pulse` | The Flash | NATS messaging broker | 4222, 8222 | +| `arc-mailer` | Hedwig | Email delivery service | 1025, 8025 | +| `arc-migrate` | Pathfinder | Database migration runner | - | +| `arc-brain` | Sherlock | Core AI reasoning engine | 8000 | +| `arc-janitor` | The Wolf | Operations and cleanup service | 8007 | +| `arc-billing` | Alfred | Usage tracking and billing | 8008 | #### Security Services (included if `features.security: true`) -| Service | Codename | Purpose | Ports | -|---------|----------|---------|-------| -| `arc-identity` | J.A.R.V.I.S. | Ory Kratos identity management | 4433, 4434 | -| `arc-vault` | Nick Fury | Infisical secrets manager | 8200 | -| `arc-guard` | RoboCop | AI guardrails and safety checks | 8002 | +| Service | Codename | Purpose | Ports | +| -------------- | ------------ | ------------------------------- | ---------- | +| `arc-identity` | J.A.R.V.I.S. | Ory Kratos identity management | 4433, 4434 | +| `arc-vault` | Nick Fury | Infisical secrets manager | 8200 | +| `arc-guard` | RoboCop | AI guardrails and safety checks | 8002 | #### Voice Services (included if `features.voice: true`) -| Service | Codename | Purpose | Ports | -|---------|----------|---------|-------| -| `arc-voice-server` | Daredevil | LiveKit real-time voice server | 7880, 7881 | -| `arc-voice-agent` | Scarlett | Voice AI agent processor | 8001 | -| `arc-ingress` | Sentry | LiveKit ingress for external streams | 7882 | -| `arc-egress` | Scribe | LiveKit egress for recording | 7883 | +| Service | Codename | Purpose | Ports | +| ------------------ | --------- | ------------------------------------ | ---------- | +| `arc-voice-server` | Daredevil | LiveKit real-time voice server | 7880, 7881 | +| `arc-voice-agent` | Scarlett | Voice AI agent processor | 8001 | +| `arc-ingress` | Sentry | LiveKit ingress for external streams | 7882 | +| `arc-egress` | Scribe | LiveKit egress for recording | 7883 | **Default Configuration** (`arc.yaml`): + ```yaml version: 1.1.0 name: my-arc-workspace tier: super-saiyan features: - voice: true # Voice services enabled by default - security: true # Security services enabled by default + voice: true # Voice services enabled by default + security: true # Security services enabled by default observability: false chaos: false @@ -91,12 +94,14 @@ environment: **Best for**: Production environments with enhanced observability and performance. **Planned Features**: + - All SuperSaiyan services - Full observability stack (Prometheus, Loki, Tempo, Grafana) - Enhanced caching (Redis Cluster) - Advanced event streaming (Kafka + Pulsar) **Resource Requirements**: + - **CPU**: Minimum 8 cores (16 recommended) - **RAM**: Minimum 16GB (32GB recommended) - **Storage**: 50GB minimum @@ -110,12 +115,14 @@ environment: **Best for**: Large-scale deployments with chaos engineering and advanced diagnostics. **Planned Features**: + - All SuperSaiyanBlue services - Chaos engineering tools (Chaos Mesh) - Advanced tracing and profiling - Multi-region support **Resource Requirements**: + - **CPU**: Minimum 16 cores - **RAM**: Minimum 32GB - **Storage**: 100GB minimum @@ -133,11 +140,13 @@ Feature flags modify which services are included in your workspace, regardless o #### `voice` - Voice and Real-Time Communication **What it enables**: + - Real-time voice agents (LiveKit) - WebRTC streaming infrastructure - Voice recording and transcription **Services Added**: + - `arc-voice-server` (Daredevil) - LiveKit server - `arc-voice-agent` (Scarlett) - Voice AI processor - `arc-ingress` (Sentry) - External stream ingress @@ -152,11 +161,13 @@ Feature flags modify which services are included in your workspace, regardless o #### `security` - Identity and Secrets Management **What it enables**: + - Production-grade identity management (Ory Kratos) - Secrets vaulting (Infisical) - AI safety guardrails **Services Added**: + - `arc-identity` (J.A.R.V.I.S.) - User authentication and sessions - `arc-vault` (Nick Fury) - Secrets storage and rotation - `arc-guard` (RoboCop) - AI output validation and filtering @@ -170,12 +181,14 @@ Feature flags modify which services are included in your workspace, regardless o #### `observability` - Metrics, Logs, and Traces **What it enables**: + - Full observability stack (Prometheus, Loki, Tempo, Grafana) - OpenTelemetry collection - Distributed tracing - Centralized logging **Services Added**: + - `arc-otel` (Black Widow) - OpenTelemetry collector - `arc-metrics` (Dr. House) - Prometheus metrics database - `arc-logs` (Watson) - Loki log aggregation @@ -194,11 +207,13 @@ Feature flags modify which services are included in your workspace, regardless o #### `chaos` - Chaos Engineering **What it enables**: + - Chaos Mesh for fault injection - Network latency simulation - Pod failure testing **Services Added**: + - `arc-chaos` (T-800) - Chaos Mesh controller **When to enable**: Resilience testing, production readiness validation @@ -237,6 +252,7 @@ Understanding how services are selected helps you predict what will run when you ### Example 1: Minimal Configuration **arc.yaml**: + ```yaml tier: super-saiyan features: @@ -255,6 +271,7 @@ features: ### Example 2: Full-Featured Development **arc.yaml**: + ```yaml tier: super-saiyan features: @@ -265,6 +282,7 @@ features: ``` **Result**: + - 13 base infrastructure - 3 security services (identity, vault, guard) - 4 voice services (voice-server, voice-agent, ingress, egress) @@ -279,16 +297,18 @@ features: ### Example 3: Production-Ready with Observability **arc.yaml**: + ```yaml tier: super-saiyan features: - voice: false # Disable voice to reduce resource usage - security: true # Essential for production + voice: false # Disable voice to reduce resource usage + security: true # Essential for production observability: true # Monitor everything chaos: false ``` **Result**: + - 13 base infrastructure - 3 security services - 6 observability services @@ -334,25 +354,27 @@ arc-viz (Friday) ### Minimum Requirements by Configuration -| Configuration | Services | CPU (cores) | RAM (GB) | Storage (GB) | -|--------------|----------|-------------|----------|--------------| -| Base Only | 13 | 2 | 4 | 10 | -| + Security | 16 | 3 | 6 | 12 | -| + Voice | 17 | 4 | 8 | 15 | -| + Observability | 19 | 6 | 12 | 25 | -| Full (All Features) | 26 | 8 | 16 | 30 | +| Configuration | Services | CPU (cores) | RAM (GB) | Storage (GB) | +| ------------------- | -------- | ----------- | -------- | ------------ | +| Base Only | 13 | 2 | 4 | 10 | +| + Security | 16 | 3 | 6 | 12 | +| + Voice | 17 | 4 | 8 | 15 | +| + Observability | 19 | 6 | 12 | 25 | +| Full (All Features) | 26 | 8 | 16 | 30 | ### Recommended Specs For comfortable development with SuperSaiyan tier (default features): **Laptop/Desktop**: + - CPU: Intel i7/i9 or AMD Ryzen 7/9 (8+ cores) - RAM: 16GB minimum, 32GB recommended - Storage: 50GB free SSD space - Docker Desktop: 20.10.0+ with 8GB memory allocation **Cloud VM** (AWS/GCP/Azure): + - Instance Type: t3.xlarge (AWS), n2-standard-4 (GCP), Standard_D4s_v3 (Azure) - RAM: 16GB - Disk: 50GB SSD @@ -388,12 +410,14 @@ Start here ### Adding a Service Edit `arc.yaml`: + ```yaml features: - observability: true # Change false → true + observability: true # Change false → true ``` Then regenerate: + ```bash arc workspace run ``` @@ -401,12 +425,14 @@ arc workspace run ### Removing a Service Edit `arc.yaml`: + ```yaml features: - voice: false # Change true → false + voice: false # Change true → false ``` Then regenerate: + ```bash arc workspace run ``` @@ -433,6 +459,7 @@ No. The base infrastructure (13 services) is sufficient for simple AI agents. En ### What happens if I enable a feature but my machine doesn't have enough resources? Docker will attempt to start all services, but you may experience: + - Services failing health checks - Out-of-memory errors - Slow performance @@ -442,6 +469,7 @@ Use `docker stats` to monitor resource usage and disable unnecessary features. ### Can I create my own custom tier? Not directly, but you can: + 1. Start with any tier (e.g., SuperSaiyan) 2. Edit `arc.yaml` to customize the service list 3. Share your `arc.yaml` with your team @@ -473,5 +501,5 @@ If you truly need a minimal setup, consider using individual services outside of - **Monitor your platform**: `docker stats` or Grafana (if observability enabled) For more details on the initialization process, see: -- [Quickstart Guide](../../specs/011-workspace-orchestration-deep/quickstart.md) + - [Architecture Deep Dive](../developer/workspace-init-flow.md) diff --git a/go.mod b/go.mod index 82c64d1..b4e7365 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,10 @@ go 1.24.2 require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/glamour v0.10.0 + github.com/charmbracelet/harmonica v0.2.0 + github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/charmbracelet/log v0.4.2 - github.com/charmbracelet/x/ansi v0.11.6 - github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 @@ -20,47 +19,36 @@ require ( ) require ( - github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/aymerick/douceur v0.2.0 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect - github.com/charmbracelet/harmonica v0.2.0 // indirect - github.com/charmbracelet/huh v0.8.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.10.0 // indirect github.com/clipperhouse/uax29/v2 v2.6.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect - github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/pretty v0.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect - github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yuin/goldmark v1.7.8 // indirect - github.com/yuin/goldmark-emoji v1.0.5 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect - golang.org/x/net v0.38.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect diff --git a/go.sum b/go.sum index cced254..323a137 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,11 @@ -github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= -github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= -github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= -github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= -github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= -github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= -github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= @@ -20,8 +14,6 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= -github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= -github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= @@ -34,37 +26,37 @@ github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g= github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs= github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= -github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= -github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= @@ -80,25 +72,18 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= -github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -115,16 +100,9 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= -github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= -github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= diff --git a/internal/app/backups/README.md b/internal/app/backups/README.md deleted file mode 100644 index 4c50f72..0000000 --- a/internal/app/backups/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# Backup Functionality (Reserved) - -**Status**: Reserved for future implementation -**Constitutional Principle**: Principle II - Local-First Architecture - -## Purpose - -This directory is reserved for implementing backup and restore functionality in the A.R.C. CLI. When implemented, this component will enable users to snapshot platform state, configurations, and secrets for disaster recovery and migration scenarios. - -## Planned Functionality - -- **State Snapshots**: Backup complete platform state including embedded database (`.arc/state.db`) -- **Configuration Archives**: Create timestamped backups of all generated configs (docker-compose.yml, service configs) -- **Secret Backup**: Securely archive secrets with encryption (`.arc/secrets/` directory) -- **Selective Restore**: Restore specific components (state only, configs only, secrets only, or complete platform) -- **Backup Verification**: Validate backup integrity with checksums and structural validation -- **Migration Support**: Export backups for migration to different environments or machines - -## Implementation Checklist - -When implementing backup functionality, ensure adherence to: - -- **Local-First**: All backups stored locally in `.arc/backups/` directory, no cloud dependencies (Constitution Principle II) -- **XDG Compliance**: Store backups in XDG Data Directory (`~/.local/share/arc/backups/`) -- **Security by Default**: Encrypt secret backups using high-entropy keys (Constitution Principle X) -- **Repository Pattern**: Implement `BackupRepository` interface in `pkg/store/` with implementation here -- **Factory Pattern**: Inject dependencies via `internal/app/factory.go`, avoid global state -- **Atomic Operations**: Use write-to-temp-then-rename pattern for backup file creation -- **Compression**: Use gzip/zstd compression for backup archives to minimize disk usage - -## Backup Strategies - -- **Full Backup**: Complete snapshot of state database, configs, and encrypted secrets -- **Incremental Backup**: Track changes since last backup using state database operation log -- **Scheduled Backup**: Optional automatic backups before risky operations (`arc chaos`, `arc reconcile --apply`) -- **Pre-Operation Snapshots**: Automatic backup before destructive operations with easy rollback - -## References - -- Constitution: `.specify/memory/constitution.md` (Principles II, X, XII) -- Architectural Patterns: `.specify/memory/patterns.md` (Repository Pattern) -- Feature Spec: `specs/010-codebase-cleanup-and/spec.md` -- Implementation Plan: `specs/010-codebase-cleanup-and/plan.md` diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go new file mode 100644 index 0000000..bdcf879 --- /dev/null +++ b/internal/app/bootstrap.go @@ -0,0 +1,35 @@ +// Package app provides application-level dependency injection. +// bootstrap.go replaces ad-hoc init() side effects with explicit wiring. +package app + +import ( + "github.com/arc-framework/arc-cli/pkg/ui/engine" + "github.com/arc-framework/arc-cli/pkg/ui/theme" +) + +// EngineConfig builds an engine.Config from the application context. +// It wires the new UI engine with the backend services from the app context. +// +// Usage: +// +// cfg := app.EngineConfig(appCtx, loader, views, "A.R.C.", "your workspace") +// return engine.Start(cfg) +func EngineConfig( + ctx *Context, + loader *theme.Loader, + views []engine.View, + title, subtitle string, +) engine.Config { + return engine.Config{ + Mode: engine.ModeDashboard, + Views: views, + Title: title, + Subtitle: subtitle, + Backend: engine.Backend{ + Catalog: ctx.Catalog, + Store: ctx.Store, + }, + Prefs: ctx.Prefs, + Loader: loader, + } +} diff --git a/internal/app/context.go b/internal/app/context.go index 342c5d3..40a2866 100644 --- a/internal/app/context.go +++ b/internal/app/context.go @@ -1,111 +1,33 @@ package app import ( - "sync" - "github.com/arc-framework/arc-cli/internal/config" "github.com/arc-framework/arc-cli/internal/preferences" "github.com/arc-framework/arc-cli/pkg/catalog" "github.com/arc-framework/arc-cli/pkg/log" "github.com/arc-framework/arc-cli/pkg/store" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" ) // Context holds all application-wide dependencies. -// It is created once at CLI startup and passed to all commands via constructor injection. -// This enables dependency injection, eliminates global state, and allows parallel testing. type Context struct { - // Config holds the loaded application configuration - Config *config.Config - - // Logger provides structured logging throughout the application - Logger log.Logger - - // Store provides access to resource and history repositories - Store *store.Store - - // Prefs holds user preferences (theme, UI settings) - Prefs *preferences.Preferences - - // UI provides themed UI operations (Success, Error, Warning, Info, etc.) - UI *ui.Service - - // Catalog provides access to the embedded service catalog - Catalog catalog.Catalog - - // BaseDir is the base directory for all Arc data (~/.arc or XDG equivalent) - BaseDir string - - // NoColor disables all color output (respects NO_COLOR env var) - NoColor bool - - // NoAnimation disables all animations + Config *config.Config + Logger log.Logger + Store *store.Store + Prefs *preferences.Preferences + Catalog catalog.Catalog + BaseDir string + NoColor bool NoAnimation bool - - // SafeBorder holds the terminal capability detection for border rendering. - // Initialized eagerly during CLI startup. - // Spec: 015-ui-refactor, Task T024-T025 - SafeBorder *components.SafeBorder - - // Factory provides themed UI component creation (cards, panels, etc.). - // Created from ProfileContext + SafeBorder during middleware setup. - // Spec: 015-ui-refactor, Task T024-T025 - Factory ui.ComponentFactory - - // profileContext is the cached ProfileContext (lazy loaded) - profileContext *profiles.ProfileContext - profileContextMu sync.RWMutex } -// NewContext creates a new application context with default values. -// Use functional options to customize the context for testing or special cases. func NewContext(opts ...Option) (*Context, error) { - // Create context with sensible defaults ctx := &Context{ - Logger: log.Default(), // Default logger, can be overridden by options + Logger: log.Default(), } - - // Apply all options for _, opt := range opts { if err := opt(ctx); err != nil { return nil, err } } - return ctx, nil } - -// GetProfileContext returns the cached ProfileContext, loading it on first access. -// Uses double-checked locking for thread safety and lazy initialization. -// Always succeeds (fallback to Enterprise profile on errors). -func (c *Context) GetProfileContext() *profiles.ProfileContext { - // First check (fast path - no lock) - c.profileContextMu.RLock() - if c.profileContext != nil { - defer c.profileContextMu.RUnlock() - return c.profileContext - } - c.profileContextMu.RUnlock() - - // Second check (slow path - with lock) - c.profileContextMu.Lock() - defer c.profileContextMu.Unlock() - - // Check again in case another goroutine initialized while we were waiting - if c.profileContext == nil { - c.profileContext = profiles.LoadProfileContextFromPreferences() - } - - return c.profileContext -} - -// InvalidateProfileContext clears the cached ProfileContext. -// Call this after profile changes to force reload on next GetProfileContext(). -func (c *Context) InvalidateProfileContext() { - c.profileContextMu.Lock() - defer c.profileContextMu.Unlock() - - c.profileContext = nil -} diff --git a/internal/app/context_test.go b/internal/app/context_test.go index f794fff..cd761f6 100644 --- a/internal/app/context_test.go +++ b/internal/app/context_test.go @@ -1,8 +1,6 @@ package app import ( - "os" - "sync" "testing" "github.com/stretchr/testify/assert" @@ -156,7 +154,6 @@ func TestNewDefaultContextWithConfig(t *testing.T) { assert.NotNil(t, ctx.Logger) assert.NotNil(t, ctx.Store) assert.NotNil(t, ctx.Prefs) - assert.NotNil(t, ctx.UI) }, }, { @@ -245,173 +242,3 @@ func TestParseLogLevel(t *testing.T) { }) } } - -// Tests for ProfileContext integration (T021) -// Target coverage: 75%+ - -func TestContext_GetProfileContext(t *testing.T) { - t.Run("lazy initialization", func(t *testing.T) { - ctx, err := NewContext() - require.NoError(t, err) - - // First call should initialize - profileCtx1 := ctx.GetProfileContext() - require.NotNil(t, profileCtx1) - - // Second call should return cached instance - profileCtx2 := ctx.GetProfileContext() - require.NotNil(t, profileCtx2) - - // Should return the same instance (pointer equality) - assert.Equal(t, profileCtx1, profileCtx2) - }) - - t.Run("returns valid profile context", func(t *testing.T) { - ctx, err := NewContext() - require.NoError(t, err) - - profileCtx := ctx.GetProfileContext() - require.NotNil(t, profileCtx) - - // Verify it has a valid profile - profile := profileCtx.Profile() - require.NotNil(t, profile) - assert.NotEmpty(t, profile.ID) - - // Verify it has valid tier names - tierNames := profileCtx.TierNames() - assert.Len(t, tierNames, 3) - - // Verify it has a logo - logo := profileCtx.BannerLogo() - assert.NotEmpty(t, logo) - }) - - t.Run("defaults to enterprise profile on fresh install", func(t *testing.T) { - // Create temporary home directory for isolated test - tempHome := t.TempDir() - originalHome := os.Getenv("HOME") - defer func() { - if originalHome != "" { - os.Setenv("HOME", originalHome) - } - }() - os.Setenv("HOME", tempHome) - - // Create context - preferences will be fresh - ctx, err := NewContext() - require.NoError(t, err) - - profileCtx := ctx.GetProfileContext() - require.NotNil(t, profileCtx) - - profile := profileCtx.Profile() - require.NotNil(t, profile) - - // Should default to enterprise on fresh install - assert.Equal(t, "enterprise", profile.ID) - }) -} - -func TestContext_InvalidateProfileContext(t *testing.T) { - t.Run("clears cached context", func(t *testing.T) { - ctx, err := NewContext() - require.NoError(t, err) - - // Get initial profile context - profileCtx1 := ctx.GetProfileContext() - require.NotNil(t, profileCtx1) - - // Invalidate cache - ctx.InvalidateProfileContext() - - // Get profile context again - profileCtx2 := ctx.GetProfileContext() - require.NotNil(t, profileCtx2) - - // Should be different instances (new allocation) - // Note: We can't guarantee different pointers without changing preferences, - // but we verify the mechanism works - assert.NotNil(t, profileCtx1) - assert.NotNil(t, profileCtx2) - }) - - t.Run("allows reload after invalidation", func(t *testing.T) { - ctx, err := NewContext() - require.NoError(t, err) - - // Get and cache - profileCtx1 := ctx.GetProfileContext() - require.NotNil(t, profileCtx1) - - // Invalidate - ctx.InvalidateProfileContext() - - // Should be able to get again - profileCtx2 := ctx.GetProfileContext() - require.NotNil(t, profileCtx2) - - // Both should have valid profiles - assert.NotNil(t, profileCtx1.Profile()) - assert.NotNil(t, profileCtx2.Profile()) - }) -} - -func TestContext_ProfileContextThreadSafety(t *testing.T) { - ctx, err := NewContext() - require.NoError(t, err) - - // Concurrently access GetProfileContext - var wg sync.WaitGroup - iterations := 50 - - for i := 0; i < iterations; i++ { - wg.Add(1) - go func() { - defer wg.Done() - profileCtx := ctx.GetProfileContext() - assert.NotNil(t, profileCtx) - assert.NotNil(t, profileCtx.Profile()) - }() - } - - wg.Wait() - - // Verify context is still valid - profileCtx := ctx.GetProfileContext() - assert.NotNil(t, profileCtx) - assert.NotNil(t, profileCtx.Profile()) -} - -func TestContext_ProfileContextInvalidationThreadSafety(t *testing.T) { - ctx, err := NewContext() - require.NoError(t, err) - - // Concurrently access and invalidate - var wg sync.WaitGroup - iterations := 20 - - for i := 0; i < iterations; i++ { - wg.Add(2) - - // Reader goroutine - go func() { - defer wg.Done() - profileCtx := ctx.GetProfileContext() - assert.NotNil(t, profileCtx) - }() - - // Invalidator goroutine - go func() { - defer wg.Done() - ctx.InvalidateProfileContext() - }() - } - - wg.Wait() - - // Final verification - profileCtx := ctx.GetProfileContext() - assert.NotNil(t, profileCtx) - assert.NotNil(t, profileCtx.Profile()) -} diff --git a/internal/app/factory.go b/internal/app/factory.go index 3462aa4..4e7787d 100644 --- a/internal/app/factory.go +++ b/internal/app/factory.go @@ -10,8 +10,6 @@ import ( "github.com/arc-framework/arc-cli/pkg/log" "github.com/arc-framework/arc-cli/pkg/store" "github.com/arc-framework/arc-cli/pkg/store/local" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/themes" ) // NewDefaultContextWithConfig creates a new context with the provided configuration. @@ -47,27 +45,9 @@ func NewDefaultContextWithConfig(cfg *config.Config) (*Context, error) { // Load user preferences prefs, err := preferences.Load() if err != nil { - // If preferences don't exist, use defaults prefs = preferences.Default() } - // Load theme (user preference or default) - themeName := prefs.GetTheme() - if themeName == "" { - themeName = cfg.UI.Theme // Fall back to config - } - - themeLoader := themes.NewLoader() - theme, err := themeLoader.Load(themeName) - if err != nil { - // If theme loading fails, use default - logger.Warn("Failed to load theme, using default", "theme", themeName, "error", err) - theme, _ = themes.GetDefault() - } - - // Create UI service with loaded theme - uiService := ui.NewService(theme, logger) - // Initialize service catalog catalogStart := time.Now() serviceCatalog, err := catalog.NewEmbeddedCatalog() @@ -78,13 +58,11 @@ func NewDefaultContextWithConfig(cfg *config.Config) (*Context, error) { "duration", time.Since(catalogStart), "services", serviceCatalog.ServiceCount()) - // Build context with all dependencies ctx := &Context{ Config: cfg, Logger: logger, Store: storeInstance, Prefs: prefs, - UI: uiService, Catalog: serviceCatalog, BaseDir: baseDir, NoColor: cfg.UI.NoColor, diff --git a/internal/app/history/README.md b/internal/app/history/README.md deleted file mode 100644 index 87c16d1..0000000 --- a/internal/app/history/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# History Tracking (Reserved) - -**Status**: Reserved for future implementation -**Constitutional Principle**: Principle XI - Stateful Operations & Smart Resource Management - -## Purpose - -This directory is reserved for implementing operational history tracking and querying in the A.R.C. CLI. When implemented, this component will provide rich query capabilities over historical operations, events, and state transitions for troubleshooting and auditing. - -## Planned Functionality - -- **Operation History**: Query past CLI command executions with filtering by type, status, service, and time range -- **Event Timeline**: Browse chronological events (state transitions, failures, warnings) with severity filtering -- **Failure Analysis**: Identify patterns in failed operations using `arc history --failed --service postgres` -- **Audit Trail**: Generate compliance reports showing who ran what commands when -- **Performance Analytics**: Track operation duration trends to identify performance degradation -- **Replay Capability**: Re-execute past operations for troubleshooting or migration - -## Implementation Checklist - -When implementing history tracking, ensure adherence to: - -- **Repository Pattern**: Implement `HistoryRepository` interface in `pkg/store/` with implementation here -- **Embedded Storage**: Read from state database (`~/.local/share/arc/state.db`) created by `internal/app/state/` component -- **Query Interface**: Support filtering by operation type, status, service, time range, and user -- **Performance**: History queries must complete in <50ms for typical filters, <200ms for complex aggregations -- **Factory Pattern**: Inject state database connection via `internal/app/factory.go` -- **XDG Compliance**: Access state from XDG Data Directory (Constitution Principle XII) - -## Query Examples - -```bash -arc history # Show recent operations (last 24 hours) -arc history --failed # Show only failed operations -arc history --operation-type up # Show all 'arc up' executions -arc history --service postgres # Operations affecting PostgreSQL -arc history --since 7d --user alice # Operations by alice in last 7 days -arc events --severity error --since 1h # Recent errors -arc stats --service redis --metric latency # Health metrics over time -``` - -## Data Model - -History queries will read from the state database tables: - -- **operations**: Every CLI command execution (id, type, status, timestamp, duration, user) -- **events**: State transitions and errors (timestamp, severity, source, message) -- **resources**: Resource lifecycle changes (resource_id, operation_id, action, timestamp) -- **health_checks**: Service health over time (service, timestamp, status, latency) - -## References - -- Constitution: `.specify/memory/constitution.md` (Principle XI) -- Architectural Patterns: `.specify/memory/patterns.md` (Repository Pattern) -- Related Component: `internal/app/state/` (creates the state database) -- Feature Spec: `specs/010-codebase-cleanup-and/spec.md` -- Implementation Plan: `specs/010-codebase-cleanup-and/plan.md` diff --git a/internal/app/options.go b/internal/app/options.go index 19eef7f..a83a3af 100644 --- a/internal/app/options.go +++ b/internal/app/options.go @@ -22,9 +22,9 @@ func WithLogger(logger log.Logger) Option { // WithStore sets a custom store for the context. // Useful for testing with mock repositories. -func WithStore(store *store.Store) Option { +func WithStore(s *store.Store) Option { return func(ctx *Context) error { - ctx.Store = store + ctx.Store = s return nil } } diff --git a/internal/app/state/README.md b/internal/app/state/README.md deleted file mode 100644 index b73b7ac..0000000 --- a/internal/app/state/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# State Management (Reserved) - -**Status**: Reserved for future implementation -**Constitutional Principle**: Principle XI - Stateful Operations & Smart Resource Management - -## Purpose - -This directory is reserved for implementing stateful operation tracking and resource management in the A.R.C. CLI. When implemented, this component will persist all CLI operations, resources, and user decisions in an embedded database to enable intelligent behavior and historical context. - -## Planned Functionality - -- **Operation Tracking**: Log every `arc` command execution with timestamp, user, command, arguments, result, and duration -- **Resource Lifecycle**: Track containers, volumes, networks, and configs throughout their lifecycle (created, modified, started, stopped, deleted) -- **Decision Memory**: Remember user choices during interactive prompts (image pull preferences, config selections, volume preferences) -- **Historical Queries**: Enable commands like `arc history --failed` to troubleshoot past operations -- **Smart Resource Management**: Conditional resource creation based on tracked state ("PostgreSQL volume already exists, reuse?") - -## Implementation Checklist - -When implementing state management, ensure adherence to: - -- **Storage**: Use embedded database (SQLite/BoltDB) in `.arc/state.db` (Constitution Principle XII) -- **Architecture Pattern**: Implement Repository Pattern with interfaces in `pkg/store/` and implementation here -- **XDG Compliance**: Store state database in XDG Data Directory (`~/.local/share/arc/state.db`) -- **Factory Pattern**: Inject dependencies via `internal/app/factory.go`, avoid global state -- **Performance**: State queries must complete in <10ms for typical queries, <50ms for complex aggregations -- **Local-First**: All state must persist locally, no external dependencies (Constitution Principle II) - -## References - -- Constitution: `.specify/memory/constitution.md` (Principles XI, XII) -- Architectural Patterns: `.specify/memory/patterns.md` (Repository Pattern) -- Feature Spec: `specs/010-codebase-cleanup-and/spec.md` -- Implementation Plan: `specs/010-codebase-cleanup-and/plan.md` diff --git a/internal/branding/info.go b/internal/branding/info.go index 4a320a2..8028942 100644 --- a/internal/branding/info.go +++ b/internal/branding/info.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "github.com/arc-framework/arc-cli/internal/version" + "github.com/arc-framework/arc-cli/pkg/version" ) const ( @@ -75,7 +75,7 @@ func CollectSystemInfo() (*SystemInfo, error) { // Collect CLI information info.CLIVersion = version.Version info.CLIBuildDate = version.BuildDate - info.CLICommit = version.GitCommit + info.CLICommit = version.Commit // Collect Go runtime information info.GoVersion = runtime.Version() @@ -233,185 +233,3 @@ func FormatBytes(bytes int64) string { } return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) } - -// getCPUModel returns the CPU model/brand string. -// Uses platform-specific methods to retrieve this information. -func getCPUModel() string { - switch runtime.GOOS { - case OSDarwin: - return getCPUModelDarwin() - case OSLinux: - return getCPUModelLinux() - case OSWindows: - return getCPUModelWindows() - default: - return UnknownValue - } -} - -// getCPUModelDarwin retrieves CPU model on macOS using sysctl. -func getCPUModelDarwin() string { - cmd := exec.Command("sysctl", "-n", "machdep.cpu.brand_string") - output, err := cmd.Output() - if err != nil { - return UnknownValue - } - return strings.TrimSpace(string(output)) -} - -// getCPUModelLinux retrieves CPU model from /proc/cpuinfo. -func getCPUModelLinux() string { - data, err := os.ReadFile("/proc/cpuinfo") - if err != nil { - return UnknownValue - } - - lines := strings.Split(string(data), "\n") - for _, line := range lines { - if strings.HasPrefix(line, "model name") { - parts := strings.SplitN(line, ":", 2) - if len(parts) == 2 { - return strings.TrimSpace(parts[1]) - } - } - } - return UnknownValue -} - -// getCPUModelWindows retrieves CPU model using wmic on Windows. -func getCPUModelWindows() string { - cmd := exec.Command("wmic", "cpu", "get", "name") - output, err := cmd.Output() - if err != nil { - return UnknownValue - } - - lines := strings.Split(string(output), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line != "" && line != "Name" { - return line - } - } - return UnknownValue -} - -// getMemoryInfo returns total and free memory in bytes. -// Uses platform-specific methods to retrieve this information. -func getMemoryInfo() (total, free uint64) { - switch runtime.GOOS { - case OSDarwin: - return getMemoryInfoDarwin() - case OSLinux: - return getMemoryInfoLinux() - case OSWindows: - return getMemoryInfoWindows() - default: - return 0, 0 - } -} - -// getMemoryInfoDarwin retrieves memory info on macOS using sysctl. -func getMemoryInfoDarwin() (total, free uint64) { - // Get total memory - cmd := exec.Command("sysctl", "-n", "hw.memsize") - output, err := cmd.Output() - if err == nil { - _, _ = fmt.Sscanf(strings.TrimSpace(string(output)), "%d", &total) - } - - // Get page size and free pages for available memory - pageSize := uint64(os.Getpagesize()) - - cmd = exec.Command("vm_stat") - output, err = cmd.Output() - if err == nil { - lines := strings.Split(string(output), "\n") - var freePages, inactivePages uint64 - for _, line := range lines { - if strings.HasPrefix(line, "Pages free:") { - _, _ = fmt.Sscanf(line, "Pages free: %d", &freePages) - } else if strings.HasPrefix(line, "Pages inactive:") { - _, _ = fmt.Sscanf(line, "Pages inactive: %d", &inactivePages) - } - } - // Free memory = (free pages + inactive pages) * page size - free = (freePages + inactivePages) * pageSize - } - - return total, free -} - -// getMemoryInfoLinux retrieves memory info from /proc/meminfo. -func getMemoryInfoLinux() (total, free uint64) { - data, err := os.ReadFile("/proc/meminfo") - if err != nil { - return 0, 0 - } - - lines := strings.Split(string(data), "\n") - for _, line := range lines { - if strings.HasPrefix(line, "MemTotal:") { - var kb uint64 - _, _ = fmt.Sscanf(line, "MemTotal: %d kB", &kb) - total = kb * 1024 - } else if strings.HasPrefix(line, "MemAvailable:") { - var kb uint64 - _, _ = fmt.Sscanf(line, "MemAvailable: %d kB", &kb) - free = kb * 1024 - } - } - - return total, free -} - -// getMemoryInfoWindows retrieves memory info using wmic on Windows. -func getMemoryInfoWindows() (total, free uint64) { - // Get total memory - cmd := exec.Command("wmic", "computersystem", "get", "totalphysicalmemory") - output, err := cmd.Output() - if err == nil { - lines := strings.Split(string(output), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line != "" && line != "TotalPhysicalMemory" { - _, _ = fmt.Sscanf(line, "%d", &total) - break - } - } - } - - // Get free memory - cmd = exec.Command("wmic", "os", "get", "freephysicalmemory") - output, err = cmd.Output() - if err == nil { - lines := strings.Split(string(output), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line != "" && line != "FreePhysicalMemory" { - var kb uint64 - _, _ = fmt.Sscanf(line, "%d", &kb) - free = kb * 1024 // Convert from KB to bytes - break - } - } - } - - return total, free -} - -// GetCPUInfo returns formatted CPU information string. -func GetCPUInfo() string { - model := getCPUModel() - numCPU := runtime.NumCPU() - return fmt.Sprintf("%s (%d cores)", model, numCPU) -} - -// GetMemoryInfo returns formatted memory information string. -func GetMemoryInfo() string { - total, free := getMemoryInfo() - if total == 0 { - return UnknownValue - } - return fmt.Sprintf("%s total, %s free", FormatBytes(int64(total)), FormatBytes(int64(free))) -} diff --git a/internal/branding/sysinfo.go b/internal/branding/sysinfo.go new file mode 100644 index 0000000..1519b30 --- /dev/null +++ b/internal/branding/sysinfo.go @@ -0,0 +1,186 @@ +package branding + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strings" +) + +// getCPUModel returns the CPU model/brand string. +// Uses platform-specific methods to retrieve this information. +func getCPUModel() string { + switch runtime.GOOS { + case OSDarwin: + return getCPUModelDarwin() + case OSLinux: + return getCPUModelLinux() + case OSWindows: + return getCPUModelWindows() + default: + return UnknownValue + } +} + +// getCPUModelDarwin retrieves CPU model on macOS using sysctl. +func getCPUModelDarwin() string { + cmd := exec.Command("sysctl", "-n", "machdep.cpu.brand_string") + output, err := cmd.Output() + if err != nil { + return UnknownValue + } + return strings.TrimSpace(string(output)) +} + +// getCPUModelLinux retrieves CPU model from /proc/cpuinfo. +func getCPUModelLinux() string { + data, err := os.ReadFile("/proc/cpuinfo") + if err != nil { + return UnknownValue + } + + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "model name") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + return strings.TrimSpace(parts[1]) + } + } + } + return UnknownValue +} + +// getCPUModelWindows retrieves CPU model using wmic on Windows. +func getCPUModelWindows() string { + cmd := exec.Command("wmic", "cpu", "get", "name") + output, err := cmd.Output() + if err != nil { + return UnknownValue + } + + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" && line != "Name" { + return line + } + } + return UnknownValue +} + +// getMemoryInfo returns total and free memory in bytes. +// Uses platform-specific methods to retrieve this information. +func getMemoryInfo() (total, free uint64) { + switch runtime.GOOS { + case OSDarwin: + return getMemoryInfoDarwin() + case OSLinux: + return getMemoryInfoLinux() + case OSWindows: + return getMemoryInfoWindows() + default: + return 0, 0 + } +} + +// getMemoryInfoDarwin retrieves memory info on macOS using sysctl. +func getMemoryInfoDarwin() (total, free uint64) { + cmd := exec.Command("sysctl", "-n", "hw.memsize") + output, err := cmd.Output() + if err == nil { + _, _ = fmt.Sscanf(strings.TrimSpace(string(output)), "%d", &total) + } + + pageSize := uint64(os.Getpagesize()) + + cmd = exec.Command("vm_stat") + output, err = cmd.Output() + if err == nil { + lines := strings.Split(string(output), "\n") + var freePages, inactivePages uint64 + for _, line := range lines { + if strings.HasPrefix(line, "Pages free:") { + _, _ = fmt.Sscanf(line, "Pages free: %d", &freePages) + } else if strings.HasPrefix(line, "Pages inactive:") { + _, _ = fmt.Sscanf(line, "Pages inactive: %d", &inactivePages) + } + } + free = (freePages + inactivePages) * pageSize + } + + return total, free +} + +// getMemoryInfoLinux retrieves memory info from /proc/meminfo. +func getMemoryInfoLinux() (total, free uint64) { + data, err := os.ReadFile("/proc/meminfo") + if err != nil { + return 0, 0 + } + + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "MemTotal:") { + var kb uint64 + _, _ = fmt.Sscanf(line, "MemTotal: %d kB", &kb) + total = kb * 1024 + } else if strings.HasPrefix(line, "MemAvailable:") { + var kb uint64 + _, _ = fmt.Sscanf(line, "MemAvailable: %d kB", &kb) + free = kb * 1024 + } + } + + return total, free +} + +// getMemoryInfoWindows retrieves memory info using wmic on Windows. +func getMemoryInfoWindows() (total, free uint64) { + cmd := exec.Command("wmic", "computersystem", "get", "totalphysicalmemory") + output, err := cmd.Output() + if err == nil { + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" && line != "TotalPhysicalMemory" { + _, _ = fmt.Sscanf(line, "%d", &total) + break + } + } + } + + cmd = exec.Command("wmic", "os", "get", "freephysicalmemory") + output, err = cmd.Output() + if err == nil { + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" && line != "FreePhysicalMemory" { + var kb uint64 + _, _ = fmt.Sscanf(line, "%d", &kb) + free = kb * 1024 + break + } + } + } + + return total, free +} + +// GetCPUInfo returns formatted CPU information string. +func GetCPUInfo() string { + model := getCPUModel() + numCPU := runtime.NumCPU() + return fmt.Sprintf("%s (%d cores)", model, numCPU) +} + +// GetMemoryInfo returns formatted memory information string. +func GetMemoryInfo() string { + total, free := getMemoryInfo() + if total == 0 { + return UnknownValue + } + return fmt.Sprintf("%s total, %s free", FormatBytes(int64(total)), FormatBytes(int64(free))) +} diff --git a/internal/preferences/preferences.go b/internal/preferences/preferences.go index d1ee26e..94f3ca6 100644 --- a/internal/preferences/preferences.go +++ b/internal/preferences/preferences.go @@ -12,6 +12,7 @@ import ( type Preferences struct { Theme string `json:"theme"` // Current theme: "cyan-purple", "rainbow", "fire", "ocean", "matrix", "character-rainbow" Profile string `json:"profile,omitempty"` // Active profile ID (e.g., "saiyan", "jedi") + Skin string `json:"skin,omitempty"` // Active skin ID (e.g., "arc", "minimal") BorderMode string `json:"border_mode,omitempty"` // Border mode: "" (auto), "none", "block", "classic" } @@ -141,3 +142,14 @@ func (p *Preferences) SetProfileWithThemeSync(profileID, themeID string) error { // Single atomic save return p.Save() } + +// SetSkin sets the skin ID and saves the preferences. +func (p *Preferences) SetSkin(skinID string) error { + p.Skin = skinID + return p.Save() +} + +// GetSkin returns the current skin ID, or empty string if none is saved. +func (p *Preferences) GetSkin() string { + return p.Skin +} diff --git a/internal/state/serializer.go b/internal/state/serializer.go index d8ede1f..3c2648a 100644 --- a/internal/state/serializer.go +++ b/internal/state/serializer.go @@ -94,7 +94,7 @@ func (s *Serializer) writeAtomic(path string, data interface{}, marshal func(int // AppendJSON appends a JSON entry to an array file atomically func (s *Serializer) AppendJSON(path string, entry interface{}) error { // Read existing entries - var entries []interface{} + entries := make([]interface{}, 0, 1) // Check if file exists exists, err := afero.Exists(s.fs, path) diff --git a/internal/terminal/detect.go b/internal/terminal/detect.go deleted file mode 100644 index a4c3a7b..0000000 --- a/internal/terminal/detect.go +++ /dev/null @@ -1,183 +0,0 @@ -// Package terminal provides terminal capability detection and configuration. -package terminal - -import ( - "os" - "strings" - - "golang.org/x/term" -) - -// ColorProfile defines the terminal's color support level. -type ColorProfile int - -const ( - // NoColor indicates no color support. - NoColor ColorProfile = iota - // Color16 indicates basic 16-color support. - Color16 - // Color256 indicates 256-color support. - Color256 - // TrueColor indicates 24-bit RGB color support. - TrueColor -) - -// Capabilities describes terminal features and capabilities. -type Capabilities struct { - // IsTTY indicates if stdout is a terminal. - IsTTY bool - - // Width is the terminal width in columns. - Width int - - // Height is the terminal height in rows. - Height int - - // ColorProfile indicates the color support level. - ColorProfile ColorProfile - - // SupportsUnicode indicates if the terminal supports Unicode characters. - SupportsUnicode bool - - // SupportsEmoji indicates if the terminal supports emoji rendering. - SupportsEmoji bool - - // NoColorForced indicates if NO_COLOR environment variable is set. - NoColorForced bool - - // ColorForced indicates if CLICOLOR_FORCE environment variable is set. - ColorForced bool -} - -// Detector provides terminal capability detection. -type Detector struct{} - -// NewDetector creates a new terminal capability detector. -func NewDetector() *Detector { - return &Detector{} -} - -// Detect performs comprehensive terminal capability detection. -func (d *Detector) Detect() Capabilities { - caps := Capabilities{ - IsTTY: d.IsInteractive(), - SupportsUnicode: true, // Assume Unicode support by default - SupportsEmoji: true, // Assume emoji support by default - NoColorForced: os.Getenv("NO_COLOR") != "", - ColorForced: os.Getenv("CLICOLOR_FORCE") != "" && os.Getenv("CLICOLOR_FORCE") != "0", - } - - // Detect terminal size - caps.Width, caps.Height = d.detectSize() - - // Detect color profile - caps.ColorProfile = d.detectColorProfile(caps.IsTTY, caps.NoColorForced, caps.ColorForced) - - return caps -} - -// IsInteractive returns true if stdout is connected to a terminal. -func (d *Detector) IsInteractive() bool { - return term.IsTerminal(int(os.Stdout.Fd())) -} - -// Width returns the terminal width in columns. -func (d *Detector) Width() int { - width, _ := d.detectSize() - return width -} - -// Height returns the terminal height in rows. -func (d *Detector) Height() int { - _, height := d.detectSize() - return height -} - -// detectSize detects the terminal dimensions. -func (d *Detector) detectSize() (width, height int) { - if !d.IsInteractive() { - return 80, 24 // Default for non-TTY - } - - w, h, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil { - return 80, 24 // Default on error - } - - return w, h -} - -// detectColorProfile determines the terminal's color support level. -func (d *Detector) detectColorProfile(isTTY, noColor, colorForced bool) ColorProfile { - // NO_COLOR takes precedence - if noColor { - return NoColor - } - - // CLICOLOR_FORCE=1 forces colors even in non-TTY - if colorForced { - return d.detectColorLevel() - } - - // Non-TTY defaults to no color - if !isTTY { - return NoColor - } - - // Detect based on environment - return d.detectColorLevel() -} - -// detectColorLevel determines the color support level from environment variables. -func (d *Detector) detectColorLevel() ColorProfile { - // Check COLORTERM for true color support - colorterm := os.Getenv("COLORTERM") - if colorterm == "truecolor" || colorterm == "24bit" { - return TrueColor - } - - // Check TERM variable - termEnv := os.Getenv("TERM") - - // Dumb terminal - no colors - if termEnv == "dumb" { - return NoColor - } - - // Check for 256 color support - if strings.Contains(termEnv, "256color") { - return Color256 - } - - // Check for any color support - if strings.Contains(termEnv, "color") { - return Color16 - } - - // Common terminals with good color support - if strings.HasPrefix(termEnv, "xterm") || - strings.HasPrefix(termEnv, "screen") || - strings.HasPrefix(termEnv, "tmux") || - strings.HasPrefix(termEnv, "rxvt") { - return Color16 - } - - // Default to no color if unsure - return NoColor -} - -// String returns a human-readable string for the color profile. -func (cp ColorProfile) String() string { - switch cp { - case NoColor: - return "no-color" - case Color16: - return "16-color" - case Color256: - return "256-color" - case TrueColor: - return "true-color" - default: - return "unknown" - } -} diff --git a/internal/terminal/detect_test.go b/internal/terminal/detect_test.go deleted file mode 100644 index d60a7c9..0000000 --- a/internal/terminal/detect_test.go +++ /dev/null @@ -1,414 +0,0 @@ -package terminal - -import ( - "os" - "testing" -) - -func TestNewDetector(t *testing.T) { - detector := NewDetector() - if detector == nil { - t.Fatal("NewDetector() returned nil") - } -} - -func TestColorProfileString(t *testing.T) { - tests := []struct { - name string - profile ColorProfile - expected string - }{ - {"NoColor", NoColor, "no-color"}, - {"Color16", Color16, "16-color"}, - {"Color256", Color256, "256-color"}, - {"TrueColor", TrueColor, "true-color"}, - {"Unknown", ColorProfile(999), "unknown"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tt.profile.String() - if got != tt.expected { - t.Errorf("ColorProfile.String() = %v, want %v", got, tt.expected) - } - }) - } -} - -func TestDetectColorLevel(t *testing.T) { - tests := []struct { - name string - colorterm string - term string - expected ColorProfile - description string - }{ - { - name: "TrueColor_truecolor", - colorterm: "truecolor", - term: "xterm-256color", - expected: TrueColor, - description: "COLORTERM=truecolor should give TrueColor", - }, - { - name: "TrueColor_24bit", - colorterm: "24bit", - term: "xterm-256color", - expected: TrueColor, - description: "COLORTERM=24bit should give TrueColor", - }, - { - name: "Color256_term", - colorterm: "", - term: "xterm-256color", - expected: Color256, - description: "TERM=xterm-256color should give Color256", - }, - { - name: "Color256_color", - colorterm: "", - term: "screen-256color", - expected: Color256, - description: "TERM with 256color should give Color256", - }, - { - name: "Color16_xterm", - colorterm: "", - term: "xterm", - expected: Color16, - description: "TERM=xterm should give Color16", - }, - { - name: "Color16_screen", - colorterm: "", - term: "screen", - expected: Color16, - description: "TERM=screen should give Color16", - }, - { - name: "Color16_tmux", - colorterm: "", - term: "tmux", - expected: Color16, - description: "TERM=tmux should give Color16", - }, - { - name: "Color16_rxvt", - colorterm: "", - term: "rxvt", - expected: Color16, - description: "TERM=rxvt should give Color16", - }, - { - name: "Color16_color", - colorterm: "", - term: "ansi-color", - expected: Color16, - description: "TERM with color should give Color16", - }, - { - name: "NoColor_dumb", - colorterm: "", - term: "dumb", - expected: NoColor, - description: "TERM=dumb should give NoColor", - }, - { - name: "NoColor_unknown", - colorterm: "", - term: "unknown-term", - expected: NoColor, - description: "Unknown TERM should default to NoColor", - }, - { - name: "NoColor_empty", - colorterm: "", - term: "", - expected: NoColor, - description: "Empty TERM should give NoColor", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Save original env - origColorterm := os.Getenv("COLORTERM") - origTerm := os.Getenv("TERM") - defer func() { - if origColorterm != "" { - os.Setenv("COLORTERM", origColorterm) - } else { - os.Unsetenv("COLORTERM") - } - if origTerm != "" { - os.Setenv("TERM", origTerm) - } else { - os.Unsetenv("TERM") - } - }() - - // Set test env - if tt.colorterm != "" { - os.Setenv("COLORTERM", tt.colorterm) - } else { - os.Unsetenv("COLORTERM") - } - if tt.term != "" { - os.Setenv("TERM", tt.term) - } else { - os.Unsetenv("TERM") - } - - // Test - detector := NewDetector() - got := detector.detectColorLevel() - if got != tt.expected { - t.Errorf("%s: got %v, want %v", tt.description, got, tt.expected) - } - }) - } -} - -func TestDetectColorProfile(t *testing.T) { - tests := []struct { - name string - noColor string - colorForce string - term string - expected []ColorProfile // Changed to a slice - description string - }{ - { - name: "NO_COLOR_precedence", - noColor: "1", - colorForce: "1", - term: "xterm-256color", - expected: []ColorProfile{NoColor}, - description: "NO_COLOR should take precedence over everything", - }, - { - name: "NO_COLOR_empty_string", - noColor: "", - colorForce: "", - term: "xterm-256color", - expected: []ColorProfile{NoColor}, - description: "Non-TTY without force should give NoColor", - }, - { - name: "CLICOLOR_FORCE_with_colors", - noColor: "", - colorForce: "1", - term: "xterm-256color", - expected: []ColorProfile{Color256, TrueColor}, // Expect either - description: "CLICOLOR_FORCE=1 should enable colors", - }, - { - name: "CLICOLOR_FORCE_zero", - noColor: "", - colorForce: "0", - term: "xterm-256color", - expected: []ColorProfile{NoColor}, - description: "CLICOLOR_FORCE=0 treated as non-TTY", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Save original env - origNoColor := os.Getenv("NO_COLOR") - origColorForce := os.Getenv("CLICOLOR_FORCE") - origTerm := os.Getenv("TERM") - defer func() { - if origNoColor != "" { - os.Setenv("NO_COLOR", origNoColor) - } else { - os.Unsetenv("NO_COLOR") - } - if origColorForce != "" { - os.Setenv("CLICOLOR_FORCE", origColorForce) - } else { - os.Unsetenv("CLICOLOR_FORCE") - } - if origTerm != "" { - os.Setenv("TERM", origTerm) - } else { - os.Unsetenv("TERM") - } - }() - - // Set test env - if tt.noColor != "" { - os.Setenv("NO_COLOR", tt.noColor) - } else { - os.Unsetenv("NO_COLOR") - } - if tt.colorForce != "" { - os.Setenv("CLICOLOR_FORCE", tt.colorForce) - } else { - os.Unsetenv("CLICOLOR_FORCE") - } - if tt.term != "" { - os.Setenv("TERM", tt.term) - } else { - os.Unsetenv("TERM") - } - - // Test - detector := NewDetector() - noColorForced := os.Getenv("NO_COLOR") != "" - colorForced := os.Getenv("CLICOLOR_FORCE") != "" && os.Getenv("CLICOLOR_FORCE") != "0" - - got := detector.detectColorProfile(false, noColorForced, colorForced) - - // Check if got is one of the expected profiles - found := false - for _, expected := range tt.expected { - if got == expected { - found = true - break - } - } - - if !found { - t.Errorf("%s: got %v, want one of %v", tt.description, got, tt.expected) - } - }) - } -} - -func TestDetectSize(t *testing.T) { - detector := NewDetector() - - width, height := detector.detectSize() - - // Should always return positive dimensions - if width <= 0 { - t.Errorf("detectSize() width = %d, want > 0", width) - } - if height <= 0 { - t.Errorf("detectSize() height = %d, want > 0", height) - } - - // Default for non-TTY should be 80x24 - // But we can't test this reliably since test might run in TTY or not - // At minimum, check reasonable bounds - if width < 20 || width > 1000 { - t.Errorf("detectSize() width = %d, seems unreasonable", width) - } - if height < 10 || height > 200 { - t.Errorf("detectSize() height = %d, seems unreasonable", height) - } -} - -func TestWidth(t *testing.T) { - detector := NewDetector() - width := detector.Width() - - if width <= 0 { - t.Errorf("Width() = %d, want > 0", width) - } -} - -func TestHeight(t *testing.T) { - detector := NewDetector() - height := detector.Height() - - if height <= 0 { - t.Errorf("Height() = %d, want > 0", height) - } -} - -func TestDetect(t *testing.T) { - // Save original env - origNoColor := os.Getenv("NO_COLOR") - origColorForce := os.Getenv("CLICOLOR_FORCE") - defer func() { - if origNoColor != "" { - os.Setenv("NO_COLOR", origNoColor) - } else { - os.Unsetenv("NO_COLOR") - } - if origColorForce != "" { - os.Setenv("CLICOLOR_FORCE", origColorForce) - } else { - os.Unsetenv("CLICOLOR_FORCE") - } - }() - - tests := []struct { - name string - noColor string - colorForce string - }{ - { - name: "default", - noColor: "", - colorForce: "", - }, - { - name: "NO_COLOR_set", - noColor: "1", - colorForce: "", - }, - { - name: "CLICOLOR_FORCE_set", - noColor: "", - colorForce: "1", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.noColor != "" { - os.Setenv("NO_COLOR", tt.noColor) - } else { - os.Unsetenv("NO_COLOR") - } - if tt.colorForce != "" { - os.Setenv("CLICOLOR_FORCE", tt.colorForce) - } else { - os.Unsetenv("CLICOLOR_FORCE") - } - - detector := NewDetector() - caps := detector.Detect() - - // Verify struct fields are populated - if caps.Width <= 0 { - t.Errorf("Detect() Width = %d, want > 0", caps.Width) - } - if caps.Height <= 0 { - t.Errorf("Detect() Height = %d, want > 0", caps.Height) - } - - // Verify environment variable detection - if tt.noColor != "" && !caps.NoColorForced { - t.Error("Detect() NoColorForced = false, want true when NO_COLOR is set") - } - if tt.colorForce != "" && !caps.ColorForced { - t.Error("Detect() ColorForced = false, want true when CLICOLOR_FORCE is set") - } - - // Verify NO_COLOR forces NoColor profile - if caps.NoColorForced && caps.ColorProfile != NoColor { - t.Errorf("Detect() ColorProfile = %v, want NoColor when NO_COLOR is set", caps.ColorProfile) - } - - // Unicode and Emoji should be assumed true by default - if !caps.SupportsUnicode { - t.Error("Detect() SupportsUnicode = false, want true by default") - } - if !caps.SupportsEmoji { - t.Error("Detect() SupportsEmoji = false, want true by default") - } - }) - } -} - -func TestIsInteractive(t *testing.T) { - detector := NewDetector() - - // We can't reliably test this since it depends on how tests are run - // Just verify it returns a boolean without panicking - _ = detector.IsInteractive() -} diff --git a/internal/testing/README.md b/internal/testing/README.md deleted file mode 100644 index 5a7dff7..0000000 --- a/internal/testing/README.md +++ /dev/null @@ -1,315 +0,0 @@ -# Internal Testing Package - -This package provides testing utilities, mocks, and fixtures for the A.R.C. CLI project. - -## Contents - -- **assertions.go** - Custom assertion helpers for cleaner test code -- **fixtures.go** - Test context builders and fixture creation -- **golden.go** - Golden file testing utilities -- **helpers.go** - General test helper functions -- **mocks.go** - Mock implementations of core interfaces - -## Golden File Testing - -Golden file testing is a pattern where expected outputs are stored in reference files ("golden files") and compared -against actual test outputs. This is particularly useful for testing complex outputs like formatted text, CLI banners, -generated configs, etc. - -### Quick Start - -```go -import ( -"testing" -arct "github.com/arc-framework/arc-cli/internal/testing" -) - -func TestBanner(t *testing.T) { -banner := GenerateBanner("v1.0.0") - -// Compare against golden file in testdata/golden/banner.golden -arct.GoldenString(t, "banner", banner) -} -``` - -### Workflow - -#### 1. Writing Tests - -Create a test that generates output and compares it to a golden file: - -```go -func TestHelpOutput(t *testing.T) { -output := GenerateHelpText() -arct.GoldenString(t, "help-output", output) -} -``` - -#### 2. Creating Initial Golden Files - -Run tests with the `-update-golden` flag to create golden files: - -```bash -go test -update-golden ./... -``` - -This creates `testdata/golden/help-output.golden` with the current output. - -#### 3. Running Tests Normally - -Run tests without the flag to compare against golden files: - -```bash -go test ./... -``` - -If output doesn't match, the test fails with a diff showing what changed. - -#### 4. Updating Golden Files - -When you intentionally change output, update the golden files: - -```bash -go test -update-golden ./path/to/package -``` - -**⚠️ Important:** Review changes carefully before committing updated golden files! - -### Available Functions - -#### `GoldenFile(t, name, data)` - -Compares byte data against a golden file. - -```go -func TestConfig(t *testing.T) { -cfg := GenerateConfig() -data, _ := yaml.Marshal(cfg) -arct.GoldenFile(t, "config", data) -} -``` - -#### `GoldenString(t, name, str)` - -Convenience wrapper for string data. - -```go -func TestOutput(t *testing.T) { -output := FormatOutput() -arct.GoldenString(t, "output", output) -} -``` - -#### `GoldenRead(t, name)` - -Reads a golden file for use in tests. - -```go -func TestParser(t *testing.T) { -expected := arct.GoldenRead(t, "expected-data") -result := Parse(input) -if !bytes.Equal(result, expected) { -t.Errorf("mismatch") -} -} -``` - -#### `GoldenPath(t, name)` - -Returns the path to a golden file. - -```go -func TestLoader(t *testing.T) { -path := arct.GoldenPath(t, "config") -cfg := LoadFromFile(path) -// ... test cfg -} -``` - -#### `UpdateGolden()` - -Returns true if `-update-golden` flag is set. Useful for conditional logic. - -### Directory Structure - -Golden files are stored in `testdata/golden/` relative to each test file. **The directory is created automatically** -when you first run tests with `-update-golden`: - -``` -pkg/cli/ -├── banner.go -├── banner_test.go -└── testdata/ # Created automatically on first use - └── golden/ - ├── banner-simple.golden - ├── banner-colored.golden - └── help-text.golden -``` - -**Important**: Don't manually create empty `testdata/golden/` directories. The `GoldenFile()` function (see `golden.go` -line 40) creates them automatically when needed. This follows the principle of creating directories only when you -actually need them. - -### Best Practices - -1. **Use descriptive names**: `banner-with-logo.golden` is better than `test1.golden` - -2. **Keep golden files small**: If testing large outputs, consider testing key sections instead - -3. **Version control**: Always commit golden files to git - -4. **Review changes**: When updating golden files, carefully review the diffs: - ```bash - git diff testdata/golden/ - ``` - -5. **Test isolation**: Each test should use a unique golden file name - -6. **Document intent**: Add comments explaining what the golden file represents - -### Example: Testing CLI Banner - -```go -func TestBanner(t *testing.T) { -tests := []struct { -name string -version string -options BannerOptions -}{ -{ -name: "simple-banner", -version: "1.0.0", -options: BannerOptions{Color: false}, -}, -{ -name: "colored-banner", -version: "1.0.0", -options: BannerOptions{Color: true}, -}, -} - -for _, tt := range tests { -t.Run(tt.name, func (t *testing.T) { -banner := GenerateBanner(tt.version, tt.options) -arct.GoldenString(t, tt.name, banner) -}) -} -} -``` - -To create/update golden files: - -```bash -go test -update-golden -run TestBanner ./pkg/cli -``` - -## Custom Assertions - -The package provides several assertion helpers that improve test readability: - -### `AssertNoError(t, err, msg)` - -```go -err := DoSomething() -arct.AssertNoError(t, err, "DoSomething should not error") -``` - -### `AssertEqual(t, expected, actual)` - -```go -arct.AssertEqual(t, "expected", result) -``` - -### `AssertContains(t, str, substr, msg)` - -```go -arct.AssertContains(t, output, "success", "output should contain success message") -``` - -### `AssertNotContains(t, str, substr, msg)` - -```go -arct.AssertNotContains(t, output, "error", "output should not contain errors") -``` - -### `AssertFileExists(t, path)` - -```go -arct.AssertFileExists(t, "/tmp/output.txt") -``` - -### `AssertDirExists(t, path)` - -```go -arct.AssertDirExists(t, "/tmp/config") -``` - -### `AssertJSONEqual(t, expected, actual)` - -```go -expectedJSON := `{"key": "value"}` -arct.AssertJSONEqual(t, expectedJSON, actualJSON) -``` - -## Test Fixtures - -### Context Builder - -Create test contexts with mock dependencies: - -```go -func TestCommand(t *testing.T) { -ctx := arct.NewTestContext() - -// Or customize: -ctx := arct.NewContextBuilder(). -WithLogger(customLogger). -WithStore(customStore). -Build() - -RunCommand(ctx) -} -``` - -### Mock Repositories - -```go -mockRepo := arct.NewMockResourceRepository() -mockRepo.SetResources([]Resource{...}) - -mockHistory := arct.NewMockHistoryRepository() -``` - -## Running Tests - -```bash -# Run all tests -go test ./... - -# Run tests with coverage -go test -cover ./... - -# Run tests with verbose output -go test -v ./... - -# Run specific test -go test -run TestBanner ./pkg/cli - -# Update all golden files -go test -update-golden ./... - -# Run tests in parallel -go test -parallel 4 ./... -``` - -## Contributing - -When adding new test utilities: - -1. Add appropriate documentation -2. Include examples in this README -3. Add tests for the utility itself -4. Follow Go testing best practices -5. Update relevant sections of this README - - diff --git a/internal/testing/assertions.go b/internal/testing/assertions.go deleted file mode 100644 index be04fd3..0000000 --- a/internal/testing/assertions.go +++ /dev/null @@ -1,193 +0,0 @@ -package testing - -import ( - "os" - "reflect" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" -) - -// AssertFileExists checks that a file exists at the given path. -func AssertFileExists(t *testing.T, path string) { - t.Helper() - _, err := os.Stat(path) - require.NoError(t, err, "file should exist: %s", path) -} - -// AssertFileNotExists checks that a file does not exist at the given path. -func AssertFileNotExists(t *testing.T, path string) { - t.Helper() - _, err := os.Stat(path) - require.True(t, os.IsNotExist(err), "file should not exist: %s", path) -} - -// AssertFileContent checks that a file contains the expected content. -func AssertFileContent(t *testing.T, path string, expected []byte) { - t.Helper() - actual, err := os.ReadFile(path) - require.NoError(t, err, "failed to read file: %s", path) - require.Equal(t, expected, actual, "file content mismatch: %s", path) -} - -// AssertYAMLEqual compares two values as YAML, ignoring formatting differences. -func AssertYAMLEqual(t *testing.T, expected, actual interface{}) { - t.Helper() - - // Marshal both to YAML - expectedYAML, err := yaml.Marshal(expected) - require.NoError(t, err, "failed to marshal expected value") - - actualYAML, err := yaml.Marshal(actual) - require.NoError(t, err, "failed to marshal actual value") - - // Unmarshal both back to interface{} for comparison - var expectedData, actualData interface{} - err = yaml.Unmarshal(expectedYAML, &expectedData) - require.NoError(t, err, "failed to unmarshal expected YAML") - - err = yaml.Unmarshal(actualYAML, &actualData) - require.NoError(t, err, "failed to unmarshal actual YAML") - - // Compare using go-cmp - if diff := cmp.Diff(expectedData, actualData); diff != "" { - t.Errorf("YAML mismatch (-expected +actual):\n%s", diff) - } -} - -// AssertDirExists checks that a directory exists at the given path. -func AssertDirExists(t *testing.T, path string) { - t.Helper() - info, err := os.Stat(path) - require.NoError(t, err, "directory should exist: %s", path) - require.True(t, info.IsDir(), "path should be a directory: %s", path) -} - -// AssertDirNotExists checks that a directory does not exist at the given path. -func AssertDirNotExists(t *testing.T, path string) { - t.Helper() - _, err := os.Stat(path) - require.True(t, os.IsNotExist(err), "directory should not exist: %s", path) -} - -// AssertNoError fails the test if err is not nil. -func AssertNoError(t testing.TB, err error, msg string) { - t.Helper() - if err != nil { - t.Fatalf("%s: unexpected error: %v", msg, err) - } -} - -// AssertError fails the test if err is nil. -func AssertError(t testing.TB, err error, msg string) { - t.Helper() - if err == nil { - t.Fatalf("%s: expected error but got nil", msg) - } -} - -// AssertEqual fails the test if expected != actual. -func AssertEqual(t testing.TB, expected, actual interface{}, msg string) { - t.Helper() - if diff := cmp.Diff(expected, actual); diff != "" { - t.Fatalf("%s: mismatch (-expected +actual):\n%s", msg, diff) - } -} - -// AssertNotEqual fails the test if expected == actual. -func AssertNotEqual(t testing.TB, expected, actual interface{}, msg string) { - t.Helper() - if diff := cmp.Diff(expected, actual); diff == "" { - t.Fatalf("%s: expected values to be different but got: %+v", msg, actual) - } -} - -// AssertTrue fails the test if condition is false. -func AssertTrue(t testing.TB, condition bool, msg string) { - t.Helper() - if !condition { - t.Fatalf("%s: expected true but got false", msg) - } -} - -// AssertFalse fails the test if condition is true. -func AssertFalse(t testing.TB, condition bool, msg string) { - t.Helper() - if condition { - t.Fatalf("%s: expected false but got true", msg) - } -} - -// AssertNil fails the test if value is not nil. -func AssertNil(t testing.TB, value interface{}, msg string) { - t.Helper() - if value == nil { - return - } - - // Check for typed nil - v := reflect.ValueOf(value) - switch v.Kind() { - case reflect.Ptr, reflect.Interface, reflect.Slice, reflect.Map, reflect.Chan, reflect.Func: - if v.IsNil() { - return - } - } - - t.Fatalf("%s: expected nil but got: %+v", msg, value) -} - -// AssertNotNil fails the test if value is nil. -func AssertNotNil(t testing.TB, value interface{}, msg string) { - t.Helper() - if value == nil { - t.Fatalf("%s: expected non-nil value", msg) - } - - // Check for typed nil - v := reflect.ValueOf(value) - switch v.Kind() { - case reflect.Ptr, reflect.Interface, reflect.Slice, reflect.Map, reflect.Chan, reflect.Func: - if v.IsNil() { - t.Fatalf("%s: expected non-nil value", msg) - } - } -} - -// AssertContains fails the test if the string doesn't contain the substring. -func AssertContains(t testing.TB, str, substr, msg string) { - t.Helper() - require.Contains(t.(*testing.T), str, substr, msg) -} - -// AssertNotContains fails the test if the string contains the substring. -func AssertNotContains(t testing.TB, str, substr, msg string) { - t.Helper() - require.NotContains(t.(*testing.T), str, substr, msg) -} - -// AssertLen fails the test if the length doesn't match. -func AssertLen(t testing.TB, obj interface{}, expected int, msg string) { - t.Helper() - require.Len(t.(*testing.T), obj, expected, msg) -} - -// AssertPanics fails the test if the function doesn't panic. -func AssertPanics(t testing.TB, fn func(), msg string) { - t.Helper() - require.Panics(t.(*testing.T), fn, msg) -} - -// AssertNotPanics fails the test if the function panics. -func AssertNotPanics(t testing.TB, fn func(), msg string) { - t.Helper() - require.NotPanics(t.(*testing.T), fn, msg) -} - -// AssertJSONEqual compares two values as JSON. -func AssertJSONEqual(t testing.TB, expected, actual interface{}, msg string) { - t.Helper() - require.JSONEq(t.(*testing.T), expected.(string), actual.(string), msg) -} diff --git a/internal/testing/assertions_test.go b/internal/testing/assertions_test.go deleted file mode 100644 index 237a5f0..0000000 --- a/internal/testing/assertions_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package testing - -import ( - "testing" -) - -// These tests just verify the assertions work correctly with valid inputs. -// Testing failure cases would require special test infrastructure. - -func TestAssertNoError(t *testing.T) { - t.Parallel() - // Should pass with nil error - AssertNoError(t, nil, "test") -} - -func TestAssertError(t *testing.T) { - t.Parallel() - // Should pass with non-nil error - err := &testError{"test error"} - AssertError(t, err, "test") -} - -func TestAssertEqual(t *testing.T) { - t.Parallel() - // Should pass with equal values - AssertEqual(t, 42, 42, "test") - AssertEqual(t, "hello", "hello", "test") - AssertEqual(t, []int{1, 2, 3}, []int{1, 2, 3}, "test") -} - -func TestAssertNotEqual(t *testing.T) { - t.Parallel() - // Should pass with different values - AssertNotEqual(t, 42, 43, "test") - AssertNotEqual(t, "hello", "world", "test") -} - -func TestAssertTrue(t *testing.T) { - t.Parallel() - // Should pass with true - AssertTrue(t, true, "test") -} - -func TestAssertFalse(t *testing.T) { - t.Parallel() - // Should pass with false - AssertFalse(t, false, "test") -} - -func TestAssertNil(t *testing.T) { - t.Parallel() - // Should pass with nil - var ptr *int - AssertNil(t, ptr, "test") -} - -func TestAssertNotNil(t *testing.T) { - t.Parallel() - // Should pass with non-nil - val := 42 - AssertNotNil(t, &val, "test") -} - -func TestAssertContains(t *testing.T) { - t.Parallel() - // Should pass when substring found - AssertContains(t, "hello world", "world", "test") -} - -func TestAssertNotContains(t *testing.T) { - t.Parallel() - // Should pass when substring not found - AssertNotContains(t, "hello world", "foo", "test") -} - -func TestAssertLen(t *testing.T) { - t.Parallel() - // Should pass with correct length - AssertLen(t, []int{1, 2, 3}, 3, "test") - AssertLen(t, "hello", 5, "test") - AssertLen(t, map[string]int{"a": 1, "b": 2}, 2, "test") -} - -func TestAssertPanics(t *testing.T) { - t.Parallel() - // Should pass when function panics - AssertPanics(t, func() { - panic("test panic") - }, "test") -} - -func TestAssertNotPanics(t *testing.T) { - t.Parallel() - // Should pass when function doesn't panic - AssertNotPanics(t, func() { - // Don't panic - }, "test") -} - -// testError is a simple error type for testing -type testError struct { - msg string -} - -func (e *testError) Error() string { - return e.msg -} diff --git a/internal/testing/fixtures.go b/internal/testing/fixtures.go deleted file mode 100644 index 226a6ae..0000000 --- a/internal/testing/fixtures.go +++ /dev/null @@ -1,245 +0,0 @@ -// Package testing provides test utilities, mocks, and fixtures for the A.R.C. CLI. -package testing - -import ( - "log/slog" - "os" - "time" - - "github.com/arc-framework/arc-cli/internal/app" - "github.com/arc-framework/arc-cli/internal/config" - "github.com/arc-framework/arc-cli/internal/preferences" - "github.com/arc-framework/arc-cli/pkg/log" - "github.com/arc-framework/arc-cli/pkg/store" - "github.com/arc-framework/arc-cli/pkg/ui" -) - -// NewTestContext creates a test context with mock dependencies. -func NewTestContext() *app.Context { - return NewContextBuilder().Build() -} - -// ContextBuilder provides a fluent API for building test contexts. -type ContextBuilder struct { - logger log.Logger - store *store.Store - ui *ui.Service - prefs *preferences.Preferences - config *config.Config - baseDir string - noColor bool - noAnimation bool -} - -// NewContextBuilder creates a new context builder. -func NewContextBuilder() *ContextBuilder { - // Create mock repositories - mockResources := NewMockResourceRepository() - mockHistory := NewMockHistoryRepository() - - return &ContextBuilder{ - logger: log.Default(), - store: store.NewStore(mockResources, mockHistory), - ui: &ui.Service{}, - prefs: preferences.Default(), - config: config.Default(), - baseDir: "/tmp/arc-test", - noColor: false, - noAnimation: true, - } -} - -// WithLogger sets a custom logger. -func (b *ContextBuilder) WithLogger(logger log.Logger) *ContextBuilder { - b.logger = logger - return b -} - -// WithStore sets a custom store. -func (b *ContextBuilder) WithStore(store *store.Store) *ContextBuilder { - b.store = store - return b -} - -// WithUI sets a custom UI service. -func (b *ContextBuilder) WithUI(ui *ui.Service) *ContextBuilder { - b.ui = ui - return b -} - -// WithPreferences sets custom preferences. -func (b *ContextBuilder) WithPreferences(prefs *preferences.Preferences) *ContextBuilder { - b.prefs = prefs - return b -} - -// WithConfig sets a custom config. -func (b *ContextBuilder) WithConfig(cfg *config.Config) *ContextBuilder { - b.config = cfg - return b -} - -// WithBaseDir sets the base directory. -func (b *ContextBuilder) WithBaseDir(dir string) *ContextBuilder { - b.baseDir = dir - return b -} - -// WithNoColor sets the NoColor flag. -func (b *ContextBuilder) WithNoColor(noColor bool) *ContextBuilder { - b.noColor = noColor - return b -} - -// WithNoAnimation sets the NoAnimation flag. -func (b *ContextBuilder) WithNoAnimation(noAnimation bool) *ContextBuilder { - b.noAnimation = noAnimation - return b -} - -// Build creates the context. -func (b *ContextBuilder) Build() *app.Context { - return &app.Context{ - Config: b.config, - Logger: b.logger, - Store: b.store, - Prefs: b.prefs, - UI: b.ui, - BaseDir: b.baseDir, - NoColor: b.noColor, - NoAnimation: b.noAnimation, - } -} - -// MinimalConfig returns a minimal valid configuration. -func MinimalConfig() *config.Config { - return &config.Config{ - Log: config.LogConfig{ - Level: "info", - }, - UI: config.UIConfig{ - NoColor: false, - NoAnimation: false, - Theme: "default", - }, - Store: config.StoreConfig{ - Path: "/tmp/arc-test-store", - }, - } -} - -// FullConfig returns a fully populated configuration with all options set. -func FullConfig() *config.Config { - return &config.Config{ - Log: config.LogConfig{ - Level: "debug", - }, - UI: config.UIConfig{ - NoColor: false, - NoAnimation: false, - Theme: "dracula", - }, - Store: config.StoreConfig{ - Path: "/tmp/arc-test-store", - }, - } -} - -// InvalidConfig returns an invalid configuration for testing validation. -func InvalidConfig() *config.Config { - return &config.Config{ - Log: config.LogConfig{ - Level: "invalid-level", - }, - UI: config.UIConfig{ - Theme: "", - }, - Store: config.StoreConfig{ - Path: "", // Invalid: empty path - }, - } -} - -// EmptyState returns an empty preferences state. -func EmptyState() *preferences.Preferences { - return &preferences.Preferences{ - Theme: "default", - } -} - -// SingleResourceState returns preferences with one resource. -func SingleResourceState() *preferences.Preferences { - return &preferences.Preferences{ - Theme: "dracula", - } -} - -// MultiResourceState returns preferences with multiple resources. -func MultiResourceState() *preferences.Preferences { - return &preferences.Preferences{ - Theme: "solarized", - } -} - -// TempTestDir creates a temporary directory for testing. -// Returns the path and a cleanup function. -func TempTestDir(prefix string) (string, func()) { - dir, err := os.MkdirTemp("", prefix) - if err != nil { - panic("failed to create temp dir: " + err.Error()) - } - return dir, func() { - _ = os.RemoveAll(dir) - } -} - -// MockResource creates a mock store resource for testing. -func MockResource(id, name, resourceType string) store.Resource { - return store.Resource{ - ID: id, - Name: name, - Type: resourceType, - Status: "active", - Created: time.Now(), - Updated: time.Now(), - Metadata: map[string]interface{}{ - "key": "value", - }, - } -} - -// MockOperation creates a mock operation for testing. -func MockOperation(id, command, status string) store.Operation { - return store.Operation{ - ID: id, - Timestamp: time.Now(), - Command: command, - Args: []string{}, - Status: status, - Duration: "100ms", - } -} - -// MockState creates a mock state with resources for testing. -func MockState(resources ...store.Resource) *store.State { - return &store.State{ - Version: 1, - Updated: time.Now(), - Resources: resources, - } -} - -// MockHistory creates a mock history with operations for testing. -func MockHistory(operations ...store.Operation) *store.History { - return &store.History{ - Version: 1, - Operations: operations, - } -} - -// NewTestLogger creates a test logger that writes to a mock handler. -func NewTestLogger() (*slog.Logger, *MockLogger) { - mockHandler := NewMockLogger() - logger := slog.New(mockHandler) - return logger, mockHandler -} diff --git a/internal/testing/fixtures_test.go b/internal/testing/fixtures_test.go deleted file mode 100644 index 15e87b4..0000000 --- a/internal/testing/fixtures_test.go +++ /dev/null @@ -1,287 +0,0 @@ -package testing - -import ( - "os" - "testing" -) - -func TestMockResource(t *testing.T) { - t.Parallel() - - resource := MockResource("id1", "name1", "type1") - - if resource.ID != "id1" { - t.Errorf("expected ID 'id1', got %s", resource.ID) - } - if resource.Name != "name1" { - t.Errorf("expected name 'name1', got %s", resource.Name) - } - if resource.Type != "type1" { - t.Errorf("expected type 'type1', got %s", resource.Type) - } - if resource.Status != "active" { - t.Errorf("expected status 'active', got %s", resource.Status) - } - if resource.Metadata == nil { - t.Error("expected metadata to be non-nil") - } -} - -func TestMockOperation(t *testing.T) { - t.Parallel() - - op := MockOperation("op1", "test command", "success") - - if op.ID != "op1" { - t.Errorf("expected ID 'op1', got %s", op.ID) - } - if op.Command != "test command" { - t.Errorf("expected command 'test command', got %s", op.Command) - } - if op.Status != "success" { - t.Errorf("expected status 'success', got %s", op.Status) - } -} - -func TestMockState(t *testing.T) { - t.Parallel() - - t.Run("empty state", func(t *testing.T) { - t.Parallel() - - state := MockState() - - if state.Version != 1 { - t.Errorf("expected version 1, got %d", state.Version) - } - if len(state.Resources) != 0 { - t.Errorf("expected 0 resources, got %d", len(state.Resources)) - } - }) - - t.Run("state with resources", func(t *testing.T) { - t.Parallel() - - r1 := MockResource("id1", "name1", "type1") - r2 := MockResource("id2", "name2", "type2") - state := MockState(r1, r2) - - if len(state.Resources) != 2 { - t.Errorf("expected 2 resources, got %d", len(state.Resources)) - } - }) -} - -func TestMockHistory(t *testing.T) { - t.Parallel() - - t.Run("empty history", func(t *testing.T) { - t.Parallel() - - history := MockHistory() - - if history.Version != 1 { - t.Errorf("expected version 1, got %d", history.Version) - } - if len(history.Operations) != 0 { - t.Errorf("expected 0 operations, got %d", len(history.Operations)) - } - }) - - t.Run("history with operations", func(t *testing.T) { - t.Parallel() - - op1 := MockOperation("op1", "cmd1", "success") - op2 := MockOperation("op2", "cmd2", "success") - history := MockHistory(op1, op2) - - if len(history.Operations) != 2 { - t.Errorf("expected 2 operations, got %d", len(history.Operations)) - } - }) -} - -func TestMinimalConfig(t *testing.T) { - t.Parallel() - - cfg := MinimalConfig() - - if cfg == nil { - t.Fatal("expected non-nil config") - } - if cfg.Log.Level != "info" { - t.Errorf("expected log level 'info', got %s", cfg.Log.Level) - } - if cfg.UI.Theme != "default" { - t.Errorf("expected theme 'default', got %s", cfg.UI.Theme) - } -} - -func TestFullConfig(t *testing.T) { - t.Parallel() - - cfg := FullConfig() - - if cfg == nil { - t.Fatal("expected non-nil config") - } - if cfg.Log.Level != "debug" { - t.Errorf("expected log level 'debug', got %s", cfg.Log.Level) - } - if cfg.UI.Theme != "dracula" { - t.Errorf("expected theme 'dracula', got %s", cfg.UI.Theme) - } -} - -func TestInvalidConfig(t *testing.T) { - t.Parallel() - - cfg := InvalidConfig() - - if cfg == nil { - t.Fatal("expected non-nil config") - } - if cfg.Log.Level != "invalid-level" { - t.Errorf("expected log level 'invalid-level', got %s", cfg.Log.Level) - } - if cfg.Store.Path != "" { - t.Errorf("expected empty path, got %s", cfg.Store.Path) - } -} - -func TestEmptyState(t *testing.T) { - t.Parallel() - - state := EmptyState() - - if state == nil { - t.Fatal("expected non-nil state") - } - if state.Theme != "default" { - t.Errorf("expected theme 'default', got %s", state.Theme) - } -} - -func TestSingleResourceState(t *testing.T) { - t.Parallel() - - state := SingleResourceState() - - if state == nil { - t.Fatal("expected non-nil state") - } - if state.Theme != "dracula" { - t.Errorf("expected theme 'dracula', got %s", state.Theme) - } -} - -func TestMultiResourceState(t *testing.T) { - t.Parallel() - - state := MultiResourceState() - - if state == nil { - t.Fatal("expected non-nil state") - } - if state.Theme != "solarized" { - t.Errorf("expected theme 'solarized', got %s", state.Theme) - } -} - -func TestTempTestDir(t *testing.T) { - t.Parallel() - - dir, cleanup := TempTestDir("test-") - defer cleanup() - - if dir == "" { - t.Fatal("expected non-empty directory path") - } - - // Directory should exist - info, err := os.Stat(dir) - if err != nil { - t.Fatalf("expected directory to exist: %v", err) - } - if !info.IsDir() { - t.Error("expected path to be a directory") - } -} - -func TestContextBuilder(t *testing.T) { - t.Parallel() - - t.Run("creates default context", func(t *testing.T) { - t.Parallel() - - ctx := NewTestContext() - - if ctx == nil { - t.Fatal("expected non-nil context") - } - if ctx.Logger == nil { - t.Error("expected non-nil logger") - } - if ctx.Store == nil { - t.Error("expected non-nil store") - } - if ctx.UI == nil { - t.Error("expected non-nil UI") - } - if ctx.Prefs == nil { - t.Error("expected non-nil preferences") - } - if ctx.Config == nil { - t.Error("expected non-nil config") - } - }) - - t.Run("builder with custom base dir", func(t *testing.T) { - t.Parallel() - - ctx := NewContextBuilder(). - WithBaseDir("/custom/path"). - Build() - - if ctx.BaseDir != "/custom/path" { - t.Errorf("expected BaseDir '/custom/path', got %s", ctx.BaseDir) - } - }) - - t.Run("builder with no color", func(t *testing.T) { - t.Parallel() - - ctx := NewContextBuilder(). - WithNoColor(true). - Build() - - if !ctx.NoColor { - t.Error("expected NoColor to be true") - } - }) - - t.Run("builder with no animation", func(t *testing.T) { - t.Parallel() - - ctx := NewContextBuilder(). - WithNoAnimation(true). - Build() - - if !ctx.NoAnimation { - t.Error("expected NoAnimation to be true") - } - }) -} - -func TestNewTestLogger(t *testing.T) { - t.Parallel() - - logger, mockHandler := NewTestLogger() - - if logger == nil { - t.Fatal("expected non-nil logger") - } - if mockHandler == nil { - t.Fatal("expected non-nil mock handler") - } -} diff --git a/internal/testing/golden.go b/internal/testing/golden.go deleted file mode 100644 index 5db553f..0000000 --- a/internal/testing/golden.go +++ /dev/null @@ -1,88 +0,0 @@ -// Package testing provides test utilities, mocks, and fixtures for the A.R.C. CLI. -package testing - -import ( - "flag" - "os" - "path/filepath" - "testing" -) - -var updateGolden = flag.Bool("update-golden", false, "update golden files") - -// UpdateGolden returns true if golden files should be regenerated. -// Use with go test -update-golden flag. -func UpdateGolden() bool { - return *updateGolden -} - -// GoldenFile compares data against a golden file, or updates the golden file if -update-golden is set. -// Golden files are stored in testdata/golden/ relative to the test file. -// -// Usage: -// -// func TestSomething(t *testing.T) { -// result := generateOutput() -// testing.GoldenFile(t, "something", result) -// } -// -// To update golden files: -// -// go test -update-golden -func GoldenFile(t testing.TB, name string, data []byte) { - t.Helper() - - goldenPath := filepath.Join("testdata", "golden", name+".golden") - - if UpdateGolden() { - // Create directory if it doesn't exist - dir := filepath.Dir(goldenPath) - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("failed to create golden directory: %v", err) - } - - // Write golden file - if err := os.WriteFile(goldenPath, data, 0o644); err != nil { - t.Fatalf("failed to update golden file %s: %v", goldenPath, err) - } - t.Logf("Updated golden file: %s", goldenPath) - return - } - - // Read and compare golden file - golden, err := os.ReadFile(goldenPath) - if err != nil { - t.Fatalf("failed to read golden file %s: %v (run with -update-golden to create)", goldenPath, err) - } - - if string(data) != string(golden) { - t.Errorf("output does not match golden file %s\nGot:\n%s\n\nWant:\n%s\n\n(run with -update-golden to update)", - goldenPath, string(data), string(golden)) - } -} - -// GoldenString is a convenience wrapper around GoldenFile for string data. -func GoldenString(t testing.TB, name, data string) { - t.Helper() - GoldenFile(t, name, []byte(data)) -} - -// GoldenPath returns the path to a golden file for the given test and name. -// This is useful for loading golden files directly without comparison. -func GoldenPath(t testing.TB, name string) string { - t.Helper() - return filepath.Join("testdata", "golden", name+".golden") -} - -// GoldenRead reads a golden file and returns its contents. -// This is useful when you need to load expected data from a golden file. -func GoldenRead(t testing.TB, name string) []byte { - t.Helper() - - goldenPath := GoldenPath(t, name) - data, err := os.ReadFile(goldenPath) - if err != nil { - t.Fatalf("failed to read golden file %s: %v", goldenPath, err) - } - return data -} diff --git a/internal/testing/golden_test.go b/internal/testing/golden_test.go deleted file mode 100644 index 5405566..0000000 --- a/internal/testing/golden_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package testing - -import ( - "os" - "path/filepath" - "testing" -) - -func TestGoldenFile(t *testing.T) { - // Note: Not using t.Parallel() because subtests change working directory - // Test that we can update golden files - t.Run("update mode", func(t *testing.T) { - tmpDir := t.TempDir() - origWd, _ := os.Getwd() - defer os.Chdir(origWd) - os.Chdir(tmpDir) - data := []byte("test data") - goldenPath := filepath.Join(tmpDir, "testdata", "golden", "test.golden") - os.MkdirAll(filepath.Dir(goldenPath), 0o755) - os.WriteFile(goldenPath, data, 0o644) - content, err := os.ReadFile(goldenPath) - if err != nil { - t.Fatalf("golden file not created: %v", err) - } - if string(content) != string(data) { - t.Errorf("golden file content = %q, want %q", string(content), string(data)) - } - }) - // Test that we can compare against golden files - t.Run("compare mode - match", func(t *testing.T) { - tmpDir := t.TempDir() - origWd, _ := os.Getwd() - defer os.Chdir(origWd) - os.Chdir(tmpDir) - goldenPath := filepath.Join("testdata", "golden", "test.golden") - os.MkdirAll(filepath.Dir(goldenPath), 0o755) - os.WriteFile(goldenPath, []byte("hello"), 0o644) - mockT := &mockTestingTB{TB: t} - defer func() { recover() }() - GoldenFile(mockT, "test", []byte("hello")) - if mockT.failed { - t.Error("GoldenFile() failed when it should have passed") - } - }) - // Test that we can detect differences - t.Run("compare mode - mismatch", func(t *testing.T) { - tmpDir := t.TempDir() - origWd, _ := os.Getwd() - defer os.Chdir(origWd) - os.Chdir(tmpDir) - goldenPath := filepath.Join("testdata", "golden", "test.golden") - os.MkdirAll(filepath.Dir(goldenPath), 0o755) - os.WriteFile(goldenPath, []byte("hello"), 0o644) - mockT := &mockTestingTB{TB: t} - defer func() { recover() }() - GoldenFile(mockT, "test", []byte("goodbye")) - if !mockT.failed { - t.Error("GoldenFile() should have failed on mismatch") - } - }) -} - -func TestGoldenString(t *testing.T) { - // Note: Not using t.Parallel() because test changes working directory - tmpDir := t.TempDir() - origWd, _ := os.Getwd() - defer os.Chdir(origWd) - os.Chdir(tmpDir) - goldenPath := filepath.Join("testdata", "golden", "string-test.golden") - os.MkdirAll(filepath.Dir(goldenPath), 0o755) - os.WriteFile(goldenPath, []byte("test string"), 0o644) - GoldenString(t, "string-test", "test string") -} - -func TestGoldenPath(t *testing.T) { - t.Parallel() - got := GoldenPath(t, "test") - want := filepath.Join("testdata", "golden", "test.golden") - if got != want { - t.Errorf("GoldenPath() = %q, want %q", got, want) - } -} - -func TestGoldenRead(t *testing.T) { - // Note: Not using t.Parallel() because test changes working directory - tmpDir := t.TempDir() - origWd, _ := os.Getwd() - defer os.Chdir(origWd) - os.Chdir(tmpDir) - goldenPath := filepath.Join("testdata", "golden", "read-test.golden") - os.MkdirAll(filepath.Dir(goldenPath), 0o755) - want := []byte("test content") - os.WriteFile(goldenPath, want, 0o644) - got := GoldenRead(t, "read-test") - if string(got) != string(want) { - t.Errorf("GoldenRead() = %q, want %q", string(got), string(want)) - } -} - -func TestUpdateGolden(t *testing.T) { - t.Parallel() - got := UpdateGolden() - if got != false && got != true { - t.Errorf("UpdateGolden() returned non-bool value") - } -} - -// mockTestingTB is a mock implementation of testing.TB for testing GoldenFile behavior -type mockTestingTB struct { - testing.TB - failed bool - fataled bool -} - -func (m *mockTestingTB) Errorf(format string, args ...interface{}) { - m.failed = true -} - -func (m *mockTestingTB) Fatalf(format string, args ...interface{}) { - m.failed = true - m.fataled = true - panic("mock fatalf") -} - -func (m *mockTestingTB) Logf(format string, args ...interface{}) { -} - -func (m *mockTestingTB) Helper() { -} diff --git a/internal/testing/helpers.go b/internal/testing/helpers.go deleted file mode 100644 index 2cf543f..0000000 --- a/internal/testing/helpers.go +++ /dev/null @@ -1,118 +0,0 @@ -package testing - -import ( - "bytes" - "io" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" - - "github.com/arc-framework/arc-cli/pkg/store" -) - -// TempDir creates a temporary directory for testing and registers cleanup. -// The directory is automatically removed when the test completes. -func TempDir(t *testing.T) string { - t.Helper() - return t.TempDir() -} - -// CaptureOutput captures stdout and stderr during function execution. -// Returns the captured stdout and stderr as strings. -func CaptureOutput(t *testing.T, fn func()) (stdout, stderr string) { - t.Helper() - - // Save original stdout/stderr - oldStdout := os.Stdout - oldStderr := os.Stderr - - // Create pipes - rOut, wOut, err := os.Pipe() - require.NoError(t, err, "failed to create stdout pipe") - rErr, wErr, err := os.Pipe() - require.NoError(t, err, "failed to create stderr pipe") - - // Replace stdout/stderr - os.Stdout = wOut - os.Stderr = wErr - - // Capture output in goroutines - outChan := make(chan string) - errChan := make(chan string) - - go func() { - var buf bytes.Buffer - _, _ = io.Copy(&buf, rOut) - outChan <- buf.String() - }() - - go func() { - var buf bytes.Buffer - _, _ = io.Copy(&buf, rErr) - errChan <- buf.String() - }() - - // Execute function - fn() - - // Restore stdout/stderr - _ = wOut.Close() - _ = wErr.Close() - os.Stdout = oldStdout - os.Stderr = oldStderr - - // Collect output - stdout = <-outChan - stderr = <-errChan - - _ = rOut.Close() - _ = rErr.Close() - - return stdout, stderr -} - -// CreateFile creates a file with the given content at the specified path. -// All parent directories are created if they don't exist. -// Returns the absolute path to the created file. -func CreateFile(t *testing.T, path string, content []byte) string { - t.Helper() - - // Create parent directories - dir := filepath.Dir(path) - err := os.MkdirAll(dir, 0o755) - require.NoError(t, err, "failed to create directories") - - // Write file - err = os.WriteFile(path, content, 0o644) - require.NoError(t, err, "failed to write file") - - absPath, err := filepath.Abs(path) - require.NoError(t, err, "failed to get absolute path") - - return absPath -} - -// CreateStateFile creates a state file with the given state in the specified directory. -// Returns the path to the created state file. -func CreateStateFile(t *testing.T, dir string, s *store.State) string { - t.Helper() - - // Marshal state to YAML - data, err := yaml.Marshal(s) - require.NoError(t, err, "failed to marshal state") - - // Create state file - statePath := filepath.Join(dir, "state.yaml") - return CreateFile(t, statePath, data) -} - -// CleanupDir removes a directory and all its contents. -// This is useful for explicit cleanup in tests that don't use t.TempDir(). -func CleanupDir(t *testing.T, dir string) { - t.Helper() - err := os.RemoveAll(dir) - require.NoError(t, err, "failed to cleanup directory") -} diff --git a/internal/testing/mocks.go b/internal/testing/mocks.go deleted file mode 100644 index d3623c9..0000000 --- a/internal/testing/mocks.go +++ /dev/null @@ -1,576 +0,0 @@ -// Package testing provides test utilities, mocks, and fixtures for the A.R.C. CLI. -package testing - -import ( - "bytes" - "context" - "fmt" - "io" - "log/slog" - "strings" - "sync" - - "github.com/arc-framework/arc-cli/pkg/catalog" - "github.com/arc-framework/arc-cli/pkg/store" -) - -// MockLogger implements slog.Handler for testing purposes. -type MockLogger struct { - mu sync.Mutex - records []MockLogRecord - enabled bool -} - -// MockLogRecord represents a captured log entry. -type MockLogRecord struct { - Level slog.Level - Message string - Attrs map[string]any -} - -// NewMockLogger creates a new mock logger. -func NewMockLogger() *MockLogger { - return &MockLogger{ - records: make([]MockLogRecord, 0), - enabled: true, - } -} - -// Enabled implements slog.Handler. -func (m *MockLogger) Enabled(_ context.Context, level slog.Level) bool { - return m.enabled -} - -// Handle implements slog.Handler. -func (m *MockLogger) Handle(_ context.Context, r slog.Record) error { - m.mu.Lock() - defer m.mu.Unlock() - - attrs := make(map[string]any) - r.Attrs(func(a slog.Attr) bool { - attrs[a.Key] = a.Value.Any() - return true - }) - - m.records = append(m.records, MockLogRecord{ - Level: r.Level, - Message: r.Message, - Attrs: attrs, - }) - - return nil -} - -// WithAttrs implements slog.Handler. -func (m *MockLogger) WithAttrs(attrs []slog.Attr) slog.Handler { - return m -} - -// WithGroup implements slog.Handler. -func (m *MockLogger) WithGroup(name string) slog.Handler { - return m -} - -// Records returns all captured log records. -func (m *MockLogger) Records() []MockLogRecord { - m.mu.Lock() - defer m.mu.Unlock() - return append([]MockLogRecord{}, m.records...) -} - -// Reset clears all captured log records. -func (m *MockLogger) Reset() { - m.mu.Lock() - defer m.mu.Unlock() - m.records = make([]MockLogRecord, 0) -} - -// HasMessage checks if any log record contains the given message. -func (m *MockLogger) HasMessage(msg string) bool { - m.mu.Lock() - defer m.mu.Unlock() - for _, r := range m.records { - if r.Message == msg { - return true - } - } - return false -} - -// MockResourceRepository implements store.ResourceRepository for testing. -type MockResourceRepository struct { - mu sync.RWMutex - state *store.State - err error -} - -// NewMockResourceRepository creates a new mock resource repository. -func NewMockResourceRepository() *MockResourceRepository { - return &MockResourceRepository{ - state: &store.State{ - Version: 1, - Resources: []store.Resource{}, - }, - } -} - -// SetError sets an error to be returned by repository operations. -func (m *MockResourceRepository) SetError(err error) { - m.mu.Lock() - defer m.mu.Unlock() - m.err = err -} - -// ReadState implements store.ResourceRepository. -func (m *MockResourceRepository) ReadState() (*store.State, error) { - m.mu.RLock() - defer m.mu.RUnlock() - if m.err != nil { - return nil, m.err - } - return m.state, nil -} - -// WriteState implements store.ResourceRepository. -func (m *MockResourceRepository) WriteState(state *store.State) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.err != nil { - return m.err - } - m.state = state - return nil -} - -// BackupState implements store.ResourceRepository. -func (m *MockResourceRepository) BackupState() error { - m.mu.Lock() - defer m.mu.Unlock() - return m.err -} - -// ClearState implements store.ResourceRepository. -func (m *MockResourceRepository) ClearState() error { - m.mu.Lock() - defer m.mu.Unlock() - if m.err != nil { - return m.err - } - m.state = &store.State{ - Version: 1, - Resources: []store.Resource{}, - } - return nil -} - -// MockHistoryRepository implements store.HistoryRepository for testing. -type MockHistoryRepository struct { - mu sync.RWMutex - history *store.History - err error -} - -// NewMockHistoryRepository creates a new mock history repository. -func NewMockHistoryRepository() *MockHistoryRepository { - return &MockHistoryRepository{ - history: &store.History{ - Version: 1, - Operations: []store.Operation{}, - }, - } -} - -// SetError sets an error to be returned by repository operations. -func (m *MockHistoryRepository) SetError(err error) { - m.mu.Lock() - defer m.mu.Unlock() - m.err = err -} - -// ReadHistory implements store.HistoryRepository. -func (m *MockHistoryRepository) ReadHistory() (*store.History, error) { - m.mu.RLock() - defer m.mu.RUnlock() - if m.err != nil { - return nil, m.err - } - return m.history, nil -} - -// WriteHistory implements store.HistoryRepository. -func (m *MockHistoryRepository) WriteHistory(history *store.History) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.err != nil { - return m.err - } - m.history = history - return nil -} - -// AddOperation implements store.HistoryRepository. -func (m *MockHistoryRepository) AddOperation(operation *store.Operation) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.err != nil { - return m.err - } - m.history.Operations = append(m.history.Operations, *operation) - return nil -} - -// ClearHistory implements store.HistoryRepository. -func (m *MockHistoryRepository) ClearHistory() error { - m.mu.Lock() - defer m.mu.Unlock() - if m.err != nil { - return m.err - } - m.history = &store.History{ - Version: 1, - Operations: []store.Operation{}, - } - return nil -} - -// MockUI is a simple mock UI for testing (not implementing full ui.Service interface). -type MockUI struct { - mu sync.Mutex - outputs []MockUIOutput - writer io.Writer -} - -// MockUIOutput represents a captured UI operation. -type MockUIOutput struct { - Method string - Message string - Args []any -} - -// NewMockUI creates a new mock UI. -func NewMockUI() *MockUI { - return &MockUI{ - outputs: make([]MockUIOutput, 0), - writer: &bytes.Buffer{}, - } -} - -// Status captures a status message. -func (m *MockUI) Status(msg string, args ...any) { - m.mu.Lock() - defer m.mu.Unlock() - m.outputs = append(m.outputs, MockUIOutput{ - Method: "Status", - Message: msg, - Args: args, - }) -} - -// Success captures a success message. -func (m *MockUI) Success(msg string, args ...any) { - m.mu.Lock() - defer m.mu.Unlock() - m.outputs = append(m.outputs, MockUIOutput{ - Method: "Success", - Message: msg, - Args: args, - }) -} - -// Error captures an error message. -func (m *MockUI) Error(msg string, args ...any) { - m.mu.Lock() - defer m.mu.Unlock() - m.outputs = append(m.outputs, MockUIOutput{ - Method: "Error", - Message: msg, - Args: args, - }) -} - -// Warning captures a warning message. -func (m *MockUI) Warning(msg string, args ...any) { - m.mu.Lock() - defer m.mu.Unlock() - m.outputs = append(m.outputs, MockUIOutput{ - Method: "Warning", - Message: msg, - Args: args, - }) -} - -// Info captures an info message. -func (m *MockUI) Info(msg string, args ...any) { - m.mu.Lock() - defer m.mu.Unlock() - m.outputs = append(m.outputs, MockUIOutput{ - Method: "Info", - Message: msg, - Args: args, - }) -} - -// Outputs returns all captured UI outputs. -func (m *MockUI) Outputs() []MockUIOutput { - m.mu.Lock() - defer m.mu.Unlock() - return append([]MockUIOutput{}, m.outputs...) -} - -// Reset clears all captured outputs. -func (m *MockUI) Reset() { - m.mu.Lock() - defer m.mu.Unlock() - m.outputs = make([]MockUIOutput, 0) -} - -// HasOutput checks if any output contains the given message. -func (m *MockUI) HasOutput(msg string) bool { - m.mu.Lock() - defer m.mu.Unlock() - for _, o := range m.outputs { - if o.Message == msg { - return true - } - } - return false -} - -// Writer returns the underlying writer. -func (m *MockUI) Writer() io.Writer { - return m.writer -} - -// MockCatalog implements catalog.Catalog for testing purposes. -// It provides configurable services and behaviors for testing catalog operations. -type MockCatalog struct { - mu sync.RWMutex - services map[string]*catalog.Service - err error -} - -// NewMockCatalog creates a new mock catalog with default test services. -func NewMockCatalog() *MockCatalog { - return &MockCatalog{ - services: DefaultTestServices(), - } -} - -// NewEmptyMockCatalog creates a mock catalog with no services. -func NewEmptyMockCatalog() *MockCatalog { - return &MockCatalog{ - services: make(map[string]*catalog.Service), - } -} - -// DefaultTestServices returns a set of default test services. -func DefaultTestServices() map[string]*catalog.Service { - return map[string]*catalog.Service{ - "oracle": { - Codename: "oracle", - Technology: "PostgreSQL", - Description: "Test PostgreSQL database", - Image: "postgres:16-alpine", - Version: "16.0", - Role: catalog.RoleData, - Aliases: []string{"postgres", "postgresql"}, - Ports: []catalog.PortMapping{{Container: 5432, Host: 5432, Protocol: "tcp"}}, - }, - "sonic": { - Codename: "sonic", - Technology: "Redis", - Description: "Test Redis cache", - Image: "redis:7-alpine", - Version: "7.0", - Role: catalog.RoleData, - Aliases: []string{"redis"}, - Ports: []catalog.PortMapping{{Container: 6379, Host: 6379, Protocol: "tcp"}}, - }, - "heimdall": { - Codename: "heimdall", - Technology: "Traefik", - Description: "Test Traefik gateway", - Image: "traefik:v3.0", - Version: "3.0", - Role: catalog.RoleInfrastructure, - Aliases: []string{"traefik", "gateway"}, - Ports: []catalog.PortMapping{{Container: 80, Host: 80, Protocol: "tcp"}}, - }, - "jarvis": { - Codename: "jarvis", - Technology: "Kratos", - Description: "Test Kratos identity service", - Image: "oryd/kratos:v1.1.0", - Version: "1.1.0", - Role: catalog.RoleInfrastructure, - Aliases: []string{"kratos"}, - Ports: []catalog.PortMapping{{Container: 4433, Host: 4433, Protocol: "tcp"}}, - Dependencies: []string{"oracle"}, - }, - } -} - -// SetError sets an error to be returned by catalog operations. -func (m *MockCatalog) SetError(err error) { - m.mu.Lock() - defer m.mu.Unlock() - m.err = err -} - -// AddService adds a service to the mock catalog. -func (m *MockCatalog) AddService(svc *catalog.Service) { - m.mu.Lock() - defer m.mu.Unlock() - m.services[strings.ToLower(svc.Codename)] = svc -} - -// GetService implements catalog.Catalog. -func (m *MockCatalog) GetService(codename string) (*catalog.Service, error) { - m.mu.RLock() - defer m.mu.RUnlock() - if m.err != nil { - return nil, m.err - } - - lowerName := strings.ToLower(strings.TrimSpace(codename)) - - // Direct lookup - if svc, ok := m.services[lowerName]; ok { - return svc, nil - } - - // Alias lookup - for _, svc := range m.services { - for _, alias := range svc.Aliases { - if strings.ToLower(alias) == lowerName { - return svc, nil - } - } - } - - return nil, catalog.NewServiceNotFoundError(codename, m.SuggestSimilar(codename, 3)) -} - -// ListServices implements catalog.Catalog. -func (m *MockCatalog) ListServices(filter catalog.ServiceFilter) ([]*catalog.Service, error) { - m.mu.RLock() - defer m.mu.RUnlock() - if m.err != nil { - return nil, m.err - } - - targetRole := filter.ToRole() - var result []*catalog.Service - for _, svc := range m.services { - if targetRole == "" || svc.Role == targetRole { - result = append(result, svc) - } - } - return result, nil -} - -// AllServices implements catalog.Catalog. -func (m *MockCatalog) AllServices() []*catalog.Service { - services, _ := m.ListServices(catalog.FilterAll) - return services -} - -// HasService implements catalog.Catalog. -func (m *MockCatalog) HasService(codename string) bool { - m.mu.RLock() - defer m.mu.RUnlock() - - lowerName := strings.ToLower(strings.TrimSpace(codename)) - - if _, ok := m.services[lowerName]; ok { - return true - } - - for _, svc := range m.services { - for _, alias := range svc.Aliases { - if strings.ToLower(alias) == lowerName { - return true - } - } - } - return false -} - -// ServiceCount implements catalog.Catalog. -func (m *MockCatalog) ServiceCount() int { - m.mu.RLock() - defer m.mu.RUnlock() - return len(m.services) -} - -// SuggestSimilar implements catalog.Catalog. -func (m *MockCatalog) SuggestSimilar(input string, maxSuggestions int) []string { - m.mu.RLock() - defer m.mu.RUnlock() - - var suggestions []string - input = strings.ToLower(input) - - for _, svc := range m.services { - if strings.HasPrefix(strings.ToLower(svc.Codename), input) { - suggestions = append(suggestions, svc.Codename) - } - } - - if len(suggestions) > maxSuggestions { - return suggestions[:maxSuggestions] - } - return suggestions -} - -// Ensure MockCatalog implements catalog.Catalog. -var _ catalog.Catalog = (*MockCatalog)(nil) - -// MockCatalogBuilder provides a fluent API for building mock catalogs. -type MockCatalogBuilder struct { - catalog *MockCatalog -} - -// NewMockCatalogBuilder creates a new mock catalog builder. -func NewMockCatalogBuilder() *MockCatalogBuilder { - return &MockCatalogBuilder{ - catalog: NewEmptyMockCatalog(), - } -} - -// WithDefaults adds the default test services. -func (b *MockCatalogBuilder) WithDefaults() *MockCatalogBuilder { - for _, svc := range DefaultTestServices() { - b.catalog.AddService(svc) - } - return b -} - -// WithService adds a custom service. -func (b *MockCatalogBuilder) WithService(svc *catalog.Service) *MockCatalogBuilder { - b.catalog.AddService(svc) - return b -} - -// WithError sets an error to be returned. -func (b *MockCatalogBuilder) WithError(err error) *MockCatalogBuilder { - b.catalog.SetError(err) - return b -} - -// Build returns the configured mock catalog. -func (b *MockCatalogBuilder) Build() *MockCatalog { - return b.catalog -} - -// TestService creates a service for testing with minimal required fields. -func TestService(codename, technology string, role catalog.ServiceRole) *catalog.Service { - return &catalog.Service{ - Codename: codename, - Technology: technology, - Description: fmt.Sprintf("Test %s service", technology), - Image: fmt.Sprintf("%s:latest", strings.ToLower(technology)), - Version: "1.0.0", - Role: role, - } -} diff --git a/internal/testing/mocks_test.go b/internal/testing/mocks_test.go deleted file mode 100644 index cd24fb2..0000000 --- a/internal/testing/mocks_test.go +++ /dev/null @@ -1,322 +0,0 @@ -package testing - -import ( - "testing" -) - -func TestMockLogger(t *testing.T) { - t.Parallel() - - t.Run("captures log records", func(t *testing.T) { - t.Parallel() - - mock := NewMockLogger() - records := mock.Records() - - if len(records) != 0 { - t.Errorf("expected 0 records, got %d", len(records)) - } - }) - - t.Run("HasMessage returns false for empty logger", func(t *testing.T) { - t.Parallel() - - mock := NewMockLogger() - - if mock.HasMessage("test") { - t.Error("expected HasMessage to return false for empty logger") - } - }) - - t.Run("Reset clears records", func(t *testing.T) { - t.Parallel() - - mock := NewMockLogger() - mock.Reset() - - records := mock.Records() - if len(records) != 0 { - t.Errorf("expected 0 records after reset, got %d", len(records)) - } - }) -} - -func TestMockUI(t *testing.T) { - t.Parallel() - - t.Run("Outputs returns empty initially", func(t *testing.T) { - t.Parallel() - - mock := NewMockUI() - outputs := mock.Outputs() - - if len(outputs) != 0 { - t.Errorf("expected 0 outputs, got %d", len(outputs)) - } - }) - - t.Run("Status captures output", func(t *testing.T) { - t.Parallel() - - mock := NewMockUI() - mock.Status("test message") - - outputs := mock.Outputs() - if len(outputs) != 1 { - t.Fatalf("expected 1 output, got %d", len(outputs)) - } - if outputs[0].Method != "Status" { - t.Errorf("expected method 'Status', got %s", outputs[0].Method) - } - if outputs[0].Message != "test message" { - t.Errorf("expected message 'test message', got %s", outputs[0].Message) - } - }) - - t.Run("Success captures output", func(t *testing.T) { - t.Parallel() - - mock := NewMockUI() - mock.Success("success") - - if !mock.HasOutput("success") { - t.Error("expected HasOutput to return true") - } - }) - - t.Run("Error captures output", func(t *testing.T) { - t.Parallel() - - mock := NewMockUI() - mock.Error("error") - - if !mock.HasOutput("error") { - t.Error("expected HasOutput to return true") - } - }) - - t.Run("Warning captures output", func(t *testing.T) { - t.Parallel() - - mock := NewMockUI() - mock.Warning("warning") - - if !mock.HasOutput("warning") { - t.Error("expected HasOutput to return true") - } - }) - - t.Run("Info captures output", func(t *testing.T) { - t.Parallel() - - mock := NewMockUI() - mock.Info("info") - - if !mock.HasOutput("info") { - t.Error("expected HasOutput to return true") - } - }) - - t.Run("Reset clears outputs", func(t *testing.T) { - t.Parallel() - - mock := NewMockUI() - mock.Status("test") - mock.Reset() - - outputs := mock.Outputs() - if len(outputs) != 0 { - t.Errorf("expected 0 outputs after reset, got %d", len(outputs)) - } - }) - - t.Run("Writer returns non-nil", func(t *testing.T) { - t.Parallel() - - mock := NewMockUI() - writer := mock.Writer() - - if writer == nil { - t.Error("expected non-nil writer") - } - }) -} - -func TestMockCatalog(t *testing.T) { - t.Parallel() - - t.Run("NewMockCatalog has default services", func(t *testing.T) { - t.Parallel() - - mock := NewMockCatalog() - - if mock.ServiceCount() == 0 { - t.Error("expected default services") - } - - // Should have oracle, sonic, heimdall, jarvis - if !mock.HasService("oracle") { - t.Error("expected oracle service") - } - if !mock.HasService("sonic") { - t.Error("expected sonic service") - } - }) - - t.Run("NewEmptyMockCatalog has no services", func(t *testing.T) { - t.Parallel() - - mock := NewEmptyMockCatalog() - - if mock.ServiceCount() != 0 { - t.Errorf("expected 0 services, got %d", mock.ServiceCount()) - } - }) - - t.Run("GetService by codename", func(t *testing.T) { - t.Parallel() - - mock := NewMockCatalog() - - svc, err := mock.GetService("oracle") - if err != nil { - t.Fatalf("GetService error: %v", err) - } - if svc.Codename != "oracle" { - t.Errorf("expected codename 'oracle', got %s", svc.Codename) - } - }) - - t.Run("GetService by alias", func(t *testing.T) { - t.Parallel() - - mock := NewMockCatalog() - - svc, err := mock.GetService("postgres") - if err != nil { - t.Fatalf("GetService error: %v", err) - } - if svc.Codename != "oracle" { - t.Errorf("expected codename 'oracle', got %s", svc.Codename) - } - }) - - t.Run("GetService not found", func(t *testing.T) { - t.Parallel() - - mock := NewMockCatalog() - - _, err := mock.GetService("nonexistent") - if err == nil { - t.Error("expected error for nonexistent service") - } - }) - - t.Run("ListServices with filter", func(t *testing.T) { - t.Parallel() - - mock := NewMockCatalog() - - // Only data services - services, err := mock.ListServices(2) // FilterData - if err != nil { - t.Fatalf("ListServices error: %v", err) - } - - // Should have oracle and sonic (data services) - for _, svc := range services { - if svc.Role != "Data" && svc.Role != "data" { - t.Errorf("expected Data role, got %s", svc.Role) - } - } - }) - - t.Run("AddService adds new service", func(t *testing.T) { - t.Parallel() - - mock := NewEmptyMockCatalog() - mock.AddService(TestService("test", "TestTech", "data")) - - if mock.ServiceCount() != 1 { - t.Errorf("expected 1 service, got %d", mock.ServiceCount()) - } - - if !mock.HasService("test") { - t.Error("expected test service") - } - }) - - t.Run("SetError returns error on operations", func(t *testing.T) { - t.Parallel() - - mock := NewMockCatalog() - mock.SetError(ErrMockError) - - _, err := mock.GetService("oracle") - if err == nil { - t.Error("expected error") - } - - _, err = mock.ListServices(0) - if err == nil { - t.Error("expected error") - } - }) -} - -func TestMockCatalogBuilder(t *testing.T) { - t.Parallel() - - t.Run("builds empty catalog", func(t *testing.T) { - t.Parallel() - - cat := NewMockCatalogBuilder().Build() - - if cat.ServiceCount() != 0 { - t.Errorf("expected 0 services, got %d", cat.ServiceCount()) - } - }) - - t.Run("WithDefaults adds default services", func(t *testing.T) { - t.Parallel() - - cat := NewMockCatalogBuilder().WithDefaults().Build() - - if cat.ServiceCount() == 0 { - t.Error("expected services") - } - }) - - t.Run("WithService adds custom service", func(t *testing.T) { - t.Parallel() - - cat := NewMockCatalogBuilder(). - WithService(TestService("custom", "CustomTech", "ai")). - Build() - - if !cat.HasService("custom") { - t.Error("expected custom service") - } - }) - - t.Run("WithError sets error", func(t *testing.T) { - t.Parallel() - - cat := NewMockCatalogBuilder(). - WithDefaults(). - WithError(ErrMockError). - Build() - - _, err := cat.GetService("oracle") - if err == nil { - t.Error("expected error") - } - }) -} - -// ErrMockError is a sentinel error for testing. -var ErrMockError = errMock{} - -type errMock struct{} - -func (errMock) Error() string { return "mock error" } diff --git a/internal/version/version.go b/internal/version/version.go deleted file mode 100644 index 3e4c996..0000000 --- a/internal/version/version.go +++ /dev/null @@ -1,26 +0,0 @@ -// Package version provides version information for the A.R.C. CLI. -package version - -import "fmt" - -var ( - // Version is the current version (set by -ldflags at build) - Version = "dev-local" - - // BuildDate is the build timestamp (set by -ldflags at build) - BuildDate = "unknown" - - // GitCommit is the git commit hash (set by -ldflags at build) - GitCommit = "unknown" -) - -// String returns a formatted version string -func String() string { - return Version -} - -// Full returns a detailed version string -func Full() string { - return fmt.Sprintf("A.R.C. CLI %s (built %s, commit %s)", - Version, BuildDate, GitCommit) -} diff --git a/internal/version/version_test.go b/internal/version/version_test.go deleted file mode 100644 index 7d6d20a..0000000 --- a/internal/version/version_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package version - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestString(t *testing.T) { - tests := []struct { - name string - version string - expectedSuffix string - }{ - { - name: "dev version", - version: "0.0.1-dev", - expectedSuffix: "-dev", - }, - { - name: "release version", - version: "1.0.0", - expectedSuffix: "", - }, - { - name: "prerelease version", - version: "2.0.0-beta.1", - expectedSuffix: "-beta.1", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Set version for test - oldVersion := Version - defer func() { Version = oldVersion }() - - Version = tt.version - result := String() - - assert.Equal(t, tt.version, result) - if tt.expectedSuffix != "" { - assert.Contains(t, result, tt.expectedSuffix) - } - }) - } -} - -func TestFull(t *testing.T) { - tests := []struct { - name string - version string - buildDate string - gitCommit string - contains []string - notContains []string - }{ - { - name: "complete version info", - version: "1.0.0", - buildDate: "2025-12-20", - gitCommit: "abc123", - contains: []string{"A.R.C. CLI", "1.0.0", "2025-12-20", "abc123"}, - }, - { - name: "dev version", - version: "0.0.1-dev", - buildDate: "2025-12-20", - gitCommit: "dev", - contains: []string{"A.R.C. CLI", "0.0.1-dev", "built", "commit"}, - }, - { - name: "empty commit", - version: "1.0.0", - buildDate: "2025-12-20", - gitCommit: "", - contains: []string{"A.R.C. CLI", "1.0.0", "2025-12-20"}, - notContains: []string{}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Save old values - oldVersion := Version - oldBuildDate := BuildDate - oldGitCommit := GitCommit - defer func() { - Version = oldVersion - BuildDate = oldBuildDate - GitCommit = oldGitCommit - }() - - // Set test values - Version = tt.version - BuildDate = tt.buildDate - GitCommit = tt.gitCommit - - result := Full() - - // Check format - assert.True(t, strings.HasPrefix(result, "A.R.C. CLI"), "should start with A.R.C. CLI") - - // Check contains - for _, expected := range tt.contains { - assert.Contains(t, result, expected, "should contain %s", expected) - } - - // Check not contains - for _, notExpected := range tt.notContains { - assert.NotContains(t, result, notExpected, "should not contain %s", notExpected) - } - }) - } -} - -func TestFull_Format(t *testing.T) { - // Test specific format - oldVersion := Version - oldBuildDate := BuildDate - oldGitCommit := GitCommit - defer func() { - Version = oldVersion - BuildDate = oldBuildDate - GitCommit = oldGitCommit - }() - - Version = "1.2.3" - BuildDate = "2025-01-01" - GitCommit = "abc123" - - result := Full() - - expected := "A.R.C. CLI 1.2.3 (built 2025-01-01, commit abc123)" - assert.Equal(t, expected, result) -} - -func TestVersionVariables(t *testing.T) { - // Test that version variables are set (even if to defaults) - assert.NotEmpty(t, Version, "Version should not be empty") - assert.NotEmpty(t, BuildDate, "BuildDate should not be empty") - // GitCommit can be empty in dev builds -} diff --git a/pkg/catalog/embedded_catalog_test.go b/pkg/catalog/embedded_catalog_test.go index 135aa82..b3c4bd7 100644 --- a/pkg/catalog/embedded_catalog_test.go +++ b/pkg/catalog/embedded_catalog_test.go @@ -525,7 +525,7 @@ func TestServicesYAML_SchemaValidation(t *testing.T) { break } } else { - if !((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') { + if (c < 'A' || c > 'Z') && (c < '0' || c > '9') && c != '_' { t.Errorf("env var name has invalid char: %q", env.Name) break } diff --git a/pkg/catalog/errors.go b/pkg/catalog/errors.go index 976a54a..5ae6b1e 100644 --- a/pkg/catalog/errors.go +++ b/pkg/catalog/errors.go @@ -28,7 +28,7 @@ type ServiceNotFoundError struct { // Error implements the error interface. func (e *ServiceNotFoundError) Error() string { var sb strings.Builder - sb.WriteString(fmt.Sprintf("unknown service %q", e.Requested)) + fmt.Fprintf(&sb, "unknown service %q", e.Requested) if len(e.Suggestions) > 0 { sb.WriteString(". Did you mean: ") diff --git a/pkg/catalog/integration_test.go b/pkg/catalog/integration_test.go index 99eeba9..53c67bd 100644 --- a/pkg/catalog/integration_test.go +++ b/pkg/catalog/integration_test.go @@ -1,6 +1,7 @@ package catalog_test import ( + "errors" "strings" "testing" @@ -122,8 +123,7 @@ func TestIntegration_ServiceDiscoveryFlow(t *testing.T) { t.Error("Expected error for misspelled service") } else { var notFound *catalog.ServiceNotFoundError - if errors, ok := err.(*catalog.ServiceNotFoundError); ok { - notFound = errors + if errors.As(err, ¬Found) { t.Logf("ServiceNotFoundError suggestions: %v", notFound.Suggestions) } } diff --git a/pkg/catalog/renderer.go b/pkg/catalog/renderer.go index 5193f93..a7c716a 100644 --- a/pkg/catalog/renderer.go +++ b/pkg/catalog/renderer.go @@ -320,7 +320,7 @@ func (r *TemplateRenderer) RenderDockerCompose(services []*Service, vars Templat buf.WriteString("# A.R.C. Platform - Docker Compose Configuration\n") buf.WriteString("# Generated by arc services - DO NOT EDIT MANUALLY\n") buf.WriteString("#\n") - buf.WriteString(fmt.Sprintf("# Services: %d\n", len(services))) + fmt.Fprintf(&buf, "# Services: %d\n", len(services)) workspaceName := vars.GetString("workspace_name") if workspaceName != "" { buf.WriteString("# Generated for: " + workspaceName + "\n\n") @@ -361,7 +361,7 @@ func (r *TemplateRenderer) RenderDockerCompose(services []*Service, vars Templat networkName = "arc-network" } buf.WriteString("networks:\n") - buf.WriteString(fmt.Sprintf(" %s:\n", networkName)) + fmt.Fprintf(&buf, " %s:\n", networkName) buf.WriteString(" driver: bridge\n") return buf.String(), nil diff --git a/pkg/catalog/resolver.go b/pkg/catalog/resolver.go index 805ea08..bdabf26 100644 --- a/pkg/catalog/resolver.go +++ b/pkg/catalog/resolver.go @@ -101,11 +101,9 @@ func (r *DependencyResolver) dfs(service *Service, visited, inStack map[string]b // buildCycleChain builds the chain of services forming a cycle. func (r *DependencyResolver) buildCycleChain(service *Service, _ map[string]bool) []string { - chain := []string{service.Codename} // In a real implementation, we'd track the actual path // For now, just return the service that caused the cycle - chain = append(chain, service.Codename) - return chain + return []string{service.Codename, service.Codename} } // ValidateDAG checks the entire catalog for circular dependencies. diff --git a/pkg/catalog/resolver_property_test.go b/pkg/catalog/resolver_property_test.go index effb79d..f4e075f 100644 --- a/pkg/catalog/resolver_property_test.go +++ b/pkg/catalog/resolver_property_test.go @@ -1,6 +1,7 @@ package catalog import ( + "errors" "strings" "testing" ) @@ -444,7 +445,8 @@ func TestProperty_CircularDependencyDetected(t *testing.T) { t.Error("ValidateDAG should have detected circular dependency") } - _, isCircular := err.(*CircularDependencyError) + circularDependencyError := &CircularDependencyError{} + isCircular := errors.As(err, &circularDependencyError) if !isCircular { t.Errorf("Expected CircularDependencyError, got %T", err) } diff --git a/pkg/catalog/validator.go b/pkg/catalog/validator.go index ff68334..69c3dfa 100644 --- a/pkg/catalog/validator.go +++ b/pkg/catalog/validator.go @@ -192,8 +192,8 @@ func FormatConflicts(conflicts []PortConflict) string { var sb strings.Builder sb.WriteString("Port conflicts detected:\n") for _, c := range conflicts { - sb.WriteString(fmt.Sprintf(" Port %d/%s: %s\n", - c.Port, c.Protocol, strings.Join(c.Services, ", "))) + fmt.Fprintf(&sb, " Port %d/%s: %s\n", + c.Port, c.Protocol, strings.Join(c.Services, ", ")) } return sb.String() } diff --git a/pkg/catalog/writer_test.go b/pkg/catalog/writer_test.go index 527f583..dde7c7c 100644 --- a/pkg/catalog/writer_test.go +++ b/pkg/catalog/writer_test.go @@ -1,6 +1,7 @@ package catalog import ( + "bytes" "os" "path/filepath" "testing" @@ -24,7 +25,7 @@ func TestFileWriter_WriteFile(t *testing.T) { if err != nil { t.Fatalf("Failed to read written file: %v", err) } - if string(data) != string(content) { + if !bytes.Equal(data, content) { t.Errorf("File content = %q, want %q", string(data), string(content)) } @@ -53,7 +54,7 @@ func TestFileWriter_WriteFile_CreatesDirectories(t *testing.T) { if err != nil { t.Fatalf("Failed to read written file: %v", err) } - if string(data) != string(content) { + if !bytes.Equal(data, content) { t.Errorf("File content = %q, want %q", string(data), string(content)) } } @@ -83,7 +84,7 @@ func TestFileWriter_WriteFile_BacksUpExistingFile(t *testing.T) { if err != nil { t.Fatalf("Backup file not found: %v", err) } - if string(backupData) != string(originalContent) { + if !bytes.Equal(backupData, originalContent) { t.Errorf("Backup content = %q, want %q", string(backupData), string(originalContent)) } @@ -92,7 +93,7 @@ func TestFileWriter_WriteFile_BacksUpExistingFile(t *testing.T) { if err != nil { t.Fatalf("Failed to read new file: %v", err) } - if string(newData) != string(newContent) { + if !bytes.Equal(newData, newContent) { t.Errorf("New content = %q, want %q", string(newData), string(newContent)) } } @@ -137,7 +138,7 @@ func TestFileWriter_Rollback(t *testing.T) { if err != nil { t.Fatalf("Failed to read restored file: %v", err) } - if string(restoredData) != string(originalContent) { + if !bytes.Equal(restoredData, originalContent) { t.Errorf("Restored content = %q, want %q", string(restoredData), string(originalContent)) } diff --git a/pkg/cli/.gitignore b/pkg/cli/.gitignore new file mode 100644 index 0000000..f189542 --- /dev/null +++ b/pkg/cli/.gitignore @@ -0,0 +1,3 @@ +# A.R.C. workspace +.arc/ +.env diff --git a/pkg/cli/arc.yaml b/pkg/cli/arc.yaml new file mode 100644 index 0000000..fb9c903 --- /dev/null +++ b/pkg/cli/arc.yaml @@ -0,0 +1,38 @@ +# A.R.C. Workspace Manifest +# Documentation: https://github.com/arc-framework/arc-cli + +# Semantic version of arc.yaml format +version: "1.0.0" + +# Platform tier (selected during initialization) +# Options: super-saiyan, super-saiyan-blue, ultra-instinct +tier: "basic" + +# High-level feature flags +# Enable/disable platform capabilities without managing individual services +features: + # Voice interface (Scarlett/Daredevil) + voice: false + + # Security & Identity (J.A.R.V.I.S./Kratos) + security: false + + # Observability stack (Prometheus, Grafana, Loki) + observability: false + + # Chaos engineering (T-800/Chaos Mesh) + chaos: false + +# Service-specific overrides (optional) +# Uncomment to customize individual service configurations +# services: +# arc-heimdall-gateway: +# enabled: true +# config: +# port: 8080 + +# Environment variables (optional) +# Injected into all services +# environment: +# LOG_LEVEL: "info" +# ENVIRONMENT: "development" diff --git a/pkg/cli/banner.go b/pkg/cli/banner.go deleted file mode 100644 index 5898570..0000000 --- a/pkg/cli/banner.go +++ /dev/null @@ -1,659 +0,0 @@ -// Package cli provides the command-line interface for the A.R.C. CLI application. -package cli - -import ( - "context" - "fmt" - "math" - "os" - "os/signal" - "strconv" - "strings" - "syscall" - "time" - - "github.com/charmbracelet/lipgloss" - "golang.org/x/term" - - "github.com/arc-framework/arc-cli/internal/branding" - "github.com/arc-framework/arc-cli/internal/terminal" - "github.com/arc-framework/arc-cli/internal/version" - "github.com/arc-framework/arc-cli/pkg/ui/animations" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/styles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -const asciiArt = `=================================================== -========== ========= ========= ========= -========= ======== ==== ======= === ======== -======== == ======= ==== ====== ============== -======= ==== ====== === ====== ============== -======= ==== ====== ======== ============== -======= ====== ==== ====== ============== -======= ==== ====== ==== ====== ============== -======= ==== == == ==== == === === ======== -======= ==== == == ==== == ==== ========= -===================================================` - -const themeCharacterRainbow = "character-rainbow" - -const ( - // MinBannerWidth is the minimum terminal width required for full banner display - MinBannerWidth = 80 - // DefaultBannerWidth is used when terminal width cannot be detected - DefaultBannerWidth = 80 -) - -// RenderBanner returns the styled A.R.C. banner with improved layout. -// If profileCtx is provided, uses profile-specific logo and theme colors. -// If profileCtx is nil, falls back to Enterprise profile. -// FR-004, FR-005, FR-015, FR-016 -func RenderBanner(profileCtx *profiles.ProfileContext) string { - // Detect terminal width for responsive rendering - termWidth := getTerminalWidth() - - // Return minimal banner for narrow terminals - if termWidth < MinBannerWidth { - return renderMinimalBanner() - } - - // Load ProfileContext if not provided (fallback to Enterprise) - if profileCtx == nil { - profileCtx = profiles.GetDefaultProfileContext() - } - - if styles.NoColor { - return renderPlainBannerWithProfile(profileCtx) - } - - // Render logo using profile context - var title string - logo := profileCtx.BannerLogo() - themeColors := profileCtx.ThemeColors() - - // Use profile renderer for logo rendering - renderer := profiles.NewRenderer() - profile := profileCtx.Profile() - - if profile != nil && renderer.SupportsTerminal() { - rendered, err := renderer.RenderLogo(profile) - if err == nil { - title = rendered - } else { - // Fallback to gradient rendering - title = renderLogoWithTheme(logo, themeColors) - } - } else { - // Fallback to gradient rendering - title = renderLogoWithTheme(logo, themeColors) - } - - // Create branding info with better layout - // Banner width set to 50 to accommodate full tagline (47 chars) - bannerWidth := 50 - - // Build version line (centered) - versionText := branding.Name + " v" + version.String() - versionStyle := styles.PrimaryStyle.Bold(true).Width(bannerWidth).Align(lipgloss.Center) - versionLine := versionStyle.Render(versionText) - - // Build tagline (centered) - taglineStyle := styles.InfoStyle.Italic(true).Width(bannerWidth).Align(lipgloss.Center) - taglineLine := taglineStyle.Render(branding.Tagline) - - // Add separator line - use simple dashes for compatibility - separatorText := strings.Repeat("-", bannerWidth) - separator := styles.SecondaryStyle.Render(separatorText) - - // Build the banner content as separate lines - banner := title + "\n" + - "\n" + - separator + "\n" + - versionLine + "\n" + - taglineLine + "\n" - - return banner -} - -// getTerminalWidth detects the current terminal width. -// Returns DefaultBannerWidth if detection fails. -// FR-015, FR-016 -func getTerminalWidth() int { - // Try to get terminal size from stdout - fd := int(os.Stdout.Fd()) - width, _, err := term.GetSize(fd) - if err != nil || width <= 0 { - // Fallback to default if detection fails - return DefaultBannerWidth - } - return width -} - -// renderMinimalBanner returns a minimal banner for narrow terminals (<80 cols). -// FR-016 -func renderMinimalBanner() string { - versionText := branding.Name + " v" + version.String() - return versionText + "\n" + branding.Tagline + "\n" -} - -// renderLogoWithTheme renders a logo using theme colors (fallback when renderer fails). -func renderLogoWithTheme(logo string, themeColors *themes.ColorSet) string { - if logo == "" { - // Ultimate fallback to hardcoded ASCII art - logo = asciiArt - } - - lines := strings.Split(strings.TrimSpace(logo), "\n") - - // If no theme colors, render without coloring - if themeColors == nil { - return logo - } - - var result strings.Builder - bannerColors := themeColors.BannerColors() - - for i, line := range lines { - if len(bannerColors) == 0 { - result.WriteString(line) - } else { - colorIndex := i - if colorIndex >= len(bannerColors) { - // Safety check: use last color if we run out - colorIndex = len(bannerColors) - 1 - } - color := bannerColors[colorIndex] - style := lipgloss.NewStyle().Foreground(color).Bold(true) - result.WriteString(style.Render(line)) - } - - if i < len(lines)-1 { - result.WriteString("\n") - } - } - - return result.String() -} - -// renderPlainBannerWithProfile returns plain text banner for --no-color mode with profile logo. -func renderPlainBannerWithProfile(profileCtx *profiles.ProfileContext) string { - bannerWidth := 50 - separator := strings.Repeat("-", bannerWidth) - - // Get logo from profile context - logo := profileCtx.BannerLogo() - if logo == "" { - logo = asciiArt - } - - // Center the version line - versionText := branding.Name + " v" + version.String() - centeredVersion := centerText(versionText, bannerWidth) - - // Center the tagline - centeredTagline := centerText(branding.Tagline, bannerWidth) - - lines := []string{ - logo, - "", - separator, - centeredVersion, - centeredTagline, - "", - } - return strings.Join(lines, "\n") -} - -// centerText centers text within a given width -func centerText(text string, width int) string { - textLen := len(text) - if textLen >= width { - return text - } - padding := width - textLen - leftPad := padding / 2 - rightPad := padding - leftPad - return strings.Repeat(" ", leftPad) + text + strings.Repeat(" ", rightPad) -} - -// renderGradientBanner renders banner with gradient theme -func renderGradientBanner(themeName string) string { - lines := strings.Split(strings.TrimSpace(asciiArt), "\n") - - // Load theme using YAML loader - themeLoader := themes.NewLoader() - theme, err := themeLoader.Load(themeName) - if err != nil { - // If theme loading fails, use default - theme, _ = themes.GetDefault() - } - - var result strings.Builder - bannerColors := theme.Colors.BannerGradient - - for i, line := range lines { - if len(bannerColors) == 0 { - result.WriteString(line) - } else { - colorIndex := i - if colorIndex >= len(bannerColors) { - // Safety check: use last color if we run out - colorIndex = len(bannerColors) - 1 - } - color := lipgloss.Color(bannerColors[colorIndex]) - style := lipgloss.NewStyle().Foreground(color).Bold(true) - result.WriteString(style.Render(line)) - } - - if i < len(lines)-1 { - result.WriteString("\n") - } - } - - return result.String() -} - -// renderCharacterRainbow colors each character individually while preserving spacing. -// Uses a line-by-line approach to maintain proper character spacing. -func renderCharacterRainbow() string { - lines := strings.Split(strings.TrimSpace(asciiArt), "\n") - rainbowColors := themes.Rainbow() - - coloredLines := make([]string, 0, len(lines)) - colorIndex := 0 - - for _, line := range lines { - var styledLine strings.Builder - - // Process each character in the line - for _, char := range line { - if char != ' ' { - color := rainbowColors[colorIndex%len(rainbowColors)] - style := lipgloss.NewStyle().Foreground(color).Bold(true) - styledLine.WriteString(style.Render(string(char))) - colorIndex++ - } else { - // Preserve spaces exactly - this maintains alignment - styledLine.WriteRune(' ') - } - } - - coloredLines = append(coloredLines, styledLine.String()) - } - - // Join all lines with newlines - return strings.Join(coloredLines, "\n") -} - -// printCompleteBanner prints the ASCII art along with branding information (version and tagline). -func printCompleteBanner(asciiArt string) { - // Banner width set to 50 to accommodate full tagline (47 chars) - bannerWidth := 50 - - // Build version line (centered) - versionText := branding.Name + " v" + version.String() - versionStyle := styles.PrimaryStyle.Bold(true).Width(bannerWidth).Align(lipgloss.Center) - versionLine := versionStyle.Render(versionText) - - // Build tagline (centered) - taglineStyle := styles.InfoStyle.Italic(true).Width(bannerWidth).Align(lipgloss.Center) - taglineLine := taglineStyle.Render(branding.Tagline) - - // Add separator line - use simple dashes for compatibility - separatorText := strings.Repeat("-", bannerWidth) - separator := styles.SecondaryStyle.Render(separatorText) - - // Print the complete banner - fmt.Print(asciiArt) - fmt.Print("\n\n") - fmt.Print(separator) - fmt.Print("\n") - fmt.Print(versionLine) - fmt.Print("\n") - fmt.Print(taglineLine) - fmt.Print("\n") -} - -// RenderBannerAnimated renders the banner with spring-based color animation. -// This is used for special cases like theme switching or first run. -// If profileCtx is provided, uses profile-specific logo and theme. -// If profileCtx is nil, falls back to Enterprise profile. -func RenderBannerAnimated(profileCtx *profiles.ProfileContext) string { - // Check if animation is disabled - if !animations.ShouldAnimate() { - return RenderBanner(profileCtx) - } - - // Load ProfileContext if not provided (fallback to Enterprise) - if profileCtx == nil { - profileCtx = profiles.GetDefaultProfileContext() - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Set up signal handler for Ctrl+C - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - - go func() { - <-sigChan - cancel() - }() - - // Get theme from profile context - theme := profileCtx.Theme() - if theme == nil { - // Load theme using YAML loader as fallback - themeLoader := themes.NewLoader() - theme, _ = themeLoader.Load("cyan-purple") // Default theme - } - - // Get logo from profile context - logo := profileCtx.BannerLogo() - if logo == "" { - logo = asciiArt - } - - // Animate gradient transition - this will print directly and handle everything - config := branding.DefaultAnimationConfig() - renderGradientAnimatedYAMLWithLogo(ctx, theme, logo, config) - - // Animation function prints everything, so return empty string - return "" -} - -// renderGradientAnimatedYAMLWithLogo renders banner with animated color transition using YAML themes and custom logo. -// This function prints the complete banner (including branding) directly to stdout. -func renderGradientAnimatedYAMLWithLogo(ctx context.Context, theme *themes.Theme, logo string, config branding.AnimationConfig) { - if logo == "" { - logo = asciiArt - } - lines := strings.Split(strings.TrimSpace(logo), "\n") - - // Start with a neutral gray from theme (or default muted color) - startColor := theme.Colors.MutedColor() - if startColor == (lipgloss.Color("")) { - // Fallback if theme doesn't provide muted color - startColor = lipgloss.Color("#7D7D7D") - } - - // Animate to theme colors - animator := components.NewAnimator() - err := animator.Start(components.AnimationConfig{ - From: 0.0, - To: 1.0, - Duration: config.Duration, - Damping: config.Damping, - Stiffness: config.Stiffness, - }) - if err != nil { - // Fallback to static render and print it - printCompleteBanner(renderGradientStaticYAML(theme, lines)) - return - } - - startTime := time.Now() - frameTime := time.Second / time.Duration(config.FPS) - - // Animation loop - var lastOutput string - for !animator.IsFinished() { - // Check context cancellation - select { - case <-ctx.Done(): - // Clear previous output if any was printed - if lastOutput != "" { - fmt.Print("\033[" + strconv.Itoa(len(lines)-1) + "F") - fmt.Print("\033[J") - } - // Print the complete banner and return - printCompleteBanner(renderGradientStaticYAML(theme, lines)) - return - default: - } - - // Check if we exceeded max duration - if time.Since(startTime) > config.Duration { - break - } - - progress := animator.Update() - output := renderGradientFrameYAML(lines, theme, startColor, progress) - - // Clear previous output and render new frame - if lastOutput != "" { - // Move cursor up and clear - fmt.Print("\033[" + strconv.Itoa(len(lines)-1) + "F") - fmt.Print("\033[J") - } - fmt.Print(output) - lastOutput = output - - time.Sleep(frameTime) - } - - // Clear the animation frames - if lastOutput != "" { - // Move cursor up to clear the animation - fmt.Print("\033[" + strconv.Itoa(len(lines)-1) + "F") - fmt.Print("\033[J") - } - - // Print the complete banner with branding info - printCompleteBanner(renderGradientStaticYAML(theme, lines)) -} - -// renderGradientFrameYAML renders a single animation frame with interpolated colors using YAML theme. -func renderGradientFrameYAML(lines []string, theme *themes.Theme, startColor lipgloss.Color, progress float64) string { - var result strings.Builder - bannerColors := theme.Colors.BannerGradient - - for i, line := range lines { - if len(bannerColors) == 0 { - result.WriteString(line) - } else { - colorIndex := i - if colorIndex >= len(bannerColors) { - colorIndex = len(bannerColors) - 1 - } - - targetColor := lipgloss.Color(bannerColors[colorIndex]) - interpolated := interpolateColor(startColor, targetColor, progress) - style := lipgloss.NewStyle().Foreground(interpolated).Bold(true) - result.WriteString(style.Render(line)) - } - if i < len(lines)-1 { - result.WriteString("\n") - } - } - - return result.String() -} - -// renderGradientStaticYAML renders banner without animation using YAML theme (fallback). -func renderGradientStaticYAML(theme *themes.Theme, lines []string) string { - var result strings.Builder - bannerColors := theme.Colors.BannerGradient - - for i, line := range lines { - if len(bannerColors) == 0 { - result.WriteString(line) - } else { - colorIndex := i - if colorIndex >= len(bannerColors) { - colorIndex = len(bannerColors) - 1 - } - - color := lipgloss.Color(bannerColors[colorIndex]) - style := lipgloss.NewStyle().Foreground(color).Bold(true) - result.WriteString(style.Render(line)) - } - if i < len(lines)-1 { - result.WriteString("\n") - } - } - return result.String() -} - -// interpolateColor interpolates between two colors using smooth LERP transitions. -// progress should be 0.0-1.0 where 0.0 = startColor and 1.0 = endColor. -func interpolateColor(start, end lipgloss.Color, progress float64) lipgloss.Color { - // Apply ease-out for smooth deceleration (like spring settling) - smoothProgress := animations.EaseOut(progress) - - // Parse hex colors to RGB - startR, startG, startB := parseHexColor(string(start)) - endR, endG, endB := parseHexColor(string(end)) - - // Interpolate RGB values using LERP - r := uint8(animations.Lerp(float64(startR), float64(endR), smoothProgress)) - g := uint8(animations.Lerp(float64(startG), float64(endG), smoothProgress)) - b := uint8(animations.Lerp(float64(startB), float64(endB), smoothProgress)) - - // Convert back to hex color - return lipgloss.Color(fmt.Sprintf("#%02X%02X%02X", r, g, b)) -} - -// parseHexColor parses a hex color string to RGB components. -func parseHexColor(hex string) (r, g, b uint8) { - // Remove # prefix if present - hex = strings.TrimPrefix(hex, "#") - - // Parse hex values - if len(hex) == 6 { - _, _ = fmt.Sscanf(hex, "%02x%02x%02x", &r, &g, &b) - } - - return r, g, b -} - -// RenderCharacterRainbow renders the banner with per-character animated rainbow colors -func RenderCharacterRainbow(duration time.Duration) string { - lines := strings.Split(strings.TrimSpace(asciiArt), "\n") - rainbowColors := themes.Rainbow() - - // Skip animation if disabled or not TTY - if !animations.ShouldAnimate() { - return renderCharacterRainbowStatic(lines, rainbowColors) - } - - caps := terminal.NewDetector().Detect() - if !caps.IsTTY { - return renderCharacterRainbowStatic(lines, rainbowColors) - } - - startTime := time.Now() - frameTime := time.Second / 60 - - var result strings.Builder - for time.Since(startTime) < duration { - result.Reset() - elapsed := time.Since(startTime) - progress := float64(elapsed) / float64(duration) - - // Apply ease-in-out for smooth animation - smoothProgress := animations.EaseInOut(progress) - - colorIndex := 0 - for lineIdx, line := range lines { - for _, char := range line { - if char != ' ' { - // Calculate animated hue using LERP - hue := math.Mod(float64(colorIndex)*10+smoothProgress*360, 360) / 360.0 - - color := lipgloss.Color(hslToHex(hue, 0.8, 0.6)) - style := lipgloss.NewStyle().Foreground(color).Bold(true) - result.WriteString(style.Render(string(char))) - colorIndex++ - } else { - result.WriteRune(char) - } - } - if lineIdx < len(lines)-1 { - result.WriteRune('\n') - } - } - - fmt.Print("\r" + result.String()) - time.Sleep(frameTime) - } - - fmt.Println() - return result.String() -} - -// hslToHex converts HSL color values to hex string -func hslToHex(h, s, l float64) string { - h -= math.Floor(h) - - var r, g, b float64 - - if s == 0 { - r, g, b = l, l, l - } else { - hueToRGB := func(p, q, t float64) float64 { - // Normalize t to [0, 1] range first - for t < 0 { - t++ - } - for t > 1 { - t-- - } - - switch { - case t < 1.0/6.0: - return p + (q-p)*6*t - case t < 1.0/2.0: - return q - case t < 2.0/3.0: - return p + (q-p)*(2.0/3.0-t)*6 - default: - return p - } - } - - var q float64 - if l < 0.5 { - q = l * (1 + s) - } else { - q = l + s - l*s - } - p := 2*l - q - - r = hueToRGB(p, q, h+1.0/3.0) - g = hueToRGB(p, q, h) - b = hueToRGB(p, q, h-1.0/3.0) - } - - return fmt.Sprintf("#%02x%02x%02x", - int(r*255), - int(g*255), - int(b*255)) -} - -// renderCharacterRainbowStatic renders static rainbow (no animation) -func renderCharacterRainbowStatic(lines []string, rainbowColors []lipgloss.Color) string { - var result strings.Builder - colorIndex := 0 - - for lineIdx, line := range lines { - for _, char := range line { - if char != ' ' { - color := rainbowColors[colorIndex%len(rainbowColors)] - style := lipgloss.NewStyle().Foreground(color).Bold(true) - result.WriteString(style.Render(string(char))) - colorIndex++ - } else { - result.WriteRune(char) - } - } - if lineIdx < len(lines)-1 { - result.WriteRune('\n') - } - } - - return result.String() -} diff --git a/pkg/cli/banner_test.go b/pkg/cli/banner_test.go deleted file mode 100644 index d648bc0..0000000 --- a/pkg/cli/banner_test.go +++ /dev/null @@ -1,381 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/arc-framework/arc-cli/pkg/ui/animations" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/styles" -) - -// TestRenderBanner_NilContext tests banner rendering with nil ProfileContext (Enterprise fallback) -func TestRenderBanner_NilContext(t *testing.T) { - // Disable animations for consistent testing - origNoAnimation := animations.NoAnimation - defer func() { animations.NoAnimation = origNoAnimation }() - animations.NoAnimation = true - - result := RenderBanner(nil) - - if result == "" { - t.Error("RenderBanner(nil) returned empty string") - } - - // Should contain version info - if !strings.Contains(result, "A.R.C.") { - t.Error("Banner should contain A.R.C. branding") - } -} - -// TestRenderBanner_WithProfileContext tests banner rendering with valid ProfileContext -func TestRenderBanner_WithProfileContext(t *testing.T) { - // Disable animations for consistent testing - origNoAnimation := animations.NoAnimation - defer func() { animations.NoAnimation = origNoAnimation }() - animations.NoAnimation = true - - // Load a test profile (jedi) - repo, err := profiles.NewRepository() - if err != nil { - t.Skipf("Skipping test: profile repository not available: %v", err) - return - } - - profile, err := repo.GetByID("jedi") - if err != nil { - t.Skipf("Skipping test: jedi profile not available: %v", err) - return - } - - profileCtx, err := profiles.NewProfileContext(profile, nil) - if err != nil { - t.Fatalf("Failed to create ProfileContext: %v", err) - } - - result := RenderBanner(profileCtx) - - if result == "" { - t.Error("RenderBanner(jediCtx) returned empty string") - } -} - -func TestRenderBannerAnimated(t *testing.T) { - // Disable animations to avoid timing issues in tests - origNoAnimation := animations.NoAnimation - defer func() { animations.NoAnimation = origNoAnimation }() - animations.NoAnimation = true - - result := RenderBannerAnimated(nil) - - if result == "" { - t.Error("RenderBannerAnimated(nil) returned empty string") - } -} - -func TestRenderBanner_NoColor(t *testing.T) { - // Test with NoColor enabled - origNoColor := styles.NoColor - defer func() { styles.NoColor = origNoColor }() - styles.NoColor = true - - result := RenderBanner(nil) - if result == "" { - t.Error("RenderBanner(nil) should work with NoColor") - } -} - -func TestBannerRendering_Consistency(t *testing.T) { - // Disable animations for consistent testing - origNoAnimation := animations.NoAnimation - defer func() { animations.NoAnimation = origNoAnimation }() - animations.NoAnimation = true - - // Render twice should produce consistent output - result1 := RenderBanner(nil) - result2 := RenderBanner(nil) - - if result1 == "" || result2 == "" { - t.Error("Banner rendering should produce non-empty output") - } - - // Results should be identical - if result1 != result2 { - t.Error("Banner rendering should be deterministic") - } -} - -func TestBannerComponents(t *testing.T) { - // Disable animations for consistent testing - origNoAnimation := animations.NoAnimation - defer func() { animations.NoAnimation = origNoAnimation }() - animations.NoAnimation = true - - // Test that banner contains expected components - result := RenderBanner(nil) - - // Should contain some content (can't check exact content due to ANSI codes) - if len(result) < 10 { - t.Error("Banner seems too short") - } -} - -// TestGetTerminalWidth tests terminal width detection -func TestGetTerminalWidth(t *testing.T) { - width := getTerminalWidth() - - // Should return a positive width (either detected or default) - if width <= 0 { - t.Errorf("getTerminalWidth() returned invalid width: %d", width) - } - - // Should return at least the default width - if width < DefaultBannerWidth { - t.Errorf("getTerminalWidth() returned width less than default: %d < %d", width, DefaultBannerWidth) - } -} - -// TestRenderMinimalBanner tests minimal banner for narrow terminals -func TestRenderMinimalBanner(t *testing.T) { - result := renderMinimalBanner() - - if result == "" { - t.Error("renderMinimalBanner() returned empty string") - } - - // Should contain version info - if !strings.Contains(result, "A.R.C.") { - t.Error("Minimal banner should contain A.R.C. branding") - } - - // Should be shorter than full banner - fullBanner := RenderBanner(nil) - if len(result) >= len(fullBanner) { - t.Error("Minimal banner should be shorter than full banner") - } -} - -func TestRenderBanner_DifferentProfiles(t *testing.T) { - // Disable animations for consistent testing - origNoAnimation := animations.NoAnimation - defer func() { animations.NoAnimation = origNoAnimation }() - animations.NoAnimation = true - - // Test banner with different profiles - profileIDs := []string{"enterprise", "jedi", "saiyan", "shinobi", "pirate"} - - repo, err := profiles.NewRepository() - if err != nil { - t.Skipf("Skipping test: profile repository not available: %v", err) - return - } - - for _, profileID := range profileIDs { - t.Run(profileID, func(t *testing.T) { - profile, err := repo.GetByID(profileID) - if err != nil { - t.Skipf("Skipping %s: profile not available: %v", profileID, err) - return - } - - profileCtx, err := profiles.NewProfileContext(profile, nil) - if err != nil { - t.Fatalf("Failed to create ProfileContext for %s: %v", profileID, err) - } - - result := RenderBanner(profileCtx) - if result == "" { - t.Errorf("RenderBanner(%s) returned empty string", profileID) - } - - // Each profile should produce different output (different logos) - enterpriseResult := RenderBanner(nil) - if profileID != "enterprise" && result == enterpriseResult { - t.Errorf("Profile %s should produce different banner than enterprise", profileID) - } - }) - } -} - -// BenchmarkRenderBanner benchmarks static banner rendering -func BenchmarkRenderBanner(b *testing.B) { - // Save and restore animation state - origNoAnimation := animations.NoAnimation - defer func() { animations.NoAnimation = origNoAnimation }() - - animations.NoAnimation = true - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = RenderBanner(nil) - } -} - -// BenchmarkRenderBannerAnimated benchmarks animated banner rendering -func BenchmarkRenderBannerAnimated(b *testing.B) { - // Save and restore animation state - origNoAnimation := animations.NoAnimation - defer func() { animations.NoAnimation = origNoAnimation }() - - // Disable animations for benchmark (to avoid timing issues) - animations.NoAnimation = true - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = RenderBannerAnimated(nil) - } -} - -// BenchmarkRenderBannerAnimated_NoAnimation benchmarks animated banner with animations disabled -func BenchmarkRenderBannerAnimated_NoAnimation(b *testing.B) { - origNoAnimation := animations.NoAnimation - defer func() { animations.NoAnimation = origNoAnimation }() - - animations.NoAnimation = true - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = RenderBannerAnimated(nil) - } -} - -// BenchmarkRenderGradientBanner benchmarks gradient banner rendering -func BenchmarkRenderGradientBanner(b *testing.B) { - themes := []string{"cyan-purple", "rainbow", "fire", "ocean", "matrix"} - - for _, themeName := range themes { - b.Run(themeName, func(b *testing.B) { - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = renderGradientBanner(themeName) - } - }) - } -} - -// BenchmarkRenderCharacterRainbow benchmarks character rainbow rendering -func BenchmarkRenderCharacterRainbow(b *testing.B) { - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = renderCharacterRainbow() - } -} - -// BenchmarkColorizeHelp benchmarks help text colorization -func BenchmarkColorizeHelp(b *testing.B) { - helpText := ` -Usage: - arc [command] - -Available Commands: - info Show system information - theme Manage banner color themes - state Manage application state - -Flags: - -h, --help help for arc - -Global Flags: - --no-animation Disable all animations -` - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = colorizeHelp(helpText) - } -} - -// BenchmarkColorizeHelpWithAnimation benchmarks animated help text colorization -func BenchmarkColorizeHelpWithAnimation(b *testing.B) { - origNoAnimation := animations.NoAnimation - defer func() { animations.NoAnimation = origNoAnimation }() - - // Disable actual sleep for benchmark - animations.NoAnimation = true - - helpText := ` -Usage: - arc [command] - -Available Commands: - info Show system information - theme Manage banner color themes - -Flags: - -h, --help help for arc -` - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = colorizeHelpWithAnimation(helpText) - } -} - -// TestGoldenBanners tests all profile banners against golden files -func TestGoldenBanners(t *testing.T) { - // Disable animations and colors for consistent golden file comparison - origNoAnimation := animations.NoAnimation - origNoColor := styles.NoColor - defer func() { - animations.NoAnimation = origNoAnimation - styles.NoColor = origNoColor - }() - animations.NoAnimation = true - styles.NoColor = true - - // All 10 profiles - profileIDs := []string{ - "enterprise", "jedi", "saiyan", "shinobi", "pirate", - "pokemon", "triforce", "crystal", "bending", "horcrux", - } - - repo, err := profiles.NewRepository() - if err != nil { - t.Fatalf("Failed to create repository: %v", err) - } - - updateGolden := os.Getenv("UPDATE_GOLDEN") == "1" - - for _, profileID := range profileIDs { - t.Run(profileID, func(t *testing.T) { - profile, err := repo.GetByID(profileID) - if err != nil { - t.Fatalf("Failed to load profile %s: %v", profileID, err) - } - - profileCtx, err := profiles.NewProfileContext(profile, nil) - if err != nil { - t.Fatalf("Failed to create ProfileContext for %s: %v", profileID, err) - } - - // Generate banner - banner := RenderBanner(profileCtx) - - // Golden file path - goldenFile := filepath.Join("..", "..", "testdata", "golden", "banners", profileID+".txt") - - if updateGolden { - // Update golden file - if err := os.WriteFile(goldenFile, []byte(banner), 0o644); err != nil { - t.Fatalf("Failed to update golden file for %s: %v", profileID, err) - } - t.Logf("Updated golden file: %s", goldenFile) - return - } - - // Read golden file - expected, err := os.ReadFile(goldenFile) - if err != nil { - t.Fatalf("Failed to read golden file for %s: %v (run with UPDATE_GOLDEN=1 to create)", profileID, err) - } - - // Compare - if banner != string(expected) { - t.Errorf("Banner for %s does not match golden file.\nExpected:\n%s\n\nGot:\n%s", profileID, string(expected), banner) - t.Logf("To update golden files, run: UPDATE_GOLDEN=1 go test -run TestGoldenBanners") - } - }) - } -} diff --git a/pkg/cli/completion.go b/pkg/cli/completion.go index 51aac65..3d83190 100644 --- a/pkg/cli/completion.go +++ b/pkg/cli/completion.go @@ -5,11 +5,9 @@ import ( "os" "runtime" "strings" - "time" + "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" - - "github.com/arc-framework/arc-cli/pkg/ui/styles" ) const ( @@ -19,7 +17,14 @@ const ( shellPowershell = "powershell" ) -var completionInteractive bool +var ( + completionInteractive bool + + cmpInfo = lipgloss.NewStyle().Foreground(lipgloss.Color("#8BE9FD")) + cmpCode = lipgloss.NewStyle().Foreground(lipgloss.Color("#50FA7B")) + cmpCheck = lipgloss.NewStyle().Foreground(lipgloss.Color("#50FA7B")) + cmpErr = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5555")) +) func init() { rootCmd.AddCommand(completionCmd) @@ -27,79 +32,37 @@ func init() { } var completionCmd = &cobra.Command{ - Use: "completion [bash|zsh|fish|powershell]", - Short: "Generate shell completion scripts", + Use: "completion [bash|zsh|fish|powershell]", + Hidden: true, + Short: "Generate shell completion scripts", Long: `Generate shell completion scripts for arc CLI. -Supported shells: - - bash - - zsh - - fish - - powershell - -To load completions: - -Bash: - $ source <(arc completion bash) - - # To load completions for each session, execute once: - # Linux: - $ arc completion bash > /etc/bash_completion.d/arc - # macOS: - $ arc completion bash > $(brew --prefix)/etc/bash_completion.d/arc - -Zsh: - # If shell completion is not already enabled in your environment, - # you will need to enable it. Add the following to ~/.zshrc: - autoload -Uz compinit - compinit - - # To load completions for each session, execute once: - $ arc completion zsh > "${fpath[1]}/_arc" - - # You will need to start a new shell for this setup to take effect. - -Fish: - $ arc completion fish | source +Supported shells: bash, zsh, fish, powershell - # To load completions for each session, execute once: - $ arc completion fish > ~/.config/fish/completions/arc.fish - -PowerShell: - PS> arc completion powershell | Out-String | Invoke-Expression - - # To load completions for every new session, run: - PS> arc completion powershell > arc.ps1 - # and source this file from your PowerShell profile. +Bash: source <(arc completion bash) +Zsh: arc completion zsh > "${fpath[1]}/_arc" +Fish: arc completion fish > ~/.config/fish/completions/arc.fish +PS: arc completion powershell > arc.ps1 `, ValidArgs: []string{shellBash, shellZsh, shellFish, shellPowershell}, Args: cobra.MatchAll(cobra.MaximumNArgs(1), cobra.OnlyValidArgs), Run: func(cmd *cobra.Command, args []string) { - logger := GetLogger() - - // Interactive mode if completionInteractive { - logger.Debug("Starting interactive completion setup") runInteractiveCompletion() return } - // Detect shell if not provided shell := "" if len(args) > 0 { shell = args[0] } else { shell = detectShell() if shell == "" { - styles.Error("Could not detect shell. Please specify: bash, zsh, fish, or powershell") + fmt.Fprintln(os.Stderr, "Could not detect shell. Please specify: bash, zsh, fish, or powershell") return } - logger.Debug("Detected shell", "shell", shell) } - // Generate completion - logger.Info("Generating completion", "shell", shell) - var err error switch shell { case shellBash: @@ -111,75 +74,49 @@ PowerShell: case shellPowershell: err = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) default: - logger.Warn("Invalid shell specified", "shell", shell) - styles.Error("Invalid shell: %s. Supported shells: bash, zsh, fish, powershell", shell) + fmt.Fprintf(os.Stderr, "Invalid shell: %s. Supported shells: bash, zsh, fish, powershell\n", shell) return } if err != nil { - logger.Error("Failed to generate completion", "shell", shell, "error", err) - styles.Error("Failed to generate completion: %v", err) - return + fmt.Fprintf(os.Stderr, "Failed to generate completion: %v\n", err) } - - logger.Info("Completion generated successfully", "shell", shell) }, } -// detectShell attempts to detect the current shell func detectShell() string { - // Check SHELL environment variable shellPath := os.Getenv("SHELL") if shellPath != "" { - if strings.Contains(shellPath, "bash") { + switch { + case strings.Contains(shellPath, "bash"): return shellBash - } - if strings.Contains(shellPath, "zsh") { + case strings.Contains(shellPath, "zsh"): return shellZsh - } - if strings.Contains(shellPath, "fish") { + case strings.Contains(shellPath, "fish"): return shellFish } } - - // Check for PowerShell on Windows if runtime.GOOS == "windows" { return shellPowershell } - return "" } -// runInteractiveCompletion runs an interactive completion setup wizard func runInteractiveCompletion() { - logger := GetLogger() - - styles.Info("🎯 Shell Completion Setup Wizard") + fmt.Println(cmpInfo.Render("🎯 Shell Completion Setup Wizard")) fmt.Println() - - // Animate wizard start fmt.Print("Detecting your shell... ") - time.Sleep(300 * time.Millisecond) shell := detectShell() if shell == "" { - fmt.Println(styles.ErrorStyle.Render("✗")) - fmt.Println() - styles.Error("Could not auto-detect your shell.") - fmt.Println() - fmt.Println("Please run: arc completion ") - fmt.Println("Supported shells: bash, zsh, fish, powershell") + fmt.Println(cmpErr.Render("✗")) + fmt.Fprintln(os.Stderr, "\nCould not auto-detect shell.\nRun: arc completion ") return } - fmt.Println(styles.SuccessStyle.Render("✓")) - styles.Success("Detected: %s", shell) + fmt.Println(cmpCheck.Render("✓ " + shell)) fmt.Println() - - logger.Info("Interactive completion wizard started", "shell", shell) - - // Show installation instructions - styles.Info("📝 Installation Instructions") + fmt.Println(cmpInfo.Render("📝 Installation Instructions")) fmt.Println() switch shell { @@ -194,71 +131,42 @@ func runInteractiveCompletion() { } fmt.Println() - styles.Info("💡 Tip: After installation, restart your shell or source your profile") - - logger.Info("Interactive completion wizard completed", "shell", shell) + fmt.Println(cmpInfo.Render("💡 Restart your shell or source your profile after installation")) } func showBashInstructions() { - fmt.Println("For Bash, run one of the following commands:") - fmt.Println() - if runtime.GOOS == "darwin" { - styles.Info("macOS (with Homebrew):") - fmt.Println(styles.CodeStyle.Render(" arc completion bash > $(brew --prefix)/etc/bash_completion.d/arc")) - fmt.Println() - styles.Info("Then add to ~/.bash_profile:") - fmt.Println(styles.CodeStyle.Render(" [[ -r \"$(brew --prefix)/etc/profile.d/bash_completion.sh\" ]] && . \"$(brew --prefix)/etc/profile.d/bash_completion.sh\"")) + fmt.Println(cmpInfo.Render("macOS (Homebrew):")) + fmt.Println(cmpCode.Render(" arc completion bash > $(brew --prefix)/etc/bash_completion.d/arc")) } else { - styles.Info("Linux:") - fmt.Println(styles.CodeStyle.Render(" sudo arc completion bash > /etc/bash_completion.d/arc")) - fmt.Println() - styles.Info("Or add to ~/.bashrc:") - fmt.Println(styles.CodeStyle.Render(" source <(arc completion bash)")) + fmt.Println(cmpInfo.Render("Linux:")) + fmt.Println(cmpCode.Render(" sudo arc completion bash > /etc/bash_completion.d/arc")) + fmt.Println(cmpInfo.Render("Or add to ~/.bashrc:")) + fmt.Println(cmpCode.Render(" source <(arc completion bash)")) } } func showZshInstructions() { - fmt.Println("For Zsh:") + fmt.Println(cmpInfo.Render("Step 1: Enable in ~/.zshrc:")) + fmt.Println(cmpCode.Render(" autoload -Uz compinit && compinit")) fmt.Println() - - styles.Info("Step 1: Enable completion in ~/.zshrc (if not already enabled):") - fmt.Println(styles.CodeStyle.Render(" autoload -Uz compinit")) - fmt.Println(styles.CodeStyle.Render(" compinit")) - fmt.Println() - - styles.Info("Step 2: Install arc completion:") - fmt.Println(styles.CodeStyle.Render(" arc completion zsh > \"${fpath[1]}/_arc\"")) + fmt.Println(cmpInfo.Render("Step 2: Install:")) + fmt.Println(cmpCode.Render(` arc completion zsh > "${fpath[1]}/_arc"`)) fmt.Println() - - styles.Info("Step 3: Restart your shell:") - fmt.Println(styles.CodeStyle.Render(" exec zsh")) + fmt.Println(cmpInfo.Render("Step 3: Restart:")) + fmt.Println(cmpCode.Render(" exec zsh")) } func showFishInstructions() { - fmt.Println("For Fish:") - fmt.Println() - - styles.Info("Install completion:") - fmt.Println(styles.CodeStyle.Render(" arc completion fish > ~/.config/fish/completions/arc.fish")) - fmt.Println() - - styles.Info("Restart Fish:") - fmt.Println(styles.CodeStyle.Render(" exec fish")) + fmt.Println(cmpInfo.Render("Install:")) + fmt.Println(cmpCode.Render(" arc completion fish > ~/.config/fish/completions/arc.fish")) + fmt.Println(cmpInfo.Render("Restart:")) + fmt.Println(cmpCode.Render(" exec fish")) } func showPowerShellInstructions() { - fmt.Println("For PowerShell:") - fmt.Println() - - styles.Info("Step 1: Generate completion script:") - fmt.Println(styles.CodeStyle.Render(" arc completion powershell > arc_completion.ps1")) - fmt.Println() - - styles.Info("Step 2: Add to your PowerShell profile:") - fmt.Println(styles.CodeStyle.Render(" # Find your profile location:")) - fmt.Println(styles.CodeStyle.Render(" $PROFILE")) - fmt.Println() - fmt.Println(styles.CodeStyle.Render(" # Add this line to your profile:")) - fmt.Println(styles.CodeStyle.Render(" . /path/to/arc_completion.ps1")) + fmt.Println(cmpInfo.Render("Generate:")) + fmt.Println(cmpCode.Render(" arc completion powershell > arc_completion.ps1")) + fmt.Println(cmpInfo.Render("Add to profile:")) + fmt.Println(cmpCode.Render(" . /path/to/arc_completion.ps1")) } diff --git a/pkg/cli/config/profile.go b/pkg/cli/config/profile.go index d3765c8..2a885cc 100644 --- a/pkg/cli/config/profile.go +++ b/pkg/cli/config/profile.go @@ -2,31 +2,16 @@ package config import ( "fmt" - "os" "sort" "strings" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" - "github.com/arc-framework/arc-cli/internal/app" "github.com/arc-framework/arc-cli/internal/preferences" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/styles" - "github.com/arc-framework/arc-cli/pkg/ui/views" + uithemeldr "github.com/arc-framework/arc-cli/pkg/ui/theme" ) -// appContext holds reference to the application context for cache invalidation -var appContext *app.Context - -// SetAppContext sets the application context for this package. -// This should be called by the root command during initialization. -func SetAppContext(ctx *app.Context) { - appContext = ctx -} - // newSetProfileCmd creates the set-profile subcommand. func newSetProfileCmd() *cobra.Command { cmd := &cobra.Command{ @@ -100,342 +85,127 @@ Shows profile ID, name, theme, and tier names for each profile.`, return cmd } -// runSetProfile sets the default profile and syncs the theme. func runSetProfile(profileID string) error { - // Use new UI path if appContext is available and legacy UI is not forced - if appContext != nil && os.Getenv("ARC_USE_LEGACY_UI") == "" { - return renderSetProfileUI(profileID) - } - return legacyRunSetProfile(profileID) -} - -// runGetProfile displays the current profile. -func runGetProfile() error { - // Use new UI path if appContext is available and legacy UI is not forced - if appContext != nil && os.Getenv("ARC_USE_LEGACY_UI") == "" { - return renderGetProfileUI() - } - return legacyRunGetProfile() -} - -// runListProfiles displays all available profiles in a table. -func runListProfiles() error { - // Use new UI path if appContext is available and legacy UI is not forced - if appContext != nil && os.Getenv("ARC_USE_LEGACY_UI") == "" { - return renderListProfilesUI() - } - return legacyRunListProfiles() -} - -// renderSetProfileUI renders the profile selection using ProfileSelectView. -func renderSetProfileUI(profileID string) error { - // Validate and set first (so the UI reflects the updated state) - if err := legacyRunSetProfile(profileID); err != nil { - return err - } - // Then show the current config - return renderGetProfileUI() -} - -// renderGetProfileUI renders the current config using ConfigGetView. -func renderGetProfileUI() error { - profileCtx := appContext.GetProfileContext() - if profileCtx == nil { - return legacyRunGetProfile() - } - - borderTier := appContext.SafeBorder.Tier() - factory := ui.NewComponentFactory(profileCtx, borderTier) - - view := views.NewConfigGetView(factory) - - viewCtx := engine.NewViewContext( - profileCtx.Profile(), - profileCtx.Theme(), - 80, - 40, - make(map[string]any), - ) - _ = view.OnEnter(viewCtx) - - return engine.Render(engine.RenderConfig{ - View: view, - Mode: engine.TUIMode, - }) -} - -// renderListProfilesUI renders all profiles using ProfileListView. -func renderListProfilesUI() error { - profileCtx := appContext.GetProfileContext() - if profileCtx == nil { - return legacyRunListProfiles() - } - - borderTier := appContext.SafeBorder.Tier() - factory := ui.NewComponentFactory(profileCtx, borderTier) - - view := views.NewProfileListView(factory) - - viewCtx := engine.NewViewContext( - profileCtx.Profile(), - profileCtx.Theme(), - 80, - 40, - make(map[string]any), - ) - _ = view.OnEnter(viewCtx) - - return engine.Render(engine.RenderConfig{ - View: view, - Mode: engine.TUIMode, - }) -} - -// legacyRunSetProfile is the original set-profile implementation. -func legacyRunSetProfile(profileID string) error { - // Validate profile exists - repo, err := profiles.NewRepository() + loader, err := uithemeldr.NewLoader() if err != nil { return fmt.Errorf("failed to load profiles: %w", err) } - profile, err := repo.GetProfile(profileID) + profile, err := loader.GetProfile(profileID) if err != nil { return fmt.Errorf("invalid profile: %w", err) } - // Load preferences prefs, err := preferences.Load() if err != nil { return fmt.Errorf("failed to load preferences: %w", err) } - - // Set profile and sync theme atomically (implements FR-006, FR-007, FR-017) if saveErr := prefs.SetProfileWithThemeSync(profileID, profile.ThemeID); saveErr != nil { - return fmt.Errorf("failed to save profile and theme: %w", saveErr) + return fmt.Errorf("failed to save profile: %w", saveErr) } - // Invalidate ProfileContext cache to force reload - if appContext != nil { - appContext.InvalidateProfileContext() - } - - // Success message styles - successStyle := lipgloss.NewStyle(). - Foreground(styles.SuccessStyle.GetForeground()). - Bold(true) - - profileStyle := lipgloss.NewStyle(). - Foreground(styles.PrimaryStyle.GetForeground()). - Bold(true) - - themeStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#50FA7B")). - Bold(true) - - // Display success with profile and theme info - fmt.Printf("%s Profile set to %s\n", - successStyle.Render("✓"), - profileStyle.Render(profile.Name), - ) + success := lipgloss.NewStyle().Foreground(lipgloss.Color("#50FA7B")).Bold(true) + primary := lipgloss.NewStyle().Foreground(lipgloss.Color("#00ADD8")).Bold(true) + muted := lipgloss.NewStyle().Foreground(lipgloss.Color("#6272A4")) + fmt.Printf("%s Profile set to %s\n", success.Render("✓"), primary.Render(profile.Name)) if profile.ThemeID != "" { - fmt.Printf("%s Theme synced to %s\n", - successStyle.Render("✓"), - themeStyle.Render(profile.ThemeID), - ) + fmt.Printf("%s Theme synced to %s\n", success.Render("✓"), primary.Render(profile.ThemeID)) } - - fmt.Printf("\nTier names: %s\n", - strings.Join(profile.TierNames, " → "), - ) - + fmt.Printf("Tier names: %s\n", muted.Render(strings.Join(profile.TierNames, " → "))) return nil } -// legacyRunGetProfile is the original get-profile implementation. -func legacyRunGetProfile() error { - // Load preferences +func runGetProfile() error { prefs, err := preferences.Load() if err != nil { return fmt.Errorf("failed to load preferences: %w", err) } - profileID := prefs.GetProfile() if profileID == "" { - fmt.Println("No profile set (using default: Enterprise)") + fmt.Println("No profile set (using default: enterprise)") return nil } - // Load profile - repo, err := profiles.NewRepository() + loader, err := uithemeldr.NewLoader() if err != nil { return fmt.Errorf("failed to load profiles: %w", err) } - profile, err := repo.GetProfile(profileID) + profile, err := loader.GetProfile(profileID) if err != nil { return fmt.Errorf("failed to load profile: %w", err) } - - // Get current theme from preferences currentTheme := prefs.GetTheme() - // Style definitions - headerStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(styles.PrimaryStyle.GetForeground()). - Underline(true) - - labelStyle := lipgloss.NewStyle(). - Foreground(styles.SecondaryStyle.GetForeground()). - Bold(true) - - valueStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#50FA7B")) - - themeStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#50FA7B")). - Bold(true) - - tierStyle := lipgloss.NewStyle(). - Foreground(styles.PrimaryStyle.GetForeground()). - Bold(true) + primary := lipgloss.NewStyle().Foreground(lipgloss.Color("#00ADD8")).Bold(true) + label := lipgloss.NewStyle().Foreground(lipgloss.Color("#BD93F9")).Bold(true) + value := lipgloss.NewStyle().Foreground(lipgloss.Color("#50FA7B")) + warn := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFB86C")) - warningStyle := lipgloss.NewStyle(). - Foreground(styles.WarningStyle.GetForeground()) - - // Output fmt.Println() - fmt.Println(headerStyle.Render("Current Profile")) + fmt.Println(primary.Render("Current Profile")) fmt.Println() - - fmt.Printf("%s %s\n", labelStyle.Render("ID:"), valueStyle.Render(profile.ID)) - fmt.Printf("%s %s\n", labelStyle.Render("Name:"), valueStyle.Render(profile.Name)) - fmt.Printf("%s %s\n", labelStyle.Render("Description:"), profile.Description) - - fmt.Println() - fmt.Println(labelStyle.Render("Theme Configuration:")) - fmt.Printf(" %s %s\n", labelStyle.Render("Profile Theme:"), themeStyle.Render(profile.ThemeID)) - fmt.Printf(" %s %s", labelStyle.Render("Active Theme:"), themeStyle.Render(currentTheme)) - - // Show warning if theme is out of sync + fmt.Printf("%s %s\n", label.Render("ID:"), value.Render(profile.ID)) + fmt.Printf("%s %s\n", label.Render("Name:"), value.Render(profile.Name)) + fmt.Printf("%s %s\n", label.Render("Profile Theme:"), value.Render(profile.ThemeID)) + activeThemeOut := value.Render(currentTheme) if currentTheme != profile.ThemeID { - fmt.Printf(" %s", warningStyle.Render("(out of sync!)")) + activeThemeOut += " " + warn.Render("(out of sync!)") } + fmt.Printf("%s %s\n", label.Render("Active Theme:"), activeThemeOut) fmt.Println() - - fmt.Println() - fmt.Println(labelStyle.Render("Tier Names:")) + fmt.Println(label.Render("Tier Names:")) for i, tierName := range profile.TierNames { - fmt.Printf(" %s %s\n", - tierStyle.Render(fmt.Sprintf("Tier %d:", i+1)), - tierName, - ) + fmt.Printf(" %s %s\n", primary.Render(fmt.Sprintf("Tier %d:", i+1)), tierName) } fmt.Println() - return nil } -// legacyRunListProfiles is the original list-profiles implementation. -func legacyRunListProfiles() error { - // Load repository - repo, err := profiles.NewRepository() +func runListProfiles() error { + loader, err := uithemeldr.NewLoader() if err != nil { return fmt.Errorf("failed to load profiles: %w", err) } - allProfiles, err := repo.ListProfiles() - if err != nil { - return fmt.Errorf("failed to list profiles: %w", err) - } - - // Sort profiles by ID for consistent output - sort.Slice(allProfiles, func(i, j int) bool { - return allProfiles[i].ID < allProfiles[j].ID - }) + allProfiles := loader.GetProfiles() - // Load current profile from preferences prefs, _ := preferences.Load() currentProfileID := prefs.GetProfile() - // Style definitions - headerStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(styles.PrimaryStyle.GetForeground()). - Underline(true) - - tableHeaderStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(styles.SecondaryStyle.GetForeground()) - - idStyle := lipgloss.NewStyle(). - Foreground(styles.PrimaryStyle.GetForeground()). - Bold(true) - - themeStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#50FA7B")) - - tierStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6272A4")) + ids := make([]string, 0, len(allProfiles)) + for id := range allProfiles { + ids = append(ids, id) + } + sort.Strings(ids) - currentStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFD700")). - Bold(true) + primary := lipgloss.NewStyle().Foreground(lipgloss.Color("#00ADD8")).Bold(true) + label := lipgloss.NewStyle().Foreground(lipgloss.Color("#BD93F9")).Bold(true) + themeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#50FA7B")) + tierStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6272A4")) + currentStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700")).Bold(true) - // Print header fmt.Println() - fmt.Println(headerStyle.Render("Available Profiles")) + fmt.Println(primary.Render("Available Profiles")) fmt.Println() + fmt.Printf("%-15s %-20s %-20s %-50s\n", + label.Render("ID"), label.Render("NAME"), label.Render("THEME"), label.Render("TIERS")) + fmt.Println(strings.Repeat("─", 110)) - // Table header - fmt.Printf("%-15s %-20s %-20s %-15s %-50s\n", - tableHeaderStyle.Render("PROFILE ID"), - tableHeaderStyle.Render("NAME"), - tableHeaderStyle.Render("THEME"), - tableHeaderStyle.Render("TIER 1"), - tableHeaderStyle.Render("TIER 2 → TIER 3"), - ) - - // Separator - fmt.Println(strings.Repeat("─", 120)) - - // Table rows - for _, profile := range allProfiles { - // Build tier display - tier1 := "" - tier2to3 := "" - if len(profile.TierNames) > 0 { - tier1 = profile.TierNames[0] - } - if len(profile.TierNames) > 1 { - tierRest := profile.TierNames[1:] - tier2to3 = strings.Join(tierRest, " → ") - } - - // Mark current profile + for _, id := range ids { + p := allProfiles[id] marker := " " - profileIDDisplay := idStyle.Render(profile.ID) - if profile.ID == currentProfileID { + idDisplay := primary.Render(p.ID) + if p.ID == currentProfileID { marker = currentStyle.Render("▶") - profileIDDisplay = currentStyle.Render(profile.ID) + idDisplay = currentStyle.Render(p.ID) } - - fmt.Printf("%s %-15s %-20s %-20s %-15s %-50s\n", - marker, - profileIDDisplay, - profile.Name, - themeStyle.Render(profile.ThemeID), - tierStyle.Render(tier1), - tierStyle.Render(tier2to3), - ) + tiers := tierStyle.Render(strings.Join(p.TierNames, " → ")) + fmt.Printf("%s %-15s %-20s %-20s %-50s\n", + marker, idDisplay, p.Name, themeStyle.Render(p.ThemeID), tiers) } - - fmt.Println() - fmt.Printf("Total: %d profiles", len(allProfiles)) + fmt.Printf("\nTotal: %d profiles", len(ids)) if currentProfileID != "" { fmt.Printf(" (current: %s)", currentStyle.Render(currentProfileID)) } fmt.Println() - fmt.Println() - return nil } diff --git a/pkg/cli/config/profile_test.go b/pkg/cli/config/profile_test.go index b412f12..2d746b6 100644 --- a/pkg/cli/config/profile_test.go +++ b/pkg/cli/config/profile_test.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/arc-framework/arc-cli/internal/preferences" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" + uithemeldr "github.com/arc-framework/arc-cli/pkg/ui/theme" ) func TestSetProfile(t *testing.T) { @@ -130,14 +130,11 @@ func TestListProfiles(t *testing.T) { } // Verify all expected profiles are available - repo, err := profiles.NewRepository() + loader, err := uithemeldr.NewLoader() if err != nil { - t.Fatalf("failed to create repository: %v", err) - } - allProfiles, err := repo.ListProfiles() - if err != nil { - t.Fatalf("failed to list profiles: %v", err) + t.Fatalf("failed to create loader: %v", err) } + allProfiles := loader.ListProfiles() expectedProfiles := []string{ "enterprise", "saiyan", "jedi", "shinobi", "pirate", @@ -145,8 +142,8 @@ func TestListProfiles(t *testing.T) { } profileMap := make(map[string]bool) - for _, p := range allProfiles { - profileMap[p.ID] = true + for _, id := range allProfiles { + profileMap[id] = true } for _, expected := range expectedProfiles { @@ -157,9 +154,9 @@ func TestListProfiles(t *testing.T) { } func TestProfileValidation(t *testing.T) { - repo, err := profiles.NewRepository() + loader, err := uithemeldr.NewLoader() if err != nil { - t.Fatalf("failed to create repository: %v", err) + t.Fatalf("failed to create loader: %v", err) } tests := []struct { @@ -186,7 +183,7 @@ func TestProfileValidation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := repo.GetProfile(tt.profileID) + _, err := loader.GetProfile(tt.profileID) if (err != nil) != tt.wantErr { t.Errorf("GetProfile() error = %v, wantErr %v", err, tt.wantErr) } @@ -195,9 +192,9 @@ func TestProfileValidation(t *testing.T) { } func TestProfileTierNames(t *testing.T) { - repo, err := profiles.NewRepository() + loader, err := uithemeldr.NewLoader() if err != nil { - t.Fatalf("failed to create repository: %v", err) + t.Fatalf("failed to create loader: %v", err) } tests := []struct { @@ -224,7 +221,7 @@ func TestProfileTierNames(t *testing.T) { for _, tt := range tests { t.Run(tt.profileID, func(t *testing.T) { - profile, err := repo.GetProfile(tt.profileID) + profile, err := loader.GetProfile(tt.profileID) if err != nil { t.Fatalf("GetProfile() error = %v", err) } diff --git a/pkg/cli/dashboard/.gitkeep b/pkg/cli/dashboard/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/pkg/cli/dashboard/app.go b/pkg/cli/dashboard/app.go deleted file mode 100644 index 4cc991e..0000000 --- a/pkg/cli/dashboard/app.go +++ /dev/null @@ -1,691 +0,0 @@ -// Deprecated: Use pkg/ui/views/ instead. The dashboard package contains the legacy -// TUI implementation. New commands should implement pkg/ui/engine.View and register -// a view in pkg/ui/views/. Enable legacy mode with ARC_USE_LEGACY_UI=1. -package dashboard - -import ( - "os" - "strconv" - "time" - - "github.com/charmbracelet/bubbles/help" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/spf13/cobra" - "golang.org/x/term" - - "github.com/arc-framework/arc-cli/internal/app" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/views" - "github.com/arc-framework/arc-cli/pkg/version" -) - -// Tab constants - enum for activeTab field -const ( - TabDashboard = iota - TabServices - TabWorkspace - TabConfig -) - -// Key binding constants -const ( - keyTab = "tab" - keyShiftTab = "shift+tab" - keyEsc = "esc" -) - -// dashboardModel is the root Bubble Tea model for the A.R.C. dashboard. -// It composes the tab bar, content area, status rail, and help system. -type dashboardModel struct { - // activeTab tracks the currently selected tab (0-3) - activeTab int - - // width and height from WindowSizeMsg - width int - height int - - // factory provides themed UI components - factory ui.ComponentFactory - - // ctx holds app dependencies - ctx *app.Context - - // ready indicates whether WindowSizeMsg has been received - ready bool - - // keys holds the keybinding configuration - keys KeyMap - - // help is the bubbles/help model - help help.Model - - // showHelp indicates whether help is visible - showHelp bool - - // Header component (016-ui-layout-fix: Phase 3 - US1) - header *components.Header - - // Footer component (016-ui-layout-fix: Phase 4 - US2) - footer *components.Footer - footerVisible bool - - // Tab views (Phase 4: US2) - dashboardView *dashboardViewModel - servicesView *servicesViewModel - workspaceView *workspaceViewModel - configView *configViewModel - - // Toast notifications (Phase 7: US5) - toastStack *components.ToastStack -} - -// Init initializes the Bubble Tea model. -// Returns commands to enter alt screen and request window size. -func (m *dashboardModel) Init() tea.Cmd { - return tea.Batch( - tea.EnterAltScreen, - tea.WindowSize(), - ) -} - -// Update handles Bubble Tea messages and updates the model state. -// Handles global keys, window resize, and tab navigation. -// -//nolint:gocyclo,cyclop // Complexity will be reduced when view-specific logic is added in Phase 5+ -func (m *dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case components.ToastDismissMsg: - // Auto-dismiss expired toasts - m.toastStack.RemoveExpired() - return m, nil - - case tea.KeyMsg: - // Dismiss all toasts on any keypress (Phase 7: US5) - for _, toast := range m.toastStack.Toasts { - toast.Dismiss() - } - - // Handle global keys - switch { - case msg.String() == "q" || msg.String() == "ctrl+c" || msg.String() == keyEsc: - return m, tea.Quit - - case msg.String() == "?": - m.showHelp = !m.showHelp - return m, nil - - case msg.String() == keyTab: - // Cycle to next tab - m.activeTab = (m.activeTab + 1) % 4 - // Update header activeTab (016-ui-layout-fix: Phase 3 - US1 - T032) - if m.header != nil { - m.header.SetActiveTab(m.activeTab) - } - // Update footer controls (016-ui-layout-fix: Phase 4 - US2 - T053) - m.updateFooterControls() - return m, nil - - case msg.String() == keyShiftTab: - // Cycle to previous tab - m.activeTab-- - if m.activeTab < 0 { - m.activeTab = 3 - } - // Update header activeTab (016-ui-layout-fix: Phase 3 - US1 - T032) - if m.header != nil { - m.header.SetActiveTab(m.activeTab) - } - // Update footer controls (016-ui-layout-fix: Phase 4 - US2 - T053) - m.updateFooterControls() - return m, nil - - case msg.String() == "1": - m.activeTab = TabDashboard - // Update header activeTab (016-ui-layout-fix: Phase 3 - US1 - T032) - if m.header != nil { - m.header.SetActiveTab(m.activeTab) - } - // Update footer controls (016-ui-layout-fix: Phase 4 - US2 - T053) - if m.footer != nil { - m.footer.WithControls(getDashboardControls()) - } - return m, nil - - case msg.String() == "2": - m.activeTab = TabServices - // Update header activeTab (016-ui-layout-fix: Phase 3 - US1 - T032) - if m.header != nil { - m.header.SetActiveTab(m.activeTab) - } - // Update footer controls (016-ui-layout-fix: Phase 4 - US2 - T053) - if m.footer != nil { - m.footer.WithControls(getServicesControls()) - } - return m, nil - - case msg.String() == "3": - m.activeTab = TabWorkspace - // Update header activeTab (016-ui-layout-fix: Phase 3 - US1 - T032) - if m.header != nil { - m.header.SetActiveTab(m.activeTab) - } - // Update footer controls (016-ui-layout-fix: Phase 4 - US2 - T053) - if m.footer != nil { - m.footer.WithControls(getWorkspaceControls()) - } - return m, nil - - case msg.String() == "4": - m.activeTab = TabConfig - // Update header activeTab (016-ui-layout-fix: Phase 3 - US1 - T032) - if m.header != nil { - m.header.SetActiveTab(m.activeTab) - } - // Update footer controls (016-ui-layout-fix: Phase 4 - US2 - T053) - if m.footer != nil { - m.footer.WithControls(getConfigControls()) - } - return m, nil - - case msg.String() == "f": - // Toggle footer visibility (016-ui-layout-fix: Phase 4 - US2 - T054) - m.footerVisible = !m.footerVisible - return m, nil - } - - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - m.ready = true - - // Update header width (016-ui-layout-fix: Phase 3 - US1 - T032) - if m.header != nil { - m.header.SetWidth(msg.Width) - } - - // Update footer width (016-ui-layout-fix: Phase 4 - US2) - if m.footer != nil { - m.footer.SetWidth(msg.Width) - } - - // Propagate WindowSizeMsg to all views (Phase 4: US2 - T036) - var cmd tea.Cmd - if m.dashboardView != nil { - _, cmd = m.dashboardView.Update(msg) - } - if m.servicesView != nil { - _, cmd = m.servicesView.Update(msg) - } - if m.workspaceView != nil { - _, cmd = m.workspaceView.Update(msg) - } - if m.configView != nil { - _, cmd = m.configView.Update(msg) - } - return m, cmd - } - - // Delegate other messages to active view (Phase 4: US2 - T036) - var cmd tea.Cmd - switch m.activeTab { - case TabDashboard: - if m.dashboardView != nil { - _, cmd = m.dashboardView.Update(msg) - } - case TabServices: - if m.servicesView != nil { - _, cmd = m.servicesView.Update(msg) - } - case TabWorkspace: - if m.workspaceView != nil { - _, cmd = m.workspaceView.Update(msg) - } - case TabConfig: - if m.configView != nil { - _, cmd = m.configView.Update(msg) - } - } - - return m, cmd -} - -// updateFooterControls updates the footer keybindings based on the active tab (T053). -func (m *dashboardModel) updateFooterControls() { - if m.footer == nil { - return - } - - switch m.activeTab { - case TabDashboard: - m.footer.WithControls(getDashboardControls()) - case TabServices: - m.footer.WithControls(getServicesControls()) - case TabWorkspace: - m.footer.WithControls(getWorkspaceControls()) - case TabConfig: - m.footer.WithControls(getConfigControls()) - } -} - -// View renders the dashboard UI. -// Composes TabBar, content area, StatusRail, and help. -func (m *dashboardModel) View() string { - if !m.ready { - return "Initializing..." - } - - // Render header (016-ui-layout-fix: Phase 3 - US1 - T033) - headerView := m.renderHeader() - - // Content area - render active view (Phase 4: US2 - T037) - content := m.renderActiveTabContent() - - // Status rail - Phase 9 (US7): Dynamic, context-aware sections based on active tab - sections := m.buildStatusRailSections() - statusRail := m.factory.StatusRail(sections, m.width) - - // Help - helpView := m.renderHelp() - - // Render footer (016-ui-layout-fix: Phase 4 - US2 - T055) - footerView := m.renderFooter() - - // Compose all parts (016-ui-layout-fix: Phase 3 - US1 - T033, Phase 4 - US2 - T055) - parts := m.composeParts(headerView, content, statusRail, helpView, footerView) - - baseView := lipgloss.JoinVertical(lipgloss.Left, parts...) - - // Overlay toasts on top of base view (Phase 7: US5) - return m.toastStack.PlaceAllOverlays(baseView, m.width, m.height) -} - -// renderHeader renders the header component if available. -func (m *dashboardModel) renderHeader() string { - if m.header != nil { - return m.header.Render() - } - return "" -} - -// renderActiveTabContent renders the content for the currently active tab. -func (m *dashboardModel) renderActiveTabContent() string { - switch m.activeTab { - case TabDashboard: - if m.dashboardView != nil { - return m.dashboardView.View() - } - return "[Dashboard view not initialized]" - case TabServices: - if m.servicesView != nil { - return m.servicesView.View() - } - return "[Services view not initialized]" - case TabWorkspace: - if m.workspaceView != nil { - return m.workspaceView.View() - } - return "[Workspace view not initialized]" - case TabConfig: - if m.configView != nil { - return m.configView.View() - } - return "[Config view not initialized]" - default: - return "[Unknown tab]" - } -} - -// renderHelp renders the help view if help is shown. -func (m *dashboardModel) renderHelp() string { - if m.showHelp { - return m.help.View(&m.keys) - } - return "" -} - -// renderFooter renders the footer component if available and visible. -func (m *dashboardModel) renderFooter() string { - if m.footer != nil && m.footerVisible { - return m.footer.Render() - } - return "" -} - -// composeParts composes all UI parts into a slice, skipping empty parts. -func (m *dashboardModel) composeParts(headerView, content, statusRail, helpView, footerView string) []string { - parts := []string{} - if headerView != "" { - parts = append(parts, headerView) - } - parts = append(parts, content, statusRail) - if m.showHelp { - parts = append(parts, helpView) - } - if footerView != "" { - parts = append(parts, footerView) - } - return parts -} - -// buildStatusRailSections creates dynamic status rail sections based on current context. -// Phase 9 (US7): Shows profile, tier (with profile-specific names), workspace, and tab-specific info. -func (m *dashboardModel) buildStatusRailSections() []ui.RailSection { - sections := []ui.RailSection{} - - // Profile section (always visible if ProfileContext is available) - if profileCtx := m.ctx.GetProfileContext(); profileCtx != nil { - profile := profileCtx.Profile() - profileEmoji := m.getProfileEmoji(profile.ID) - - sections = append(sections, ui.RailSection{ - Icon: profileEmoji, - Label: "Profile", - Value: profile.Name, - }) - - // Tier section with profile-specific name - // For now, show Tier 0 as default; in full implementation, this would come from workspace state - tierName, err := profileCtx.GetTierName(0) - if err == nil { - sections = append(sections, ui.RailSection{ - Icon: "⭐", - Label: "Tier", - Value: tierName, - }) - } - } - - // Workspace section (always visible) - workspaceValue := "None" - // In full implementation, this would check actual workspace state - // For now, we show "None" as placeholder - sections = append(sections, ui.RailSection{ - Icon: "📁", - Label: "Workspace", - Value: workspaceValue, - }) - - // Tab-specific section (changes based on active tab) - switch m.activeTab { - case TabDashboard: - // Show service count - serviceCount := 0 - if m.ctx.Catalog != nil { - serviceCount = m.ctx.Catalog.ServiceCount() - } - sections = append(sections, ui.RailSection{ - Icon: "🔧", - Label: "Services", - Value: strconv.Itoa(serviceCount) + " total", - }) - - case TabServices: - // Show selected service or total count - sections = append(sections, ui.RailSection{ - Icon: "🔍", - Label: "View", - Value: "Browse", - }) - - case TabWorkspace: - // Show workspace tier level - sections = append(sections, ui.RailSection{ - Icon: "📊", - Label: "Status", - Value: "Ready", - }) - - case TabConfig: - // Show settings indicator - sections = append(sections, ui.RailSection{ - Icon: "⚙️", - Label: "Mode", - Value: "Settings", - }) - } - - return sections -} - -// getProfileEmoji returns the emoji icon for a given profile ID. -// Phase 9 (US7): Maps profile IDs to their representative emojis. -func (m *dashboardModel) getProfileEmoji(profileID string) string { - emojiMap := map[string]string{ - "jedi": "⚔️", - "saiyan": "⚡", - "enterprise": "💼", - "pokemon": "⚡", - "shinobi": "🥷", - "bending": "🌊", - "horcrux": "🔮", - "pirate": "☠️", - "triforce": "▲", - "crystal": "💎", - } - - if emoji, ok := emojiMap[profileID]; ok { - return emoji - } - - // Default emoji for unknown profiles - return "👤" -} - -// ShowToast displays a toast notification and returns a command to auto-dismiss it. -// Phase 7 (US5): Toast notifications for errors and status updates. -func (m *dashboardModel) ShowToast(message string, severity components.Severity, duration time.Duration) tea.Cmd { - toast := components.NewToast(message, severity). - SetDuration(duration). - SetPosition(components.ToastPositionTopRight) - - m.toastStack.Push(toast) - - // Return auto-dismiss command - return components.TickDismiss(duration, message) -} - -// ShowError displays an error as a toast notification. -// Phase 7 (US5): Unified error boundary - dashboard mode uses toasts instead of ErrorBox. -func (m *dashboardModel) ShowError(err error) tea.Cmd { - if err == nil { - return nil - } - return m.ShowToast(err.Error(), components.SeverityError, 5*time.Second) -} - -// ShowWarning displays a warning as a toast notification. -func (m *dashboardModel) ShowWarning(message string) tea.Cmd { - return m.ShowToast(message, components.SeverityWarning, 4*time.Second) -} - -// ShowInfo displays an info message as a toast notification. -func (m *dashboardModel) ShowInfo(message string) tea.Cmd { - return m.ShowToast(message, components.SeverityInfo, 3*time.Second) -} - -// ShowSuccess displays a success message as a toast notification. -func (m *dashboardModel) ShowSuccess(message string) tea.Cmd { - return m.ShowToast(message, components.SeveritySuccess, 3*time.Second) -} - -// getDashboardControls returns keybindings specific to the Dashboard view (T052). -func getDashboardControls() []components.KeyBinding { - return []components.KeyBinding{ - {Key: "Tab", Description: "Next"}, - {Key: "↑/↓", Description: "Navigate"}, - {Key: "Enter", Description: "Details"}, - {Key: "q", Description: "Quit"}, - {Key: "f", Description: "Toggle Footer"}, - } -} - -// getServicesControls returns keybindings specific to the Services view (T052). -func getServicesControls() []components.KeyBinding { - return []components.KeyBinding{ - {Key: "Tab", Description: "Next"}, - {Key: "↑/↓", Description: "Select"}, - {Key: "Enter", Description: "Details"}, - {Key: "s", Description: "Start"}, - {Key: "x", Description: "Stop"}, - {Key: "q", Description: "Quit"}, - {Key: "f", Description: "Toggle Footer"}, - } -} - -// getWorkspaceControls returns keybindings specific to the Workspace view (T052). -func getWorkspaceControls() []components.KeyBinding { - return []components.KeyBinding{ - {Key: "Tab", Description: "Next"}, - {Key: "Enter", Description: "Select"}, - {Key: "n", Description: "New"}, - {Key: "d", Description: "Delete"}, - {Key: "q", Description: "Quit"}, - {Key: "f", Description: "Toggle Footer"}, - } -} - -// getConfigControls returns keybindings specific to the Config view (T052). -func getConfigControls() []components.KeyBinding { - return []components.KeyBinding{ - {Key: "Tab", Description: "Next"}, - {Key: "↑/↓", Description: "Navigate"}, - {Key: "Enter", Description: "Edit"}, - {Key: "Esc", Description: "Cancel"}, - {Key: "q", Description: "Quit"}, - {Key: "f", Description: "Toggle Footer"}, - } -} - -// getTerminalDimensions returns the current terminal width and height. -// Returns default values (80x24) if detection fails. -func getTerminalDimensions() (width, height int) { - // Try to get terminal dimensions from /dev/tty (works even if stdout is redirected) - fd := int(os.Stdin.Fd()) - w, h, err := term.GetSize(fd) - if err == nil && w > 0 && h > 0 { - return w, h - } - - // Fallback to default dimensions - return 80, 24 -} - -// Launch starts the dashboard TUI. -// Uses the new engine.DashboardView unless ARC_USE_LEGACY_UI is set. -func Launch(ctx *app.Context) error { - // Use new DashboardView (Phase 5: US3 - T225) - if os.Getenv("ARC_USE_LEGACY_UI") == "" { - return launchNewDashboard(ctx) - } - return launchLegacyDashboard(ctx) -} - -// launchNewDashboard starts the new engine-based DashboardView. -func launchNewDashboard(ctx *app.Context) error { - return engine.Render(engine.RenderConfig{ - View: views.NewDashboardView(ctx.Factory), - Mode: engine.TUIMode, - }) -} - -// launchLegacyDashboard starts the legacy Bubble Tea dashboard. -// Used when ARC_USE_LEGACY_UI=1 environment variable is set. -func launchLegacyDashboard(ctx *app.Context) error { - // Get terminal dimensions for header initialization - width, height := getTerminalDimensions() - - // Create dashboard tabs - tabs := components.DashboardTabs() - - // Create header (016-ui-layout-fix: Phase 3 - US1 - T031) - header := components.NewHeader(ctx.Factory, tabs, TabDashboard, width) - - // Create footer (016-ui-layout-fix: Phase 4 - US2 - T051) - initialControls := getDashboardControls() - footer := components.NewFooter(ctx.Factory, initialControls, version.Version, version.Commit, width) - - // Create dashboard model - model := &dashboardModel{ - activeTab: TabDashboard, - width: width, - height: height, - factory: ctx.Factory, - ctx: ctx, - keys: DefaultKeyMap(), - help: help.New(), - // Initialize header (016-ui-layout-fix: Phase 3 - US1 - T031) - header: header, - // Initialize footer (016-ui-layout-fix: Phase 4 - US2 - T051) - footer: footer, - footerVisible: true, // Footer visible by default - // Initialize views (Phase 4: US2 - T038) - dashboardView: newDashboardView(ctx.Factory, ctx), - servicesView: newServicesView(ctx.Factory, ctx), - workspaceView: newWorkspaceView(ctx.Factory, ctx), - configView: newConfigView(ctx.Factory, ctx), - // Initialize toast stack (Phase 7: US5 - T055) - toastStack: components.NewToastStack(), - } - - // Run the program - p := tea.NewProgram(model, tea.WithAltScreen()) - finalModel, err := p.Run() - if err != nil { - return err - } - - // Check if the final model is a dashboardModel (type assertion) - // This allows us to check if the user quit cleanly - if _, ok := finalModel.(*dashboardModel); ok { - return nil - } - - return nil -} - -// ShouldLaunchDashboard determines if the dashboard should be launched. -// Checks for: no args, no help/json/version flags, ARC_NO_TUI != "1", stdout is TTY. -func ShouldLaunchDashboard(cmd *cobra.Command, args []string) bool { - // Check: no args - if len(args) != 0 { - return false - } - - // Check: no --help flag - if help, _ := cmd.Flags().GetBool("help"); help { - return false - } - - // Check: no --json flag (if it exists) - if jsonFlag := cmd.Flags().Lookup("json"); jsonFlag != nil { - if jsonVal, _ := cmd.Flags().GetBool("json"); jsonVal { - return false - } - } - - // Check: no --version flag (if it exists) - if versionFlag := cmd.Flags().Lookup("version"); versionFlag != nil { - if versionVal, _ := cmd.Flags().GetBool("version"); versionVal { - return false - } - } - - // Check: ARC_NO_TUI env != "1" - if noTUI := cmd.Flag("no-tui"); noTUI != nil { - if noTUIVal, _ := cmd.Flags().GetBool("no-tui"); noTUIVal { - return false - } - } - - // Check: stdout is TTY - // Use file descriptor 1 (stdout) - if !term.IsTerminal(1) { - return false - } - - // All checks passed - return true -} diff --git a/pkg/cli/dashboard/app_test.go b/pkg/cli/dashboard/app_test.go deleted file mode 100644 index 86cf8e5..0000000 --- a/pkg/cli/dashboard/app_test.go +++ /dev/null @@ -1,402 +0,0 @@ -package dashboard - -import ( - "os" - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/spf13/cobra" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/internal/app" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -// createTestModel creates a dashboardModel for testing -func createTestModel() *dashboardModel { - // Create minimal app context - ctx := &app.Context{} - - // Create factory with default profile - profileCtx := profiles.GetDefaultProfileContext() - factory := ui.NewComponentFactory(profileCtx, components.BorderTierNone) - - // Create header for tests (016-ui-layout-fix: Phase 3 - US1) - tabs := components.DashboardTabs() - header := components.NewHeader(factory, tabs, TabDashboard, 80) - - // Create footer for tests (016-ui-layout-fix: Phase 4 - US2) - controls := getDashboardControls() - footer := components.NewFooter(factory, controls, "v1.0.0-test", "abc1234", 80) - - return &dashboardModel{ - activeTab: TabDashboard, - width: 80, - height: 24, - factory: factory, - ctx: ctx, - keys: DefaultKeyMap(), - ready: false, - // Initialize header (016-ui-layout-fix: Phase 3 - US1) - header: header, - // Initialize footer (016-ui-layout-fix: Phase 4 - US2) - footer: footer, - footerVisible: true, - // Initialize views (Phase 4: US2) - dashboardView: newDashboardView(factory, ctx), - servicesView: newServicesView(factory, ctx), - workspaceView: newWorkspaceView(factory, ctx), - configView: newConfigView(factory, ctx), - // Initialize toast stack (Phase 7: US5) - toastStack: components.NewToastStack(), - } -} - -func TestDashboardModel_Init(t *testing.T) { - model := createTestModel() - - cmd := model.Init() - - // Init should return a command (batch of EnterAltScreen and WindowSize) - assert.NotNil(t, cmd, "Init() should return a command") -} - -func TestDashboardModel_UpdateTabCycle(t *testing.T) { - model := createTestModel() - - tests := []struct { - name string - key string - initialTab int - expectedTab int - expectedAction string - }{ - { - name: "Tab cycles forward from Dashboard", - key: "tab", - initialTab: TabDashboard, - expectedTab: TabServices, - }, - { - name: "Tab cycles forward from Services", - key: "tab", - initialTab: TabServices, - expectedTab: TabWorkspace, - }, - { - name: "Tab cycles forward from Workspace", - key: "tab", - initialTab: TabWorkspace, - expectedTab: TabConfig, - }, - { - name: "Tab wraps around from Config", - key: "tab", - initialTab: TabConfig, - expectedTab: TabDashboard, - }, - { - name: "Shift+Tab cycles backward from Services", - key: "shift+tab", - initialTab: TabServices, - expectedTab: TabDashboard, - }, - { - name: "Shift+Tab wraps around from Dashboard", - key: "shift+tab", - initialTab: TabDashboard, - expectedTab: TabConfig, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - model.activeTab = tt.initialTab - - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{}, Alt: false} - if tt.key == "tab" { - msg.Type = tea.KeyTab - } else if tt.key == "shift+tab" { - msg.Type = tea.KeyShiftTab - } - - updatedModel, _ := model.Update(msg) - m := updatedModel.(*dashboardModel) - - assert.Equal(t, tt.expectedTab, m.activeTab, "activeTab should update correctly") - }) - } -} - -func TestDashboardModel_UpdateQuitKeys(t *testing.T) { - model := createTestModel() - - tests := []struct { - name string - keyType tea.KeyType - keyRunes []rune - }{ - {name: "q quits", keyType: tea.KeyRunes, keyRunes: []rune{'q'}}, - {name: "ctrl+c quits", keyType: tea.KeyCtrlC, keyRunes: nil}, - {name: "esc quits", keyType: tea.KeyEsc, keyRunes: nil}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - msg := tea.KeyMsg{Type: tt.keyType, Runes: tt.keyRunes} - _, cmd := model.Update(msg) - - // Check that tea.Quit was returned - // We can't directly compare commands, but we can check it's not nil - assert.NotNil(t, cmd, "Should return tea.Quit command") - }) - } -} - -func TestDashboardModel_UpdateDirectTabJump(t *testing.T) { - model := createTestModel() - - tests := []struct { - key string - expectedTab int - }{ - {key: "1", expectedTab: TabDashboard}, - {key: "2", expectedTab: TabServices}, - {key: "3", expectedTab: TabWorkspace}, - {key: "4", expectedTab: TabConfig}, - } - - for _, tt := range tests { - t.Run("Jump to tab "+tt.key, func(t *testing.T) { - model.activeTab = TabDashboard // Reset - - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)} - updatedModel, _ := model.Update(msg) - m := updatedModel.(*dashboardModel) - - assert.Equal(t, tt.expectedTab, m.activeTab, "Should jump to correct tab") - }) - } -} - -func TestDashboardModel_UpdateWindowSize(t *testing.T) { - model := createTestModel() - - // Initially not ready - assert.False(t, model.ready, "Should not be ready before WindowSizeMsg") - - msg := tea.WindowSizeMsg{Width: 120, Height: 40} - updatedModel, _ := model.Update(msg) - m := updatedModel.(*dashboardModel) - - assert.Equal(t, 120, m.width, "Should update width") - assert.Equal(t, 40, m.height, "Should update height") - assert.True(t, m.ready, "Should be ready after WindowSizeMsg") -} - -func TestDashboardModel_UpdateHelpToggle(t *testing.T) { - model := createTestModel() - - // Initially help is hidden - assert.False(t, model.showHelp, "Help should be hidden initially") - - // Toggle on - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}} - updatedModel, _ := model.Update(msg) - m := updatedModel.(*dashboardModel) - assert.True(t, m.showHelp, "Help should be visible after first toggle") - - // Toggle off - updatedModel2, _ := m.Update(msg) - m2 := updatedModel2.(*dashboardModel) - assert.False(t, m2.showHelp, "Help should be hidden after second toggle") -} - -func TestDashboardModel_View(t *testing.T) { - model := createTestModel() - - // Before ready, should show initializing message - view := model.View() - assert.Contains(t, view, "Initializing", "Should show initializing before ready") - - // After ready, should show content - // Send WindowSizeMsg to initialize views - updatedModel, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) - model = updatedModel.(*dashboardModel) - view = model.View() - - assert.NotEmpty(t, view, "View should return non-empty string after ready") - // Check for dashboard components (Phase 5: US3) - assert.Contains(t, view, "System Dashboard", "Should show dashboard header") - assert.Contains(t, view, "System Info", "Should show system info card") - assert.Contains(t, view, "A.R.C. Runtime", "Should show runtime card") -} - -func TestDashboardModel_ViewDifferentTabs(t *testing.T) { - model := createTestModel() - - // Send WindowSizeMsg to initialize views - m, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) - model = m.(*dashboardModel) - - tests := []struct { - tab int - expectedContent string - }{ - {TabDashboard, "System Dashboard"}, // Phase 5: Updated to real dashboard content - {TabServices, "📦 Services"}, // Phase 6: Updated to real services browser - {TabWorkspace, "Workspace Configuration"}, // Phase 9: Updated to real workspace view - {TabConfig, "Config View"}, - } - - for _, tt := range tests { - t.Run("View for tab "+string(rune('0'+tt.tab)), func(t *testing.T) { - model.activeTab = tt.tab - view := model.View() - - assert.Contains(t, view, tt.expectedContent, "Should show correct placeholder for active tab") - }) - } -} - -func TestShouldLaunchDashboard(t *testing.T) { - tests := []struct { - name string - args []string - setupFlags func(*cobra.Command) - expectedResult bool - skipTTYCheck bool - }{ - { - name: "No args, no flags - should launch (if TTY)", - args: []string{}, - setupFlags: func(cmd *cobra.Command) {}, - expectedResult: true, - skipTTYCheck: false, - }, - { - name: "With args - should not launch", - args: []string{"some-arg"}, - setupFlags: func(cmd *cobra.Command) {}, - expectedResult: false, - skipTTYCheck: true, - }, - { - name: "With --help flag - should not launch", - args: []string{}, - setupFlags: func(cmd *cobra.Command) { - cmd.Flags().Bool("help", true, "") - }, - expectedResult: false, - skipTTYCheck: true, - }, - { - name: "With --json flag - should not launch", - args: []string{}, - setupFlags: func(cmd *cobra.Command) { - cmd.Flags().Bool("json", true, "") - }, - expectedResult: false, - skipTTYCheck: true, - }, - { - name: "With --version flag - should not launch", - args: []string{}, - setupFlags: func(cmd *cobra.Command) { - cmd.Flags().Bool("version", true, "") - }, - expectedResult: false, - skipTTYCheck: true, - }, - { - name: "With --no-tui flag - should not launch", - args: []string{}, - setupFlags: func(cmd *cobra.Command) { - cmd.Flags().Bool("no-tui", true, "") - }, - expectedResult: false, - skipTTYCheck: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cmd := &cobra.Command{} - tt.setupFlags(cmd) - - result := ShouldLaunchDashboard(cmd, tt.args) - - if tt.skipTTYCheck { - // For tests that should return false regardless of TTY - assert.Equal(t, tt.expectedResult, result, "ShouldLaunchDashboard result mismatch") - } else { - // For TTY-dependent tests, we just check the function runs without panic - // The actual result depends on whether test is run in a TTY - _ = result - } - }) - } -} - -func TestShouldLaunchDashboard_EnvVar(t *testing.T) { - // Note: This test would need to set environment variables - // For now, we just verify the function doesn't panic - cmd := &cobra.Command{} - result := ShouldLaunchDashboard(cmd, []string{}) - _ = result // Result depends on TTY status during test -} - -func TestLaunch(t *testing.T) { - // Skip this test if not in a TTY environment - if !isTestingInTTY() { - t.Skip("Skipping Launch test - not in TTY environment") - } - - // Create minimal app context for testing - ctx := &app.Context{} - profileCtx := profiles.GetDefaultProfileContext() - ctx.Factory = ui.NewComponentFactory(profileCtx, components.BorderTierNone) - - // We can't fully test Launch without mocking Bubble Tea, - // but we can verify it doesn't panic with a valid context - // In a real test, we'd use a headless program or mock - - // For now, just verify the function exists and accepts the context - require.NotNil(t, ctx.Factory, "Factory should be initialized") -} - -// isTestingInTTY checks if we're running in a TTY environment -func isTestingInTTY() bool { - return os.Getenv("TERM") != "" -} - -// TestLaunchRoutesNewUI verifies that Launch uses the new DashboardView by default. -// The function should return an error from Render (not panic) when not in a TTY. -func TestLaunchRoutesNewUI(t *testing.T) { - // Ensure legacy UI is NOT set - t.Setenv("ARC_USE_LEGACY_UI", "") - - ctx := &app.Context{} - profileCtx := profiles.GetDefaultProfileContext() - ctx.Factory = ui.NewComponentFactory(profileCtx, components.BorderTierNone) - - // launchNewDashboard calls engine.Render which will fail in non-TTY test env. - // We just verify it's called (not the legacy path) by checking it doesn't panic. - require.NotNil(t, ctx.Factory, "Factory should be initialized for new UI path") -} - -// TestLaunchLegacyUIFlag verifies that ARC_USE_LEGACY_UI=1 routes to the legacy dashboard. -func TestLaunchLegacyUIFlag(t *testing.T) { - t.Setenv("ARC_USE_LEGACY_UI", "1") - - ctx := &app.Context{} - profileCtx := profiles.GetDefaultProfileContext() - ctx.Factory = ui.NewComponentFactory(profileCtx, components.BorderTierNone) - - // With ARC_USE_LEGACY_UI=1, launchLegacyDashboard is called. - // We can't run the full TUI in tests, so just verify the context is valid. - require.NotNil(t, ctx.Factory, "Factory should be initialized for legacy UI path") -} diff --git a/pkg/cli/dashboard/config_view.go b/pkg/cli/dashboard/config_view.go deleted file mode 100644 index 52eb948..0000000 --- a/pkg/cli/dashboard/config_view.go +++ /dev/null @@ -1,66 +0,0 @@ -package dashboard - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/internal/app" - "github.com/arc-framework/arc-cli/pkg/ui" -) - -// configViewModel implements the Config tab view. -// -// configViewModel implements the Config tab view. -// This view will display inline configuration editor in Phase 10 (US8). -// - -type configViewModel struct { - width int - height int - factory ui.ComponentFactory - ctx *app.Context -} - -// newConfigView creates a new config view. -func newConfigView(factory ui.ComponentFactory, ctx *app.Context) *configViewModel { - return &configViewModel{ - factory: factory, - ctx: ctx, - } -} - -// Init initializes the config view. -func (v *configViewModel) Init() tea.Cmd { - return nil -} - -// Update handles messages for the config view. -// -//nolint:gocritic // Single-case switch is intentional; more cases will be added in future phases -func (v *configViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - return v, nil - } - - return v, nil -} - -// View renders the config view. -func (v *configViewModel) View() string { - if v.width == 0 { - return "Loading config..." - } - - // Placeholder content for Phase 4 - // Will be replaced with inline config editor in Phase 10 (US8) - content := lipgloss.NewStyle(). - Width(v.width). - Height(v.height). - Align(lipgloss.Center, lipgloss.Center). - Render("⚙️ Config View\n\n(Settings editor coming in Phase 10)") - - return content -} diff --git a/pkg/cli/dashboard/dashboard_view.go b/pkg/cli/dashboard/dashboard_view.go deleted file mode 100644 index 8337185..0000000 --- a/pkg/cli/dashboard/dashboard_view.go +++ /dev/null @@ -1,225 +0,0 @@ -package dashboard - -import ( - "fmt" - "runtime" - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/internal/app" - "github.com/arc-framework/arc-cli/internal/branding" - "github.com/arc-framework/arc-cli/internal/version" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" -) - -// dashboardViewModel implements the Dashboard tab view. -// This view displays live status cards with system information (Phase 5, US3). -type dashboardViewModel struct { - width int - height int - factory ui.ComponentFactory - ctx *app.Context - focusedCard int // Index of currently focused card (0-3) - sysInfo *branding.SystemInfo // Cached system info -} - -// newDashboardView creates a new dashboard view. -func newDashboardView(factory ui.ComponentFactory, ctx *app.Context) *dashboardViewModel { - // Collect system info once at initialization - sysInfo, _ := branding.CollectSystemInfo() - - return &dashboardViewModel{ - factory: factory, - ctx: ctx, - focusedCard: 0, // Start with first card focused - sysInfo: sysInfo, - } -} - -// Init initializes the dashboard view. -func (v *dashboardViewModel) Init() tea.Cmd { - return nil -} - -// Update handles messages for the dashboard view. -func (v *dashboardViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - return v, nil - - case tea.KeyMsg: - switch msg.String() { - case "up", "left": - // Navigate to previous card (wrap around) - v.focusedCard = (v.focusedCard - 1 + 4) % 4 - return v, nil - case "down", "right": - // Navigate to next card (wrap around) - v.focusedCard = (v.focusedCard + 1) % 4 - return v, nil - } - } - - return v, nil -} - -// View renders the dashboard view. -func (v *dashboardViewModel) View() string { - if v.width == 0 { - return "Loading dashboard..." - } - - // Build all cards with current focus state - cards := []string{ - v.buildSystemInfoCard(), - v.buildRuntimeCard(), - v.buildProfileCard(), - v.buildServicesCard(), - } - - // Use CardGrid for responsive layout - grid := components.NewCardGrid(cards, v.width) - content := grid.Render() - - // Add section header above the grid - header := components.NewSectionHeader("📊", "System Dashboard", v.getHeaderColor(), v.width). - WithSeparator(v.width). - WithSeparatorChar("─"). - Render() - - // Combine header + grid with spacing - return lipgloss.JoinVertical(lipgloss.Left, header, "", content) -} - -// buildSystemInfoCard creates the system information card. -func (v *dashboardViewModel) buildSystemInfoCard() string { - // Get border tier with fallback - borderTier := v.getBorderTier() - - if v.sysInfo == nil { - return components.NewCard("⚠️ System Info", "Unable to collect system information"). - SetFocused(v.focusedCard == 0). - SetBorderTier(borderTier). - Render() - } - - // Build content lines - lines := []string{ - fmt.Sprintf("OS: %s/%s", v.sysInfo.GoOS, v.sysInfo.GoArch), - fmt.Sprintf("Hostname: %s", v.sysInfo.Hostname), - fmt.Sprintf("CPU: %s (%d cores)", v.sysInfo.CPUModel, v.sysInfo.NumCPU), - } - - // Add memory if available - if v.sysInfo.MemoryTotal > 0 { - totalGB := float64(v.sysInfo.MemoryTotal) / (1024 * 1024 * 1024) - freeGB := float64(v.sysInfo.MemoryFree) / (1024 * 1024 * 1024) - lines = append(lines, fmt.Sprintf("Memory: %.1f GB total, %.1f GB free", totalGB, freeGB)) - } - - content := strings.Join(lines, "\n") - - return components.NewCard("💻 System Info", content). - SetFocused(v.focusedCard == 0). - SetBorderTier(borderTier). - Render() -} - -// buildRuntimeCard creates the Go runtime card. -func (v *dashboardViewModel) buildRuntimeCard() string { - lines := []string{ - fmt.Sprintf("Version: %s", version.Version), - fmt.Sprintf("Build Date: %s", version.BuildDate), - fmt.Sprintf("Commit: %s", version.GitCommit), - fmt.Sprintf("Go: %s", runtime.Version()), - } - - content := strings.Join(lines, "\n") - - return components.NewCard("🚀 A.R.C. Runtime", content). - SetFocused(v.focusedCard == 1). - SetBorderTier(v.getBorderTier()). - Render() -} - -// buildProfileCard creates the active profile card. -func (v *dashboardViewModel) buildProfileCard() string { - profileCtx := v.ctx.GetProfileContext() - if profileCtx == nil { - return components.NewCard("👤 Profile", "No profile loaded"). - SetFocused(v.focusedCard == 2). - SetBorderTier(v.getBorderTier()). - Render() - } - - profile := profileCtx.Profile() - theme := profileCtx.Theme() - - // Build tier names display - tierNames := "Unknown" - if len(profile.TierNames) == 3 { - tierNames = fmt.Sprintf("%s / %s / %s", profile.TierNames[0], profile.TierNames[1], profile.TierNames[2]) - } - - lines := []string{ - fmt.Sprintf("Name: %s", profile.Name), - fmt.Sprintf("Tiers: %s", tierNames), - fmt.Sprintf("Theme: %s", theme.Name), - } - - // Add description if available - if profile.Description != "" { - lines = append(lines, "", profile.Description) - } - - content := strings.Join(lines, "\n") - - return components.NewCard("👤 Active Profile", content). - SetFocused(v.focusedCard == 2). - SetBorderTier(v.getBorderTier()). - Render() -} - -// buildServicesCard creates the services catalog card. -func (v *dashboardViewModel) buildServicesCard() string { - // Get service count with fallback - serviceCount := 0 - if v.ctx.Catalog != nil { - serviceCount = v.ctx.Catalog.ServiceCount() - } - - var content string - if serviceCount == 0 { - content = "No services loaded\n\nRun 'arc services list' to view catalog" - } else { - content = fmt.Sprintf("Total Services: %d\n\nRun 'arc services list' for details", serviceCount) - } - - return components.NewCard("🔧 Services", content). - SetFocused(v.focusedCard == 3). - SetBorderTier(v.getBorderTier()). - Render() -} - -// getBorderTier returns the border tier with fallback. -func (v *dashboardViewModel) getBorderTier() components.BorderTier { - if v.ctx.SafeBorder != nil { - return v.ctx.SafeBorder.Tier() - } - return components.BorderTierNone // Fallback for tests -} - -// getHeaderColor returns the primary color for section headers. -func (v *dashboardViewModel) getHeaderColor() lipgloss.Color { - if profileCtx := v.ctx.GetProfileContext(); profileCtx != nil { - if theme := profileCtx.Theme(); theme != nil { - return lipgloss.Color(theme.Colors.Primary) - } - } - return lipgloss.Color("#00ADD8") // Fallback -} diff --git a/pkg/cli/dashboard/edge_case_test.go b/pkg/cli/dashboard/edge_case_test.go deleted file mode 100644 index b567b57..0000000 --- a/pkg/cli/dashboard/edge_case_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package dashboard - -import ( - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/stretchr/testify/assert" -) - -// TestNarrowTerminalHandling validates that dashboard renders at terminal width < 60 cols (T084). -// Per spec: minimal single-column, no borders, just content. -func TestNarrowTerminalHandling(t *testing.T) { - tests := []struct { - name string - width int - }{ - {"extremely_narrow_40cols", 40}, - {"narrow_50cols", 50}, - {"minimum_edge_59cols", 59}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create test context - ctx := createTestContext(t, "") - - // Create dashboard model - model := createTestDashboardModel(ctx) - - // Set narrow terminal size - updatedModel, _ := model.Update(tea.WindowSizeMsg{Width: tt.width, Height: 24}) - model = updatedModel.(*dashboardModel) - - // Render dashboard - output := model.View() - - // Verify output is not empty (graceful degradation) - assert.NotEmpty(t, output, "Dashboard should render even in narrow terminal (%d cols)", tt.width) - - // Verify no panic occurred (basic safety check) - assert.NotPanics(t, func() { - _ = model.View() - }, "Dashboard should not panic in narrow terminal (%d cols)", tt.width) - - t.Logf("Dashboard renders successfully at %d cols", tt.width) - }) - } -} - -// TestCorruptedProfileHandling validates enterprise fallback when profile is invalid (T085). -func TestCorruptedProfileHandling(t *testing.T) { - tests := []struct { - name string - profileName string - expectError bool - }{ - {"nonexistent_profile", "corrupted-profile-xyz", false}, // Should fallback to enterprise - {"empty_profile_name", "", false}, // Should fallback to enterprise - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create test context with potentially invalid profile - ctx := createTestContext(t, tt.profileName) - - // Create dashboard model - should not panic even with invalid profile - assert.NotPanics(t, func() { - model := createTestDashboardModel(ctx) - - // Initialize window size - updatedModel, _ := model.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) - model = updatedModel.(*dashboardModel) - - // Render - should use enterprise fallback theme - output := model.View() - assert.NotEmpty(t, output, "Dashboard should render with fallback theme") - }, "Dashboard should not panic with invalid profile '%s'", tt.profileName) - - t.Logf("Dashboard handles invalid profile '%s' gracefully", tt.profileName) - }) - } -} - -// TestNonTTYFallback validates static output in piped/non-TTY mode (T086). -// Note: This test validates that NO_COLOR environment variable is respected. -func TestNonTTYFallback(t *testing.T) { - // This test is primarily for documentation - the actual NO_COLOR handling - // is done by the Bubble Tea framework and terminal detection. - // We can verify that the dashboard model itself doesn't assume TTY. - - t.Run("dashboard_model_works_without_tty_assumptions", func(t *testing.T) { - // Create test context - ctx := createTestContext(t, "") - - // Create dashboard model (no TTY assumptions in constructor) - model := createTestDashboardModel(ctx) - - // Initialize - updatedModel, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) - model = updatedModel.(*dashboardModel) - - // Render output - output := model.View() - - // Verify we get some output (content rendering works) - assert.NotEmpty(t, output, "Dashboard should produce output even in non-TTY mode") - - // NOTE: Actual ANSI escape code stripping is handled by Bubble Tea's - // tea.WithoutRenderer() option when NO_COLOR=1 or stdout is not a TTY. - // The model itself just produces output. - - t.Log("Dashboard model produces output suitable for both TTY and piped modes") - }) -} diff --git a/pkg/cli/dashboard/keys.go b/pkg/cli/dashboard/keys.go deleted file mode 100644 index 4990791..0000000 --- a/pkg/cli/dashboard/keys.go +++ /dev/null @@ -1,116 +0,0 @@ -package dashboard - -import "github.com/charmbracelet/bubbles/key" - -// KeyMap defines all keybindings for the dashboard -type KeyMap struct { - // Global navigation - Tab key.Binding - ShiftTab key.Binding - Quit key.Binding - ForceQuit key.Binding - Escape key.Binding - Help key.Binding - - // Direct tab navigation - Tab1 key.Binding - Tab2 key.Binding - Tab3 key.Binding - Tab4 key.Binding - - // Arrow key navigation - Up key.Binding - Down key.Binding - Left key.Binding - Right key.Binding -} - -// DefaultKeyMap returns the default keybinding configuration -func DefaultKeyMap() KeyMap { - return KeyMap{ - // Global navigation - Tab: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "next tab"), - ), - ShiftTab: key.NewBinding( - key.WithKeys("shift+tab"), - key.WithHelp("shift+tab", "previous tab"), - ), - Quit: key.NewBinding( - key.WithKeys("q"), - key.WithHelp("q", "quit"), - ), - ForceQuit: key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "force quit"), - ), - Escape: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "back/cancel"), - ), - Help: key.NewBinding( - key.WithKeys("?"), - key.WithHelp("?", "toggle help"), - ), - - // Direct tab navigation - Tab1: key.NewBinding( - key.WithKeys("1"), - key.WithHelp("1", "dashboard"), - ), - Tab2: key.NewBinding( - key.WithKeys("2"), - key.WithHelp("2", "services"), - ), - Tab3: key.NewBinding( - key.WithKeys("3"), - key.WithHelp("3", "workspace"), - ), - Tab4: key.NewBinding( - key.WithKeys("4"), - key.WithHelp("4", "config"), - ), - - // Arrow key navigation - Up: key.NewBinding( - key.WithKeys("up", "k"), - key.WithHelp("↑/k", "up"), - ), - Down: key.NewBinding( - key.WithKeys("down", "j"), - key.WithHelp("↓/j", "down"), - ), - Left: key.NewBinding( - key.WithKeys("left", "h"), - key.WithHelp("←/h", "left"), - ), - Right: key.NewBinding( - key.WithKeys("right", "l"), - key.WithHelp("→/l", "right"), - ), - } -} - -// ShortHelp returns a slice of keybindings to be displayed in the short help view -func (k *KeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - k.Tab, - k.Up, - k.Down, - k.Help, - k.Quit, - } -} - -// FullHelp returns all keybindings grouped by category for the full help view -func (k *KeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{ - // First row: tab navigation - {k.Tab, k.ShiftTab, k.Tab1, k.Tab2, k.Tab3, k.Tab4}, - // Second row: arrow navigation - {k.Up, k.Down, k.Left, k.Right}, - // Third row: global actions - {k.Help, k.Escape, k.Quit, k.ForceQuit}, - } -} diff --git a/pkg/cli/dashboard/performance_test.go b/pkg/cli/dashboard/performance_test.go deleted file mode 100644 index a5cc78c..0000000 --- a/pkg/cli/dashboard/performance_test.go +++ /dev/null @@ -1,182 +0,0 @@ -package dashboard - -import ( - "runtime" - "testing" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/stretchr/testify/assert" - - "github.com/arc-framework/arc-cli/internal/app" - "github.com/arc-framework/arc-cli/pkg/catalog" - "github.com/arc-framework/arc-cli/pkg/ui/components" -) - -// TestDashboardStartupTime validates that dashboard first render is < 100ms (NF-001). -func TestDashboardStartupTime(t *testing.T) { - // Create test context with catalog - ctx := createTestContextWithCatalog(t) - - // Measure time from model creation to first View() render - start := time.Now() - - // Create dashboard model directly - model := createTestDashboardModel(ctx) - - // Set window size (simulates initialization) - updatedModel, _ := model.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) - model = updatedModel.(*dashboardModel) - - // First render - output := model.View() - elapsed := time.Since(start) - - // Verify output is not empty - assert.NotEmpty(t, output, "Dashboard should render content") - - // Performance target: < 100ms for first render - assert.Less(t, elapsed.Milliseconds(), int64(100), - "Dashboard startup should be < 100ms (NF-001), got %dms", elapsed.Milliseconds()) - - t.Logf("Dashboard startup time: %dms (target: <100ms)", elapsed.Milliseconds()) -} - -// TestTabSwitchLatency validates that tab switching is < 16ms (NF-002). -func TestTabSwitchLatency(t *testing.T) { - // Create test context - ctx := createTestContextWithCatalog(t) - - // Create dashboard model - model := createTestDashboardModel(ctx) - - // Initialize with window size - updatedModel, _ := model.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) - model = updatedModel.(*dashboardModel) - - // Warm up: render current tab - _ = model.View() - - // Measure tab switch (Update + View cycle) - start := time.Now() - - // Send right arrow key to switch tab - updatedModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) - model = updatedModel.(*dashboardModel) - - // Render new tab - output := model.View() - elapsed := time.Since(start) - - // Verify tab switched - assert.NotEmpty(t, output, "Tab should render after switch") - - // Performance target: < 16ms (60fps = 16.67ms per frame) - assert.Less(t, elapsed.Milliseconds(), int64(16), - "Tab switch latency should be < 16ms (NF-002), got %dms", elapsed.Milliseconds()) - - t.Logf("Tab switch latency: %dms (target: <16ms)", elapsed.Milliseconds()) -} - -// TestMemoryFootprint validates that dashboard memory usage is < 20MB (NF-005). -func TestMemoryFootprint(t *testing.T) { - // Force GC to get clean baseline - runtime.GC() - - var memBefore runtime.MemStats - runtime.ReadMemStats(&memBefore) - - // Create test context with full catalog - ctx := createTestContextWithCatalog(t) - - // Create dashboard model - model := createTestDashboardModel(ctx) - - // Initialize with window size - updatedModel, _ := model.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) - model = updatedModel.(*dashboardModel) - - // Render all tabs to load all data - for i := 0; i < 4; i++ { - _ = model.View() - updatedModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) - model = updatedModel.(*dashboardModel) - } - - // Force GC and measure - runtime.GC() - - var memAfter runtime.MemStats - runtime.ReadMemStats(&memAfter) - - // Calculate allocated memory (in MB) - // Use signed arithmetic to handle cases where GC ran between measurements - allocatedBytes := int64(memAfter.Alloc) - int64(memBefore.Alloc) - if allocatedBytes < 0 { - allocatedBytes = 0 - } - allocatedMB := float64(allocatedBytes) / 1024 / 1024 - - // Performance target: < 20MB - assert.Less(t, allocatedMB, float64(20), - "Dashboard memory footprint should be < 20MB (NF-005), got %.2fMB", allocatedMB) - - t.Logf("Dashboard memory footprint: %.2fMB (target: <20MB)", allocatedMB) -} - -// createTestDashboardModel creates a dashboard model for testing. -func createTestDashboardModel(ctx *app.Context) *dashboardModel { - factory := createTestFactory() - - // Create header for tests (016-ui-layout-fix: Phase 3 - US1) - tabs := components.DashboardTabs() - header := components.NewHeader(factory, tabs, TabDashboard, 80) - - // Create footer for tests (016-ui-layout-fix: Phase 4 - US2) - controls := getDashboardControls() - footer := components.NewFooter(factory, controls, "v1.0.0-test", "abc1234", 80) - - return &dashboardModel{ - activeTab: TabDashboard, - width: 80, - height: 24, - factory: factory, - ctx: ctx, - keys: DefaultKeyMap(), - header: header, - footer: footer, - footerVisible: true, - dashboardView: newDashboardView(factory, ctx), - servicesView: newServicesView(factory, ctx), - workspaceView: newWorkspaceView(factory, ctx), - configView: newConfigView(factory, ctx), - toastStack: components.NewToastStack(), - } -} - -// createTestContextWithCatalog creates a test context with a populated catalog. -func createTestContextWithCatalog(t *testing.T) *app.Context { - t.Helper() - - // Create mock catalog with services - mockCatalog := &mockCatalog{ - services: []*catalog.Service{ - { - Codename: "postgres", - Technology: "PostgreSQL 15", - Role: catalog.RoleData, - Description: "Primary database", - }, - { - Codename: "redis", - Technology: "Redis 7", - Role: catalog.RoleInfrastructure, - Description: "Cache layer", - }, - }, - } - - ctx := createTestContext(t, "") - ctx.Catalog = mockCatalog - return ctx -} diff --git a/pkg/cli/dashboard/services_view.go b/pkg/cli/dashboard/services_view.go deleted file mode 100644 index e5188de..0000000 --- a/pkg/cli/dashboard/services_view.go +++ /dev/null @@ -1,494 +0,0 @@ -package dashboard - -import ( - "fmt" - "io" - "strings" - - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/internal/app" - "github.com/arc-framework/arc-cli/pkg/catalog" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" -) - -// Role emojis for visual identification -const ( - emojiInfrastructure = "🏗️" - emojiData = "💾" - emojiAI = "🤖" - emojiObservability = "📊" -) - -// servicesViewModel implements the Services tab view. -// Phase 6 (US4): Split-pane service browser with list + detail view. -type servicesViewModel struct { - width int - height int - factory ui.ComponentFactory - ctx *app.Context - - // Phase 6 additions - list list.Model - viewport viewport.Model - focused components.PaneFocus - services []*catalog.Service - filtering bool - ready bool // Track if list/viewport are initialized -} - -// newServicesView creates a new services view. -func newServicesView(factory ui.ComponentFactory, ctx *app.Context) *servicesViewModel { - return &servicesViewModel{ - factory: factory, - ctx: ctx, - focused: components.FocusLeft, // Start with list focused - } -} - -// Init initializes the services view. -func (v *servicesViewModel) Init() tea.Cmd { - return nil -} - -// Update handles messages for the services view. -func (v *servicesViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - - // Initialize components on first window size message - if !v.ready { - v.initializeComponents() - } - - // Update component sizes based on split ratio (30/70) - v.updateComponentSizes() - return v, nil - - case tea.KeyMsg: - // Handle view-specific keys - switch msg.String() { - case keyTab: - // Toggle focus between panes - if v.focused == components.FocusLeft { - v.focused = components.FocusRight - } else { - v.focused = components.FocusLeft - } - return v, nil - - case "/": - // Enter filter mode (only if list is focused) - if v.focused == components.FocusLeft && v.ready { - v.filtering = true - return v, nil - } - return v, nil - - case "esc": - // Exit filter mode - if v.filtering { - v.filtering = false - return v, nil - } - return v, nil - } - - // Delegate key handling to focused component - if v.ready { - return v.handleComponentKeys(msg) - } - } - - return v, nil -} - -// View renders the services view. -func (v *servicesViewModel) View() string { - if v.width == 0 || !v.ready { - return "Loading services..." - } - - // Build split pane with list and detail - leftContent := v.list.View() - rightContent := v.viewport.View() - - // Get border tier with fallback - borderTier := v.getBorderTier() - - // Create split pane - split := components.NewSplitPane(leftContent, rightContent, v.width). - SetRatio(0.3). - SetFocus(v.focused). - SetBorderTier(borderTier) - - // Get theme colors if available - if profileCtx := v.ctx.GetProfileContext(); profileCtx != nil { - theme := profileCtx.Theme() - focused := lipgloss.Color(theme.Colors.Primary) - unfocused := lipgloss.Color(theme.Colors.Muted) - split.SetColors(focused, unfocused) - } - - return split.Render() -} - -// initializeComponents initializes the list and viewport models. -func (v *servicesViewModel) initializeComponents() { - // Load services from catalog - v.loadServices() - - // Initialize list model - items := make([]list.Item, len(v.services)) - for i, svc := range v.services { - items[i] = serviceItem{service: svc} - } - - // Create delegate with theme colors - delegate := newServiceItemDelegate( - v.getPrimaryColor(), - v.getForegroundColor(), - v.getSuccessColor(), - ) - v.list = list.New(items, delegate, 0, 0) - v.list.Title = "📦 Services" - v.list.SetShowStatusBar(true) - v.list.SetShowHelp(false) // We show help in parent - v.list.SetFilteringEnabled(true) - v.list.DisableQuitKeybindings() - - // Initialize viewport model - v.viewport = viewport.New(0, 0) - v.viewport.YPosition = 0 - - // Set initial detail content if we have services - if len(v.services) > 0 { - v.viewport.SetContent(v.renderServiceDetail(v.services[0])) - } else { - v.viewport.SetContent("No services available") - } - - v.ready = true -} - -// loadServices loads services from the catalog. -func (v *servicesViewModel) loadServices() { - if v.ctx.Catalog == nil { - v.services = []*catalog.Service{} - return - } - - v.services = v.ctx.Catalog.AllServices() -} - -// updateComponentSizes updates list and viewport sizes based on current dimensions. -func (v *servicesViewModel) updateComponentSizes() { - if !v.ready { - return - } - - // Calculate split widths (30% left, 70% right) - // Account for borders (2 chars each side = 4 total per pane) and gap (2 chars) - leftWidth := int(float64(v.width) * 0.3) - rightWidth := v.width - leftWidth - 2 // 2 for gap - - // Adjust for borders - listWidth := leftWidth - 4 - viewportWidth := rightWidth - 4 - - // Height should account for borders (2 lines top+bottom) - listHeight := v.height - 4 - viewportHeight := v.height - 4 - - // Ensure minimum sizes - if listWidth < 20 { - listWidth = 20 - } - if viewportWidth < 30 { - viewportWidth = 30 - } - if listHeight < 5 { - listHeight = 5 - } - if viewportHeight < 5 { - viewportHeight = 5 - } - - v.list.SetSize(listWidth, listHeight) - v.viewport.Width = viewportWidth - v.viewport.Height = viewportHeight -} - -// handleComponentKeys delegates key handling to the focused component. -func (v *servicesViewModel) handleComponentKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - if v.focused == components.FocusLeft { - // Update list - previousIndex := v.list.Index() - v.list, cmd = v.list.Update(msg) - - // If selection changed, update detail view - if v.list.Index() != previousIndex { - v.updateDetailView() - } - } else { - // Update viewport - v.viewport, cmd = v.viewport.Update(msg) - } - - return v, cmd -} - -// updateDetailView updates the viewport with the currently selected service. -func (v *servicesViewModel) updateDetailView() { - selectedItem := v.list.SelectedItem() - if selectedItem == nil { - v.viewport.SetContent("No service selected") - return - } - - if svcItem, ok := selectedItem.(serviceItem); ok { - v.viewport.SetContent(v.renderServiceDetail(svcItem.service)) - v.viewport.GotoTop() - } -} - -// renderServiceDetail formats service details for the viewport. -func (v *servicesViewModel) renderServiceDetail(svc *catalog.Service) string { - if svc == nil { - return "No service selected" - } - - var b strings.Builder - - // Header with emoji and codename - roleEmoji := getRoleEmoji(svc.Role) - b.WriteString(lipgloss.NewStyle().Bold(true).Render(fmt.Sprintf("%s %s", roleEmoji, svc.Codename))) - b.WriteString("\n\n") - - // Get muted color from theme - mutedColor := v.getMutedColor() - labelStyle := lipgloss.NewStyle().Foreground(mutedColor) - - // Technology - b.WriteString(labelStyle.Render("Technology: ")) - b.WriteString(svc.Technology) - b.WriteString("\n") - - // Role - b.WriteString(labelStyle.Render("Role: ")) - b.WriteString(string(svc.Role)) - b.WriteString("\n\n") - - // Description - if svc.Description != "" { - b.WriteString(labelStyle.Render("Description:")) - b.WriteString("\n") - b.WriteString(svc.Description) - b.WriteString("\n\n") - } - - // Image - if svc.Image != "" { - b.WriteString(labelStyle.Render("Image:")) - b.WriteString("\n") - b.WriteString(svc.Image) - b.WriteString("\n\n") - } - - // Ports - if len(svc.Ports) > 0 { - b.WriteString(labelStyle.Render("Ports:")) - b.WriteString("\n") - for _, port := range svc.Ports { - protocol := port.GetProtocol() - b.WriteString(fmt.Sprintf(" %d:%d (%s)", port.Host, port.Container, protocol)) - if port.Description != "" { - b.WriteString(fmt.Sprintf(" - %s", port.Description)) - } - b.WriteString("\n") - } - b.WriteString("\n") - } - - // Dependencies - deps := svc.GetAllDependencyCodenames() - if len(deps) > 0 { - b.WriteString(labelStyle.Render("Dependencies:")) - b.WriteString("\n") - for _, dep := range deps { - b.WriteString(fmt.Sprintf(" • %s\n", dep)) - } - b.WriteString("\n") - } - - // Environment variables - if len(svc.Environment) > 0 { - b.WriteString(labelStyle.Render("Environment:")) - b.WriteString("\n") - for _, env := range svc.Environment { - required := "" - if env.Required { - required = " (required)" - } - b.WriteString(fmt.Sprintf(" %s%s\n", env.Name, required)) - if env.Description != "" { - b.WriteString(fmt.Sprintf(" %s\n", env.Description)) - } - } - } - - return b.String() -} - -// getBorderTier returns the border tier with fallback. -func (v *servicesViewModel) getBorderTier() components.BorderTier { - if v.ctx.SafeBorder != nil { - return v.ctx.SafeBorder.Tier() - } - return components.BorderTierNone // Fallback for tests -} - -// getMutedColor returns the muted color from the current theme. -func (v *servicesViewModel) getMutedColor() lipgloss.Color { - if profileCtx := v.ctx.GetProfileContext(); profileCtx != nil { - if theme := profileCtx.Theme(); theme != nil { - return theme.Colors.MutedColor() - } - } - return lipgloss.Color("#888888") // Fallback -} - -// getSuccessColor returns the success color from the current theme. -func (v *servicesViewModel) getSuccessColor() lipgloss.Color { - if profileCtx := v.ctx.GetProfileContext(); profileCtx != nil { - if theme := profileCtx.Theme(); theme != nil { - return theme.Colors.SuccessColor() - } - } - return lipgloss.Color("#00E091") // Fallback -} - -// getPrimaryColor returns the primary color from the current theme. -func (v *servicesViewModel) getPrimaryColor() lipgloss.Color { - if profileCtx := v.ctx.GetProfileContext(); profileCtx != nil { - if theme := profileCtx.Theme(); theme != nil { - return theme.Colors.PrimaryColor() - } - } - return lipgloss.Color("#00ADD8") // Fallback -} - -// getForegroundColor returns the foreground color from the current theme. -func (v *servicesViewModel) getForegroundColor() lipgloss.Color { - if profileCtx := v.ctx.GetProfileContext(); profileCtx != nil { - if theme := profileCtx.Theme(); theme != nil { - return theme.Colors.ForegroundColor() - } - } - return lipgloss.Color("#F8F8F2") // Fallback -} - -// getRoleEmoji returns the emoji for a given service role. -func getRoleEmoji(role catalog.ServiceRole) string { - switch role { - case catalog.RoleInfrastructure: - return emojiInfrastructure - case catalog.RoleData: - return emojiData - case catalog.RoleAI: - return emojiAI - case catalog.RoleObservability: - return emojiObservability - default: - return "❓" - } -} - -// serviceItem wraps a Service for use with bubbles/list. -type serviceItem struct { - service *catalog.Service -} - -// FilterValue implements list.Item interface. -func (i serviceItem) FilterValue() string { - if i.service == nil { - return "" - } - // Allow filtering by codename, technology, or role - return fmt.Sprintf("%s %s %s", i.service.Codename, i.service.Technology, i.service.Role) -} - -// serviceItemDelegate renders service items in the list. -type serviceItemDelegate struct { - primaryColor lipgloss.Color - foregroundColor lipgloss.Color - successColor lipgloss.Color -} - -// newServiceItemDelegate creates a new service item delegate with theme colors. -func newServiceItemDelegate(primary, foreground, success lipgloss.Color) serviceItemDelegate { - return serviceItemDelegate{ - primaryColor: primary, - foregroundColor: foreground, - successColor: success, - } -} - -// Height implements list.ItemDelegate interface. -func (d serviceItemDelegate) Height() int { - return 1 -} - -// Spacing implements list.ItemDelegate interface. -func (d serviceItemDelegate) Spacing() int { - return 0 -} - -// Update implements list.ItemDelegate interface. -func (d serviceItemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { - return nil -} - -// Render implements list.ItemDelegate interface. -// -//nolint:gocritic // list.Model is large but matches bubbles/list interface signature -func (d serviceItemDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { - svcItem, ok := item.(serviceItem) - if !ok { - return - } - - svc := svcItem.service - if svc == nil { - return - } - - // Build item string: emoji + codename + status dot - roleEmoji := getRoleEmoji(svc.Role) - statusDot := lipgloss.NewStyle().Foreground(d.successColor).Render("●") - itemStr := fmt.Sprintf("%s %s %s", roleEmoji, svc.Codename, statusDot) - - // Style based on selection - var style lipgloss.Style - if index == m.Index() { - // Selected item - use foreground on primary background - style = lipgloss.NewStyle(). - Foreground(d.foregroundColor). - Background(d.primaryColor). - Bold(true) - } else { - // Normal item - use muted foreground - style = lipgloss.NewStyle(). - Foreground(d.foregroundColor) - } - - _, _ = fmt.Fprint(w, style.Render(itemStr)) // Ignore write error (bubbles/list handles I/O) -} diff --git a/pkg/cli/dashboard/services_view_test.go b/pkg/cli/dashboard/services_view_test.go deleted file mode 100644 index 6887ec9..0000000 --- a/pkg/cli/dashboard/services_view_test.go +++ /dev/null @@ -1,260 +0,0 @@ -package dashboard - -import ( - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/stretchr/testify/assert" - - "github.com/arc-framework/arc-cli/internal/app" - "github.com/arc-framework/arc-cli/pkg/catalog" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -// mockCatalog is a mock implementation of catalog.Catalog for testing. -type mockCatalog struct { - services []*catalog.Service -} - -func (m *mockCatalog) GetService(codename string) (*catalog.Service, error) { - for _, svc := range m.services { - if svc.Codename == codename { - return svc, nil - } - } - return nil, catalog.ErrServiceNotFound -} - -func (m *mockCatalog) ListServices(filter catalog.ServiceFilter) ([]*catalog.Service, error) { - return m.services, nil -} - -func (m *mockCatalog) AllServices() []*catalog.Service { - return m.services -} - -func (m *mockCatalog) ServiceCount() int { - return len(m.services) -} - -func (m *mockCatalog) HasService(codename string) bool { - _, err := m.GetService(codename) - return err == nil -} - -func (m *mockCatalog) SuggestSimilar(input string, maxSuggestions int) []string { - return []string{} -} - -// createTestServicesView creates a services view for testing. -func createTestServicesView(catalogServices []*catalog.Service) *servicesViewModel { - mockCat := &mockCatalog{services: catalogServices} - - ctx := &app.Context{ - Catalog: mockCat, - } - - profileCtx := profiles.GetDefaultProfileContext() - factory := ui.NewComponentFactory(profileCtx, components.BorderTierNone) - - return newServicesView(factory, ctx) -} - -func TestServicesViewModel_Init(t *testing.T) { - view := createTestServicesView([]*catalog.Service{ - { - Codename: "heimdall", - Technology: "Traefik", - Role: catalog.RoleInfrastructure, - Description: "API Gateway", - }, - }) - - cmd := view.Init() - assert.Nil(t, cmd, "Init should return nil command") -} - -func TestServicesViewModel_Update_WindowSize(t *testing.T) { - view := createTestServicesView([]*catalog.Service{ - { - Codename: "heimdall", - Technology: "Traefik", - Role: catalog.RoleInfrastructure, - Description: "API Gateway", - }, - }) - - // Send window size message - msg := tea.WindowSizeMsg{Width: 100, Height: 30} - updatedModel, cmd := view.Update(msg) - - assert.Nil(t, cmd, "WindowSizeMsg should not return a command") - assert.NotNil(t, updatedModel, "Update should return a model") - - // Verify dimensions were updated - updatedView := updatedModel.(*servicesViewModel) - assert.Equal(t, 100, updatedView.width, "Width should be updated") - assert.Equal(t, 30, updatedView.height, "Height should be updated") - assert.True(t, updatedView.ready, "View should be ready after window size") -} - -func TestServicesViewModel_Update_TabKey(t *testing.T) { - view := createTestServicesView([]*catalog.Service{ - { - Codename: "heimdall", - Technology: "Traefik", - Role: catalog.RoleInfrastructure, - Description: "API Gateway", - }, - }) - - // Initialize with window size - view.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) - - // Verify initial focus is left - assert.Equal(t, components.FocusLeft, view.focused, "Initial focus should be left") - - // Press tab - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("tab")} - updatedModel, _ := view.Update(msg) - updatedView := updatedModel.(*servicesViewModel) - - assert.Equal(t, components.FocusRight, updatedView.focused, "Focus should toggle to right") - - // Press tab again - updatedModel, _ = updatedView.Update(msg) - updatedView = updatedModel.(*servicesViewModel) - - assert.Equal(t, components.FocusLeft, updatedView.focused, "Focus should toggle back to left") -} - -func TestServicesViewModel_View_WithoutWindowSize(t *testing.T) { - view := createTestServicesView([]*catalog.Service{ - { - Codename: "heimdall", - Technology: "Traefik", - Role: catalog.RoleInfrastructure, - Description: "API Gateway", - }, - }) - - // View without window size should show loading message - output := view.View() - assert.Contains(t, output, "Loading services", "Should show loading message before window size") -} - -func TestServicesViewModel_View_WithServices(t *testing.T) { - view := createTestServicesView([]*catalog.Service{ - { - Codename: "heimdall", - Technology: "Traefik", - Role: catalog.RoleInfrastructure, - Description: "API Gateway", - }, - { - Codename: "oracle", - Technology: "PostgreSQL", - Role: catalog.RoleData, - Description: "Primary database", - }, - }) - - // Initialize with window size - view.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) - - // View should render split pane with services - output := view.View() - - // Should contain service list title - assert.Contains(t, output, "📦 Services", "Should show service list title") - - // Should contain service names (may be styled) - assert.Contains(t, output, "heimdall", "Should show heimdall service") - assert.Contains(t, output, "oracle", "Should show oracle service") -} - -func TestServicesViewModel_WithNilCatalog(t *testing.T) { - ctx := &app.Context{ - Catalog: nil, // Nil catalog for defensive coding test - } - - profileCtx := profiles.GetDefaultProfileContext() - factory := ui.NewComponentFactory(profileCtx, components.BorderTierNone) - - view := newServicesView(factory, ctx) - - // Should not panic with nil catalog - view.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) - - // View should render without errors - output := view.View() - assert.NotEmpty(t, output, "Should render even with nil catalog") -} - -func TestGetRoleEmoji(t *testing.T) { - tests := []struct { - role catalog.ServiceRole - expected string - }{ - {catalog.RoleInfrastructure, emojiInfrastructure}, - {catalog.RoleData, emojiData}, - {catalog.RoleAI, emojiAI}, - {catalog.RoleObservability, emojiObservability}, - {"UnknownRole", "❓"}, - } - - for _, tt := range tests { - t.Run(string(tt.role), func(t *testing.T) { - result := getRoleEmoji(tt.role) - assert.Equal(t, tt.expected, result, "Should return correct emoji for role") - }) - } -} - -func TestServiceItem_FilterValue(t *testing.T) { - svc := &catalog.Service{ - Codename: "heimdall", - Technology: "Traefik", - Role: catalog.RoleInfrastructure, - } - - item := serviceItem{service: svc} - filterValue := item.FilterValue() - - // Filter value should contain codename, technology, and role for fuzzy search - assert.Contains(t, filterValue, "heimdall", "Filter value should contain codename") - assert.Contains(t, filterValue, "Traefik", "Filter value should contain technology") - assert.Contains(t, filterValue, "Infrastructure", "Filter value should contain role") -} - -func TestServiceItem_FilterValue_NilService(t *testing.T) { - item := serviceItem{service: nil} - filterValue := item.FilterValue() - - assert.Equal(t, "", filterValue, "Filter value should be empty for nil service") -} - -func TestServiceItemDelegate_Height(t *testing.T) { - delegate := newServiceItemDelegate( - lipgloss.Color("#00ADD8"), // primary - lipgloss.Color("#F8F8F2"), // foreground - lipgloss.Color("#00E091"), // success - ) - height := delegate.Height() - - assert.Equal(t, 1, height, "Item height should be 1 line") -} - -func TestServiceItemDelegate_Spacing(t *testing.T) { - delegate := newServiceItemDelegate( - lipgloss.Color("#00ADD8"), // primary - lipgloss.Color("#F8F8F2"), // foreground - lipgloss.Color("#00E091"), // success - ) - spacing := delegate.Spacing() - - assert.Equal(t, 0, spacing, "Item spacing should be 0") -} diff --git a/pkg/cli/dashboard/workspace_view.go b/pkg/cli/dashboard/workspace_view.go deleted file mode 100644 index 84e1e8f..0000000 --- a/pkg/cli/dashboard/workspace_view.go +++ /dev/null @@ -1,222 +0,0 @@ -package dashboard - -import ( - "fmt" - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/internal/app" - "github.com/arc-framework/arc-cli/pkg/store" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" -) - -// workspaceViewModel implements the Workspace tab view. -// Phase 9 (US7): Displays workspace configuration, tier info with profile-specific names, -// and recent operations. -type workspaceViewModel struct { - width int - height int - factory ui.ComponentFactory - ctx *app.Context -} - -// newWorkspaceView creates a new workspace view. -func newWorkspaceView(factory ui.ComponentFactory, ctx *app.Context) *workspaceViewModel { - return &workspaceViewModel{ - factory: factory, - ctx: ctx, - } -} - -// Init initializes the workspace view. -func (v *workspaceViewModel) Init() tea.Cmd { - return nil -} - -// Update handles messages for the workspace view. -// -//nolint:gocritic // Single-case switch is intentional; more cases will be added in future phases -func (v *workspaceViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - return v, nil - } - - return v, nil -} - -// View renders the workspace view. -// Phase 9 (US7): Shows workspace configuration, tier information with profile-specific names, -// and recent operations using themed Card and SectionHeader components. -func (v *workspaceViewModel) View() string { - if v.width == 0 { - return "Loading workspace..." - } - - // Build all sections - sections := []string{ - v.buildWorkspaceSection(), - v.buildTierSection(), - } - - // Section: Recent Operations (if available) - if v.ctx.Store != nil { - sections = append(sections, v.buildRecentOperationsSection()) - } - - // Join sections with spacing - return lipgloss.JoinVertical(lipgloss.Left, sections...) -} - -// buildWorkspaceSection creates the workspace configuration section. -func (v *workspaceViewModel) buildWorkspaceSection() string { - header := components.NewSectionHeader("📁", "Workspace Configuration", v.getHeaderColor(), v.width). - WithSeparator(v.width). - WithSeparatorChar("─"). - Render() - - // Try to load current workspace state - // For now, show placeholder since workspace state loading is not yet integrated - // In a full implementation, we would load workspace state from Store - workspaceCard := v.buildNoWorkspaceCard() - - return lipgloss.JoinVertical(lipgloss.Left, header, "", workspaceCard) -} - -// buildNoWorkspaceCard creates a card when no workspace is loaded. -func (v *workspaceViewModel) buildNoWorkspaceCard() string { - content := "No active workspace\n\n" + - "Initialize a workspace:\n" + - " arc workspace init [path]" - - return components.NewCard("📁 Workspace Status", content). - SetBorderTier(v.getBorderTier()). - Render() -} - -// buildTierSection creates the tier information section with profile-specific names. -func (v *workspaceViewModel) buildTierSection() string { - profileCtx := v.ctx.GetProfileContext() - if profileCtx == nil { - return "" - } - - header := components.NewSectionHeader("⭐", "Profile Tiers", v.getHeaderColor(), v.width). - WithSeparator(v.width). - WithSeparatorChar("─"). - Render() - - // Build tier information card - profile := profileCtx.Profile() - tierNames := profileCtx.TierNames() - - lines := []string{ - fmt.Sprintf("Profile: %s", profile.Name), - "", - "Available Tiers:", - } - - // Add each tier with its profile-specific name - for i, tierName := range tierNames { - lines = append(lines, fmt.Sprintf(" Tier %d: %s", i, tierName)) - } - - // Add tier descriptions if available - if profile.Description != "" { - lines = append(lines, "", profile.Description) - } - - content := strings.Join(lines, "\n") - tierCard := components.NewCard("⭐ Tier Information", content). - SetBorderTier(v.getBorderTier()). - Render() - - return lipgloss.JoinVertical(lipgloss.Left, header, "", tierCard) -} - -// buildRecentOperationsSection creates the recent operations section. -func (v *workspaceViewModel) buildRecentOperationsSection() string { - header := components.NewSectionHeader("📋", "Recent Operations", v.getHeaderColor(), v.width). - WithSeparator(v.width). - WithSeparatorChar("─"). - Render() - - operationsCard := v.loadOperationsCard() - - return lipgloss.JoinVertical(lipgloss.Left, header, "", operationsCard) -} - -// loadOperationsCard attempts to load operations from history or shows empty state. -func (v *workspaceViewModel) loadOperationsCard() string { - if v.ctx.Store == nil || v.ctx.Store.History == nil { - return v.buildNoOperationsCard() - } - - history, err := v.ctx.Store.History.ReadHistory() - if err != nil || history == nil || len(history.Operations) == 0 { - return v.buildNoOperationsCard() - } - - // Take last 5 operations - recentCount := 5 - if len(history.Operations) < recentCount { - recentCount = len(history.Operations) - } - recentOps := history.Operations[len(history.Operations)-recentCount:] - return v.buildOperationsCard(recentOps) -} - -// buildOperationsCard creates a card displaying recent operations. -func (v *workspaceViewModel) buildOperationsCard(operations []store.Operation) string { - lines := []string{} - - for i := range operations { - // Format each operation with timestamp (using index to avoid copying) - op := &operations[i] - timestamp := op.Timestamp.Format("15:04:05") - command := op.Command - status := op.Status - lines = append(lines, fmt.Sprintf(" • [%s] %s (%s)", timestamp, command, status)) - } - - content := strings.Join(lines, "\n") - return components.NewCard("📋 Recent History", content). - SetBorderTier(v.getBorderTier()). - Render() -} - -// buildNoOperationsCard creates a card when no operations are available. -func (v *workspaceViewModel) buildNoOperationsCard() string { - content := "No operations recorded yet\n\n" + - "Operations will appear here after:\n" + - " • Workspace initialization\n" + - " • File generation\n" + - " • Manifest updates" - - return components.NewCard("📋 Recent History", content). - SetBorderTier(v.getBorderTier()). - Render() -} - -// getBorderTier returns the border tier with fallback. -func (v *workspaceViewModel) getBorderTier() components.BorderTier { - if v.ctx.SafeBorder != nil { - return v.ctx.SafeBorder.Tier() - } - return components.BorderTierNone // Fallback for tests -} - -// getHeaderColor returns the primary color for section headers. -func (v *workspaceViewModel) getHeaderColor() lipgloss.Color { - if profileCtx := v.ctx.GetProfileContext(); profileCtx != nil { - if theme := profileCtx.Theme(); theme != nil { - return lipgloss.Color(theme.Colors.Primary) - } - } - return lipgloss.Color("#00ADD8") // Fallback -} diff --git a/pkg/cli/dashboard/workspace_view_test.go b/pkg/cli/dashboard/workspace_view_test.go deleted file mode 100644 index 5fb8309..0000000 --- a/pkg/cli/dashboard/workspace_view_test.go +++ /dev/null @@ -1,365 +0,0 @@ -package dashboard - -import ( - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/internal/app" - "github.com/arc-framework/arc-cli/pkg/store" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// TestWorkspaceViewModel_View tests the basic View rendering. -func TestWorkspaceViewModel_View(t *testing.T) { - factory := createTestFactory() - ctx := createTestContext(t, "jedi") - - vm := newWorkspaceView(factory, ctx) - vm.width = 80 - vm.height = 24 - - output := vm.View() - - // Verify output contains expected sections - assert.Contains(t, output, "Workspace Configuration", "should show workspace section header") - assert.Contains(t, output, "Profile Tiers", "should show tier section header") - assert.Contains(t, output, "No active workspace", "should show no workspace message") -} - -// TestWorkspaceViewModel_ViewBeforeWindowSize tests rendering before window size is set. -func TestWorkspaceViewModel_ViewBeforeWindowSize(t *testing.T) { - factory := createTestFactory() - ctx := createTestContext(t, "jedi") - - vm := newWorkspaceView(factory, ctx) - // Don't set width/height - - output := vm.View() - - assert.Equal(t, "Loading workspace...", output, "should show loading message when width is 0") -} - -// TestWorkspaceViewModel_TierNames tests tier name resolution. -func TestWorkspaceViewModel_TierNames(t *testing.T) { - factory := createTestFactory() - ctx := createTestContext(t, "test") - - vm := newWorkspaceView(factory, ctx) - vm.width = 80 - vm.height = 24 - - output := vm.View() - - // Verify tier section appears - assert.Contains(t, output, "Profile Tiers", "should show tier section header") - assert.Contains(t, output, "Available Tiers:", "should show tiers label") - - // Verify tier labels appear (actual names depend on loaded profile) - assert.Contains(t, output, "Tier 0:", "should show tier 0 label") - assert.Contains(t, output, "Tier 1:", "should show tier 1 label") - assert.Contains(t, output, "Tier 2:", "should show tier 2 label") -} - -// TestWorkspaceViewModel_NilProfileContext tests behavior when ProfileContext is nil. -func TestWorkspaceViewModel_NilProfileContext(t *testing.T) { - factory := createTestFactory() - ctx := createTestContextNilProfile(t) - - vm := newWorkspaceView(factory, ctx) - vm.width = 80 - vm.height = 24 - - output := vm.View() - - // Should still show workspace section - assert.Contains(t, output, "Workspace Configuration", "should show workspace section") - - // Note: GetProfileContext() will load default profile even if nil, - // so tier section may still appear. Just verify no crash/panic occurs. -} - -// TestWorkspaceViewModel_Update tests message handling. -func TestWorkspaceViewModel_Update(t *testing.T) { - factory := createTestFactory() - ctx := createTestContext(t, "jedi") - - vm := newWorkspaceView(factory, ctx) - - // Test WindowSizeMsg - msg := tea.WindowSizeMsg{Width: 100, Height: 30} - model, cmd := vm.Update(msg) - - require.NotNil(t, model, "Update should return a model") - assert.Nil(t, cmd, "Update should not return a command for WindowSizeMsg") - - // Verify dimensions were updated - updatedVM, ok := model.(*workspaceViewModel) - require.True(t, ok, "returned model should be *workspaceViewModel") - assert.Equal(t, 100, updatedVM.width, "width should be updated") - assert.Equal(t, 30, updatedVM.height, "height should be updated") -} - -// TestWorkspaceViewModel_Init tests initialization. -func TestWorkspaceViewModel_Init(t *testing.T) { - factory := createTestFactory() - ctx := createTestContext(t, "jedi") - - vm := newWorkspaceView(factory, ctx) - cmd := vm.Init() - - assert.Nil(t, cmd, "Init should return nil command") -} - -// TestWorkspaceViewModel_WithStore tests behavior with a Store. -func TestWorkspaceViewModel_WithStore(t *testing.T) { - factory := createTestFactory() - ctx := createTestContextWithStore(t, "jedi") - - vm := newWorkspaceView(factory, ctx) - vm.width = 80 - vm.height = 24 - - output := vm.View() - - // Should still render without errors - assert.Contains(t, output, "Workspace Configuration", "should show workspace section") - assert.Contains(t, output, "Recent Operations", "should show operations section") -} - -// TestWorkspaceViewModel_SectionHeaders tests that section headers are properly rendered. -func TestWorkspaceViewModel_SectionHeaders(t *testing.T) { - factory := createTestFactory() - ctx := createTestContext(t, "saiyan") - - vm := newWorkspaceView(factory, ctx) - vm.width = 80 - vm.height = 24 - - output := vm.View() - - // Verify all expected section headers - assert.Contains(t, output, "📁", "should show workspace icon") - assert.Contains(t, output, "⭐", "should show tier icon") - assert.Contains(t, output, "Workspace Configuration", "should show workspace header") - assert.Contains(t, output, "Profile Tiers", "should show tier header") -} - -// Helper: createTestFactory creates a ComponentFactory for testing. -func createTestFactory() ui.ComponentFactory { - // Create a simple test profile - profile := &profiles.Profile{ - ID: "test", - Name: "Test", - Description: "Test profile", - TierNames: []string{"Tier0", "Tier1", "Tier2"}, - ThemeID: "default", - } - - // Create a minimal theme for testing - theme := &themes.Theme{ - Name: "Test Theme", - Colors: themes.ColorSet{ - Primary: "#00ADD8", - Secondary: "#5E81AC", - }, - } - - profileCtx, _ := profiles.NewProfileContext(profile, theme) - - return ui.NewComponentFactory(profileCtx, components.BorderTierNone) -} - -// Helper: createTestContext creates a minimal app.Context for testing. -func createTestContext(t *testing.T, profileID string) *app.Context { - t.Helper() - - // Create a test profile with the given ID - profile := &profiles.Profile{ - ID: profileID, - Name: capitalizeFirst(profileID), - Description: capitalizeFirst(profileID) + " profile for testing", - TierNames: getTierNamesForProfile(profileID), - ThemeID: "default", - } - - theme := &themes.Theme{ - Name: "Test Theme", - Colors: themes.ColorSet{ - Primary: "#00ADD8", - Secondary: "#5E81AC", - }, - } - - profileCtx, _ := profiles.NewProfileContext(profile, theme) - factory := ui.NewComponentFactory(profileCtx, components.BorderTierNone) - - // Create minimal context - ctx := &app.Context{ - Factory: factory, - SafeBorder: components.NewSafeBorder(), - } - - return ctx -} - -// Helper: createTestContextNilProfile creates an app.Context without ProfileContext. -func createTestContextNilProfile(t *testing.T) *app.Context { - t.Helper() - - factory := ui.NewComponentFactory(nil, components.BorderTierNone) - - return &app.Context{ - Factory: factory, - SafeBorder: components.NewSafeBorder(), - } -} - -// Helper: getTierNamesForProfile returns tier names for test profiles. -func getTierNamesForProfile(profileID string) []string { - tierMap := map[string][]string{ - "jedi": {"Padawan", "Knight", "Master"}, - "saiyan": {"Super Saiyan", "Super Saiyan Blue", "Ultra Instinct"}, - "enterprise": {"Starter", "Pro", "Ultra"}, - "pokemon": {"Squirtle", "Wartortle", "Blastoise"}, - } - - if tiers, ok := tierMap[profileID]; ok { - return tiers - } - - return []string{"Tier 0", "Tier 1", "Tier 2"} -} - -// Helper: capitalizeFirst capitalizes the first letter of a string. -func capitalizeFirst(s string) string { - if len(s) == 0 { - return s - } - return strings.ToUpper(s[:1]) + s[1:] -} - -// Helper: createTestContextWithStore creates an app.Context with a Store. -func createTestContextWithStore(t *testing.T, profileID string) *app.Context { - t.Helper() - - ctx := createTestContext(t, profileID) - - // Create mock store - mockResources := &mockResourceRepository{} - mockHistory := &mockHistoryRepository{} - ctx.Store = store.NewStore(mockResources, mockHistory) - - return ctx -} - -// Mock implementations for store repositories - -type mockResourceRepository struct{} - -func (m *mockResourceRepository) ReadState() (*store.State, error) { - return &store.State{ - Version: 1, - Resources: []store.Resource{}, - }, nil -} - -func (m *mockResourceRepository) WriteState(state *store.State) error { - return nil -} - -func (m *mockResourceRepository) BackupState() error { - return nil -} - -func (m *mockResourceRepository) ClearState() error { - return nil -} - -type mockHistoryRepository struct{} - -func (m *mockHistoryRepository) ReadHistory() (*store.History, error) { - return &store.History{ - Version: 1, - Operations: []store.Operation{}, - }, nil -} - -func (m *mockHistoryRepository) WriteHistory(history *store.History) error { - return nil -} - -func (m *mockHistoryRepository) AddOperation(operation *store.Operation) error { - return nil -} - -func (m *mockHistoryRepository) ClearHistory() error { - return nil -} - -// TestWorkspaceViewModel_OperationsDisplay tests operations card rendering. -func TestWorkspaceViewModel_OperationsDisplay(t *testing.T) { - factory := createTestFactory() - ctx := createTestContextWithStore(t, "jedi") - - vm := newWorkspaceView(factory, ctx) - vm.width = 80 - vm.height = 24 - - output := vm.View() - - // Should show operations section - assert.Contains(t, output, "Recent Operations", "should show operations section header") - - // Since we have an empty history, should show "No operations recorded yet" - assert.Contains(t, output, "No operations recorded yet", "should show no operations message") -} - -// TestWorkspaceViewModel_CardContent tests that cards have proper content. -func TestWorkspaceViewModel_CardContent(t *testing.T) { - factory := createTestFactory() - ctx := createTestContext(t, "test") - - vm := newWorkspaceView(factory, ctx) - vm.width = 80 - vm.height = 24 - - output := vm.View() - - // Verify workspace card content - assert.Contains(t, output, "No active workspace", "should show no workspace message") - assert.Contains(t, output, "arc workspace init", "should show init command hint") - - // Verify tier card content - assert.Contains(t, output, "Available Tiers:", "should show tiers label") - // Profile name will be from loaded profile context -} - -// TestWorkspaceViewModel_MultilineContent tests multi-section rendering. -func TestWorkspaceViewModel_MultilineContent(t *testing.T) { - factory := createTestFactory() - ctx := createTestContext(t, "enterprise") - - vm := newWorkspaceView(factory, ctx) - vm.width = 120 - vm.height = 40 - - output := vm.View() - - // Count sections - should have at least 3 main sections - lines := strings.Split(output, "\n") - assert.Greater(t, len(lines), 10, "should have multiple lines of output") - - // Verify sections are separated - workspaceIdx := strings.Index(output, "Workspace Configuration") - tierIdx := strings.Index(output, "Profile Tiers") - - assert.Greater(t, workspaceIdx, 0, "workspace section should be present") - assert.Greater(t, tierIdx, workspaceIdx, "tier section should come after workspace section") -} diff --git a/pkg/cli/errors/.gitkeep b/pkg/cli/errors/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/pkg/cli/errors/arc_error.go b/pkg/cli/errors/arc_error.go deleted file mode 100644 index b3e965a..0000000 --- a/pkg/cli/errors/arc_error.go +++ /dev/null @@ -1,111 +0,0 @@ -// Package errors provides rich error types for the A.R.C. CLI. -package errors - -import "fmt" - -// Severity represents the severity level of an error. -type Severity int - -const ( - // SeverityError represents a critical error (red, ✗ symbol). - SeverityError Severity = iota - // SeverityWarning represents a warning (orange, ⚠ symbol). - SeverityWarning - // SeverityInfo represents an informational message (blue, ℹ symbol). - SeverityInfo -) - -// String returns the string representation of the severity level. -func (s Severity) String() string { - switch s { - case SeverityError: - return "error" - case SeverityWarning: - return "warning" - case SeverityInfo: - return "info" - default: - return "unknown" - } -} - -// ArcError is a rich error type that wraps Go errors with additional context, -// hints, severity levels, and exit codes. -// -// It implements the error interface and supports errors.Is/As via Unwrap. -// -// Example usage: -// -// err := errors.New("Failed to load workspace config", originalErr). -// WithHint("Ensure arc.yaml exists in the current directory"). -// WithSeverity(errors.SeverityError). -// WithExitCode(1) -type ArcError struct { - // Err is the wrapped original error. - Err error - // Context is a human-readable context describing what failed. - // Example: "Failed to load workspace config" - Context string - // Hint is an actionable suggestion for the user. - // Example: "Ensure arc.yaml exists" - Hint string - // Severity indicates the error severity level. - Severity Severity - // ExitCode is the process exit code. - // Common codes: 1 (general error), 2 (usage error), 127 (command not found) - ExitCode int -} - -// New creates a new ArcError with the given context and wrapped error. -// The wrapped error must not be nil. -// -// Default values: -// - Severity: SeverityError -// - ExitCode: 1 -func New(context string, err error) *ArcError { - if err == nil { - panic("ArcError.Err cannot be nil") - } - - return &ArcError{ - Err: err, - Context: context, - Severity: SeverityError, - ExitCode: 1, - } -} - -// WithHint returns a new ArcError with the given hint added. -// This is a fluent builder method. -func (e *ArcError) WithHint(hint string) *ArcError { - e.Hint = hint - return e -} - -// WithSeverity returns a new ArcError with the given severity level. -// This is a fluent builder method. -func (e *ArcError) WithSeverity(severity Severity) *ArcError { - e.Severity = severity - return e -} - -// WithExitCode returns a new ArcError with the given exit code. -// This is a fluent builder method. -func (e *ArcError) WithExitCode(code int) *ArcError { - e.ExitCode = code - return e -} - -// Error implements the error interface. -// Returns a formatted string with context and the wrapped error message. -func (e *ArcError) Error() string { - if e.Context != "" { - return fmt.Sprintf("%s: %v", e.Context, e.Err) - } - return e.Err.Error() -} - -// Unwrap returns the wrapped error, enabling errors.Is and errors.As support. -func (e *ArcError) Unwrap() error { - return e.Err -} diff --git a/pkg/cli/errors/arc_error_test.go b/pkg/cli/errors/arc_error_test.go deleted file mode 100644 index dc55934..0000000 --- a/pkg/cli/errors/arc_error_test.go +++ /dev/null @@ -1,470 +0,0 @@ -package errors - -import ( - "errors" - "fmt" - "testing" -) - -func TestSeverity_String(t *testing.T) { - tests := []struct { - name string - severity Severity - want string - }{ - { - name: "error severity", - severity: SeverityError, - want: "error", - }, - { - name: "warning severity", - severity: SeverityWarning, - want: "warning", - }, - { - name: "info severity", - severity: SeverityInfo, - want: "info", - }, - { - name: "unknown severity", - severity: Severity(999), - want: "unknown", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tt.severity.String() - if got != tt.want { - t.Errorf("Severity.String() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestNew(t *testing.T) { - baseErr := fmt.Errorf("original error") - - tests := []struct { - name string - context string - err error - wantContext string - wantErr error - wantPanic bool - }{ - { - name: "valid error with context", - context: "Failed to load config", - err: baseErr, - wantContext: "Failed to load config", - wantErr: baseErr, - wantPanic: false, - }, - { - name: "valid error with empty context", - context: "", - err: baseErr, - wantContext: "", - wantErr: baseErr, - wantPanic: false, - }, - { - name: "nil error panics", - context: "Some context", - err: nil, - wantPanic: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.wantPanic { - defer func() { - if r := recover(); r == nil { - t.Errorf("New() did not panic with nil error") - } - }() - New(tt.context, tt.err) - return - } - - got := New(tt.context, tt.err) - - if got.Context != tt.wantContext { - t.Errorf("New().Context = %v, want %v", got.Context, tt.wantContext) - } - if !errors.Is(got.Err, tt.wantErr) { - t.Errorf("New().Err = %v, want %v", got.Err, tt.wantErr) - } - if got.Severity != SeverityError { - t.Errorf("New().Severity = %v, want %v", got.Severity, SeverityError) - } - if got.ExitCode != 1 { - t.Errorf("New().ExitCode = %v, want %v", got.ExitCode, 1) - } - }) - } -} - -func TestArcError_BuilderPattern(t *testing.T) { - baseErr := fmt.Errorf("base error") - - tests := []struct { - name string - builder func() *ArcError - wantContext string - wantHint string - wantSeverity Severity - wantExitCode int - }{ - { - name: "full builder chain", - builder: func() *ArcError { - return New("Failed to load workspace config", baseErr). - WithHint("Ensure arc.yaml exists"). - WithSeverity(SeverityError). - WithExitCode(2) - }, - wantContext: "Failed to load workspace config", - wantHint: "Ensure arc.yaml exists", - wantSeverity: SeverityError, - wantExitCode: 2, - }, - { - name: "partial builder - hint only", - builder: func() *ArcError { - return New("Connection failed", baseErr). - WithHint("Check network connectivity") - }, - wantContext: "Connection failed", - wantHint: "Check network connectivity", - wantSeverity: SeverityError, - wantExitCode: 1, - }, - { - name: "partial builder - severity only", - builder: func() *ArcError { - return New("Deprecated option used", baseErr). - WithSeverity(SeverityWarning) - }, - wantContext: "Deprecated option used", - wantHint: "", - wantSeverity: SeverityWarning, - wantExitCode: 1, - }, - { - name: "partial builder - exit code only", - builder: func() *ArcError { - return New("Command not found", baseErr). - WithExitCode(127) - }, - wantContext: "Command not found", - wantHint: "", - wantSeverity: SeverityError, - wantExitCode: 127, - }, - { - name: "warning with hint", - builder: func() *ArcError { - return New("Profile not found", baseErr). - WithSeverity(SeverityWarning). - WithHint("Using default profile instead") - }, - wantContext: "Profile not found", - wantHint: "Using default profile instead", - wantSeverity: SeverityWarning, - wantExitCode: 1, - }, - { - name: "info with custom exit code", - builder: func() *ArcError { - return New("Update available", baseErr). - WithSeverity(SeverityInfo). - WithExitCode(0) - }, - wantContext: "Update available", - wantHint: "", - wantSeverity: SeverityInfo, - wantExitCode: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tt.builder() - - if got.Context != tt.wantContext { - t.Errorf("Context = %v, want %v", got.Context, tt.wantContext) - } - if got.Hint != tt.wantHint { - t.Errorf("Hint = %v, want %v", got.Hint, tt.wantHint) - } - if got.Severity != tt.wantSeverity { - t.Errorf("Severity = %v, want %v", got.Severity, tt.wantSeverity) - } - if got.ExitCode != tt.wantExitCode { - t.Errorf("ExitCode = %v, want %v", got.ExitCode, tt.wantExitCode) - } - if !errors.Is(got.Err, baseErr) { - t.Errorf("Err = %v, want %v", got.Err, baseErr) - } - }) - } -} - -func TestArcError_Error(t *testing.T) { - baseErr := fmt.Errorf("base error message") - - tests := []struct { - name string - arcErr *ArcError - want string - }{ - { - name: "with context", - arcErr: &ArcError{ - Err: baseErr, - Context: "Failed to load config", - }, - want: "Failed to load config: base error message", - }, - { - name: "without context", - arcErr: &ArcError{ - Err: baseErr, - Context: "", - }, - want: "base error message", - }, - { - name: "complex error chain", - arcErr: &ArcError{ - Err: fmt.Errorf("wrapped: %w", baseErr), - Context: "Operation failed", - }, - want: "Operation failed: wrapped: base error message", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tt.arcErr.Error() - if got != tt.want { - t.Errorf("Error() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestArcError_Unwrap(t *testing.T) { - baseErr := fmt.Errorf("base error") - wrappedErr := fmt.Errorf("wrapped: %w", baseErr) - - tests := []struct { - name string - arcErr *ArcError - want error - }{ - { - name: "unwrap returns original error", - arcErr: &ArcError{ - Err: baseErr, - Context: "Some context", - }, - want: baseErr, - }, - { - name: "unwrap with wrapped error", - arcErr: &ArcError{ - Err: wrappedErr, - Context: "Another context", - }, - want: wrappedErr, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tt.arcErr.Unwrap() - if !errors.Is(got, tt.want) { - t.Errorf("Unwrap() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestArcError_ErrorsIs(t *testing.T) { - var ( - errNotFound = fmt.Errorf("not found") - errPermission = fmt.Errorf("permission denied") - ) - - tests := []struct { - name string - arcErr *ArcError - target error - want bool - }{ - { - name: "errors.Is matches wrapped error", - arcErr: &ArcError{ - Err: errNotFound, - Context: "Failed to load file", - }, - target: errNotFound, - want: true, - }, - { - name: "errors.Is does not match different error", - arcErr: &ArcError{ - Err: errNotFound, - Context: "Failed to load file", - }, - target: errPermission, - want: false, - }, - { - name: "errors.Is with error chain", - arcErr: &ArcError{ - Err: fmt.Errorf("wrapped: %w", errNotFound), - Context: "Operation failed", - }, - target: errNotFound, - want: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := errors.Is(tt.arcErr, tt.target) - if got != tt.want { - t.Errorf("errors.Is() = %v, want %v", got, tt.want) - } - }) - } -} - -type customError struct { - code int -} - -func (e *customError) Error() string { - return fmt.Sprintf("custom error: %d", e.code) -} - -func TestArcError_ErrorsAs(t *testing.T) { - baseErr := &customError{code: 42} - wrappedErr := fmt.Errorf("wrapped: %w", baseErr) - - arcErr := &ArcError{ - Err: wrappedErr, - Context: "Failed operation", - } - - var target *customError - if !errors.As(arcErr, &target) { - t.Error("errors.As() should find customError in chain") - } - - if target.code != 42 { - t.Errorf("errors.As() target.code = %v, want %v", target.code, 42) - } -} - -func TestArcError_NilSafety(t *testing.T) { - tests := []struct { - name string - arcErr *ArcError - wantErr string - }{ - { - name: "nil hint is safe", - arcErr: New("context", fmt.Errorf("error")). - WithHint(""), - wantErr: "context: error", - }, - { - name: "nil context is safe", - arcErr: New("", fmt.Errorf("error")), - wantErr: "error", - }, - { - name: "multiple builder calls are safe", - arcErr: New("context", fmt.Errorf("error")). - WithHint("hint1"). - WithHint("hint2"). - WithSeverity(SeverityWarning). - WithSeverity(SeverityError), - wantErr: "context: error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tt.arcErr.Error() - if got != tt.wantErr { - t.Errorf("Error() = %v, want %v", got, tt.wantErr) - } - }) - } -} - -func TestArcError_RealWorldUsage(t *testing.T) { - t.Run("file not found scenario", func(t *testing.T) { - err := fmt.Errorf("open arc.yaml: no such file or directory") - arcErr := New("Failed to load workspace config", err). - WithHint("Ensure arc.yaml exists in the current directory"). - WithExitCode(1) - - if arcErr.Severity != SeverityError { - t.Errorf("Severity = %v, want %v", arcErr.Severity, SeverityError) - } - if arcErr.ExitCode != 1 { - t.Errorf("ExitCode = %v, want %v", arcErr.ExitCode, 1) - } - if arcErr.Hint != "Ensure arc.yaml exists in the current directory" { - t.Errorf("Hint = %v, want 'Ensure arc.yaml exists in the current directory'", arcErr.Hint) - } - }) - - t.Run("connection refused scenario", func(t *testing.T) { - err := fmt.Errorf("dial tcp 127.0.0.1:8080: connection refused") - arcErr := New("Failed to connect to service", err). - WithHint("Ensure the service is running: arc workspace run"). - WithExitCode(1) - - wantMsg := "Failed to connect to service: dial tcp 127.0.0.1:8080: connection refused" - if arcErr.Error() != wantMsg { - t.Errorf("Error() = %v, want %v", arcErr.Error(), wantMsg) - } - }) - - t.Run("warning scenario", func(t *testing.T) { - err := fmt.Errorf("profile 'custom' not found") - arcErr := New("Profile not found", err). - WithSeverity(SeverityWarning). - WithHint("Using enterprise profile as fallback"). - WithExitCode(0) - - if arcErr.Severity != SeverityWarning { - t.Errorf("Severity = %v, want %v", arcErr.Severity, SeverityWarning) - } - if arcErr.ExitCode != 0 { - t.Errorf("ExitCode = %v, want %v", arcErr.ExitCode, 0) - } - }) - - t.Run("command not found scenario", func(t *testing.T) { - err := fmt.Errorf("executable file not found in $PATH") - arcErr := New("Command not found", err). - WithHint("Ensure the command is installed and in your PATH"). - WithExitCode(127) - - if arcErr.ExitCode != 127 { - t.Errorf("ExitCode = %v, want %v", arcErr.ExitCode, 127) - } - }) -} diff --git a/pkg/cli/errors/hints.go b/pkg/cli/errors/hints.go deleted file mode 100644 index d3babce..0000000 --- a/pkg/cli/errors/hints.go +++ /dev/null @@ -1,68 +0,0 @@ -package errors - -import "regexp" - -// DefaultHint is the fallback hint when no patterns match. -const DefaultHint = "Run with --verbose for more details" - -// HintPattern represents a pattern-to-hint mapping for error messages. -type HintPattern struct { - Pattern *regexp.Regexp - Hint string -} - -// HintRegistry matches error messages to actionable hints via regex patterns. -type HintRegistry struct { - patterns []HintPattern -} - -// NewHintRegistry creates a new HintRegistry pre-loaded with default patterns. -// Default patterns are ordered by specificity (first match wins). -func NewHintRegistry() *HintRegistry { - registry := &HintRegistry{ - patterns: make([]HintPattern, 0, 10), - } - - // Register default patterns from specs/015-ui-refactor/data-model.md - // Order matters - first match wins, so more specific patterns come first - registry.Register(`yaml:\s*(unmarshal|line|.*error)|yaml parse`, "Check YAML syntax in your configuration file") - registry.Register(`permission denied`, "Check file permissions or try with sudo") - registry.Register(`connection refused`, "Ensure the service is running: arc workspace run") - registry.Register(`profile not found`, "Run arc config list-profiles to see available profiles") - registry.Register(`no such file or directory`, "Verify the file path exists") - registry.Register(`address already in use`, "Another process is using this port. Check with lsof -i") - registry.Register(`context deadline exceeded|timeout`, "Operation timed out. Check network connectivity") - registry.Register(`docker.*not found|Cannot connect to the Docker`, "Ensure Docker is installed and running") - registry.Register(`theme.*not found`, "Run arc config list-themes to see available themes") - registry.Register(`workspace.*not initialized`, "Run arc workspace init to create a workspace") - - return registry -} - -// Register adds a new pattern-to-hint mapping. -// Pattern is a regex string. Panics if pattern fails to compile (programming error). -func (r *HintRegistry) Register(pattern, hint string) { - compiled := regexp.MustCompile(pattern) - r.patterns = append(r.patterns, HintPattern{ - Pattern: compiled, - Hint: hint, - }) -} - -// Match returns the first matching hint for the given error message. -// Returns the default fallback if no patterns match or if errorMessage is empty. -func (r *HintRegistry) Match(errorMessage string) string { - if errorMessage == "" { - return DefaultHint - } - - // First match wins - for _, p := range r.patterns { - if p.Pattern.MatchString(errorMessage) { - return p.Hint - } - } - - // Default fallback - return DefaultHint -} diff --git a/pkg/cli/errors/hints_test.go b/pkg/cli/errors/hints_test.go deleted file mode 100644 index 55e7c84..0000000 --- a/pkg/cli/errors/hints_test.go +++ /dev/null @@ -1,318 +0,0 @@ -package errors - -import ( - "testing" -) - -func TestHintRegistry_Match(t *testing.T) { - tests := []struct { - name string - errorMessage string - expectedHint string - }{ - { - name: "permission denied", - errorMessage: "open /etc/hosts: permission denied", - expectedHint: "Check file permissions or try with sudo", - }, - { - name: "connection refused", - errorMessage: "dial tcp 127.0.0.1:8080: connection refused", - expectedHint: "Ensure the service is running: arc workspace run", - }, - { - name: "profile not found", - errorMessage: "profile not found: saiyan", - expectedHint: "Run arc config list-profiles to see available profiles", - }, - { - name: "yaml unmarshal error with colon", - errorMessage: "yaml: unmarshal errors:\n line 1: cannot unmarshal", - expectedHint: "Check YAML syntax in your configuration file", - }, - { - name: "yaml error generic", - errorMessage: "yaml: line 5: mapping values are not allowed in this context", - expectedHint: "Check YAML syntax in your configuration file", - }, - { - name: "no such file or directory", - errorMessage: "stat arc.yaml: no such file or directory", - expectedHint: "Verify the file path exists", - }, - { - name: "address already in use", - errorMessage: "listen tcp :8080: bind: address already in use", - expectedHint: "Another process is using this port. Check with lsof -i", - }, - { - name: "context deadline exceeded", - errorMessage: "context deadline exceeded", - expectedHint: "Operation timed out. Check network connectivity", - }, - { - name: "timeout variation", - errorMessage: "operation timeout after 30s", - expectedHint: "Operation timed out. Check network connectivity", - }, - { - name: "docker not found", - errorMessage: "exec: \"docker\": executable file not found in $PATH", - expectedHint: "Ensure Docker is installed and running", - }, - { - name: "cannot connect to docker", - errorMessage: "Cannot connect to the Docker daemon. Is the docker daemon running?", - expectedHint: "Ensure Docker is installed and running", - }, - { - name: "theme not found", - errorMessage: "theme not found: fire", - expectedHint: "Run arc config list-themes to see available themes", - }, - { - name: "workspace not initialized", - errorMessage: "workspace not initialized in /Users/test/project", - expectedHint: "Run arc workspace init to create a workspace", - }, - { - name: "empty message returns default", - errorMessage: "", - expectedHint: "Run with --verbose for more details", - }, - { - name: "unmatched error returns default", - errorMessage: "unknown error occurred", - expectedHint: "Run with --verbose for more details", - }, - } - - registry := NewHintRegistry() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := registry.Match(tt.errorMessage) - if got != tt.expectedHint { - t.Errorf("Match() = %q, want %q", got, tt.expectedHint) - } - }) - } -} - -func TestHintRegistry_FirstMatchWins(t *testing.T) { - registry := &HintRegistry{ - patterns: make([]HintPattern, 0), - } - - // Register two overlapping patterns - registry.Register(`permission`, "First hint") - registry.Register(`permission denied`, "Second hint") - - // First pattern should win - got := registry.Match("permission denied to access file") - expected := "First hint" - - if got != expected { - t.Errorf("FirstMatchWins: got %q, want %q (first pattern should win)", got, expected) - } -} - -func TestHintRegistry_MultiPatternError(t *testing.T) { - registry := NewHintRegistry() - - // Error message that could match multiple patterns - errorMsg := "yaml: unmarshal error: permission denied" - - // Should match the first pattern encountered (yaml is registered first in default order) - got := registry.Match(errorMsg) - expected := "Check YAML syntax in your configuration file" - - if got != expected { - t.Errorf("MultiPatternError: got %q, want %q (should match first registered pattern)", got, expected) - } -} - -func TestHintRegistry_CaseInsensitive(t *testing.T) { - registry := NewHintRegistry() - - tests := []struct { - name string - errorMessage string - shouldMatch bool - }{ - { - name: "lowercase permission denied", - errorMessage: "permission denied", - shouldMatch: true, - }, - { - name: "uppercase permission denied", - errorMessage: "PERMISSION DENIED", - shouldMatch: false, // regex is case-sensitive by default - }, - { - name: "mixed case permission denied", - errorMessage: "Permission Denied", - shouldMatch: false, // regex is case-sensitive by default - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := registry.Match(tt.errorMessage) - isDefault := got == "Run with --verbose for more details" - - if tt.shouldMatch && isDefault { - t.Errorf("Expected to match a pattern, but got default hint") - } - if !tt.shouldMatch && !isDefault { - t.Errorf("Expected default hint, but got: %q", got) - } - }) - } -} - -func TestHintRegistry_Register(t *testing.T) { - registry := &HintRegistry{ - patterns: make([]HintPattern, 0), - } - - // Test normal registration - registry.Register(`test pattern`, "Test hint") - - if len(registry.patterns) != 1 { - t.Errorf("Register: expected 1 pattern, got %d", len(registry.patterns)) - } - - if registry.patterns[0].Hint != "Test hint" { - t.Errorf("Register: expected hint 'Test hint', got %q", registry.patterns[0].Hint) - } - - // Test that pattern was compiled - if registry.patterns[0].Pattern == nil { - t.Error("Register: pattern was not compiled") - } -} - -func TestHintRegistry_RegisterInvalidPattern(t *testing.T) { - registry := &HintRegistry{ - patterns: make([]HintPattern, 0), - } - - // Test that invalid regex panics - defer func() { - if r := recover(); r == nil { - t.Error("Register with invalid regex should panic") - } - }() - - registry.Register(`[invalid(regex`, "This should panic") -} - -func TestNewHintRegistry_DefaultPatterns(t *testing.T) { - registry := NewHintRegistry() - - // Verify that default patterns are loaded - if len(registry.patterns) != 10 { - t.Errorf("NewHintRegistry: expected 10 default patterns, got %d", len(registry.patterns)) - } - - // Verify some key patterns exist and are in order - expectedHints := []string{ - "Check YAML syntax in your configuration file", - "Check file permissions or try with sudo", - "Ensure the service is running: arc workspace run", - "Run arc config list-profiles to see available profiles", - "Verify the file path exists", - "Another process is using this port. Check with lsof -i", - "Operation timed out. Check network connectivity", - "Ensure Docker is installed and running", - "Run arc config list-themes to see available themes", - "Run arc workspace init to create a workspace", - } - - for i, expected := range expectedHints { - if i >= len(registry.patterns) { - t.Fatalf("NewHintRegistry: not enough patterns loaded (expected at least %d)", i+1) - } - if registry.patterns[i].Hint != expected { - t.Errorf("NewHintRegistry: pattern[%d] hint mismatch:\ngot: %q\nwant: %q", i, registry.patterns[i].Hint, expected) - } - } -} - -func TestHintRegistry_RealWorldErrors(t *testing.T) { - registry := NewHintRegistry() - - tests := []struct { - name string - errorMessage string - expectedHint string - }{ - { - name: "go file open error", - errorMessage: "open /Users/test/.arc/config.yaml: no such file or directory", - expectedHint: "Verify the file path exists", - }, - { - name: "docker compose error", - errorMessage: "Error response from daemon: driver failed programming external connectivity on endpoint xyz: Error starting userland proxy: listen tcp4 0.0.0.0:8080: bind: address already in use", - expectedHint: "Another process is using this port. Check with lsof -i", - }, - { - name: "network timeout with context", - errorMessage: "Get \"https://api.example.com\": context deadline exceeded", - expectedHint: "Operation timed out. Check network connectivity", - }, - { - name: "yaml parse error from gopkg.in", - errorMessage: "yaml: line 12: could not find expected ':'", - expectedHint: "Check YAML syntax in your configuration file", - }, - { - name: "complex docker error", - errorMessage: "docker: Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", - expectedHint: "Ensure Docker is installed and running", - }, - { - name: "workspace error with path", - errorMessage: "workspace not initialized: run 'arc workspace init' first", - expectedHint: "Run arc workspace init to create a workspace", - }, - { - name: "generic golang error", - errorMessage: "interface conversion: interface {} is nil, not string", - expectedHint: "Run with --verbose for more details", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := registry.Match(tt.errorMessage) - if got != tt.expectedHint { - t.Errorf("RealWorldError %q:\ngot: %q\nwant: %q", tt.name, got, tt.expectedHint) - } - }) - } -} - -// Benchmark to ensure pattern matching is performant -func BenchmarkHintRegistry_Match(b *testing.B) { - registry := NewHintRegistry() - errorMessage := "open /etc/hosts: permission denied" - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = registry.Match(errorMessage) - } -} - -func BenchmarkHintRegistry_MatchNoMatch(b *testing.B) { - registry := NewHintRegistry() - errorMessage := "some completely unrelated error that won't match anything" - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = registry.Match(errorMessage) - } -} diff --git a/pkg/cli/golden_generator_test.go b/pkg/cli/golden_generator_test.go deleted file mode 100644 index 680f9e8..0000000 --- a/pkg/cli/golden_generator_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "testing" - - "github.com/arc-framework/arc-cli/pkg/ui/animations" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/styles" -) - -// TestGenerateGoldenBanners generates golden files for all profiles -// Run with: go test -run TestGenerateGoldenBanners -// This is a utility test to regenerate golden files when profiles change -func TestGenerateGoldenBanners(t *testing.T) { - if os.Getenv("UPDATE_GOLDEN") != "1" { - t.Skip("Skipping golden file generation. Set UPDATE_GOLDEN=1 to regenerate.") - } - - // Disable animations and colors for consistent output - origNoAnimation := animations.NoAnimation - origNoColor := styles.NoColor - defer func() { - animations.NoAnimation = origNoAnimation - styles.NoColor = origNoColor - }() - animations.NoAnimation = true - styles.NoColor = true - - // All 10 profiles - profileIDs := []string{ - "enterprise", "jedi", "saiyan", "shinobi", "pirate", - "pokemon", "triforce", "crystal", "bending", "horcrux", - } - - repo, err := profiles.NewRepository() - if err != nil { - t.Fatalf("Failed to create repository: %v", err) - } - - goldenDir := filepath.Join("..", "..", "testdata", "golden", "banners") - if err := os.MkdirAll(goldenDir, 0o755); err != nil { - t.Fatalf("Failed to create golden directory: %v", err) - } - - for _, profileID := range profileIDs { - t.Run(profileID, func(t *testing.T) { - profile, err := repo.GetByID(profileID) - if err != nil { - t.Fatalf("Failed to load profile %s: %v", profileID, err) - } - - profileCtx, err := profiles.NewProfileContext(profile, nil) - if err != nil { - t.Fatalf("Failed to create ProfileContext for %s: %v", profileID, err) - } - - // Generate banner - banner := RenderBanner(profileCtx) - - // Write to golden file - goldenFile := filepath.Join(goldenDir, profileID+".txt") - if err := os.WriteFile(goldenFile, []byte(banner), 0o644); err != nil { - t.Fatalf("Failed to write golden file for %s: %v", profileID, err) - } - - t.Logf("Generated golden file: %s", goldenFile) - }) - } -} diff --git a/pkg/cli/help.go b/pkg/cli/help.go index 1c0e541..d2df3cf 100644 --- a/pkg/cli/help.go +++ b/pkg/cli/help.go @@ -2,30 +2,27 @@ package cli import ( "fmt" + "os" "strings" - "time" + "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" + "golang.org/x/term" - "github.com/arc-framework/arc-cli/pkg/ui/animations" - "github.com/arc-framework/arc-cli/pkg/ui/styles" + "github.com/arc-framework/arc-cli/pkg/ui/component" + uithemeldr "github.com/arc-framework/arc-cli/pkg/ui/theme" ) -// GetHelpTemplate returns a custom styled help template -func GetHelpTemplate() string { - if styles.NoColor { - return getPlainHelpTemplate() - } - return getStyledHelpTemplate() -} +// ─── Legacy template (kept for tests + fallback) ───────────────────────────── -func getStyledHelpTemplate() string { - // Return clean template - styling will be applied by custom help function +// GetHelpTemplate returns the plain cobra help template. +// Kept for backward compatibility and tests; the rich renderer overrides it at +// runtime via SetCustomHelpFunc. +func GetHelpTemplate() string { return getPlainHelpTemplate() } func getPlainHelpTemplate() string { - // Clean, standard Cobra template return ` Usage:{{if .Runnable}} {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} @@ -59,43 +56,27 @@ Use "{{.CommandPath}} [command] --help" for more information about a command.{{e ` } -// SetCustomHelpFunc sets a custom help function that applies colors at runtime -func SetCustomHelpFunc(cmd *cobra.Command) { - cmd.SetHelpFunc(func(command *cobra.Command, _ []string) { - helpText := command.UsageString() - - if !styles.NoColor { - // Apply colors to the help text - if animations.ShouldAnimate() { - helpText = colorizeHelpWithAnimation(helpText) - } else { - helpText = colorizeHelp(helpText) - } - } - - _, _ = fmt.Fprint(command.OutOrStdout(), helpText) - }) -} - -// colorizeHelp applies colors to help text sections +// colorizeHelp applies basic lipgloss colors to a plain help text string. +// Still used by tests and available as a utility. func colorizeHelp(text string) string { + primary := lipgloss.NewStyle().Foreground(lipgloss.Color("#00ADD8")).Bold(true) + cmdName := lipgloss.NewStyle().Foreground(lipgloss.Color("#8BE9FD")) + lines := strings.Split(text, "\n") var result strings.Builder for _, line := range lines { - // Color section headers using switch for better readability switch { case strings.HasPrefix(line, "Usage:"), strings.HasPrefix(line, "Available Commands:"), strings.HasPrefix(line, "Flags:"), - strings.HasPrefix(line, "Global Flags:"): - result.WriteString(styles.PrimaryStyle.Bold(true).Render(line)) + strings.HasPrefix(line, "Global Flags:"), + strings.HasPrefix(line, "Examples:"): + result.WriteString(primary.Render(line)) case strings.HasPrefix(line, " ") && !strings.HasPrefix(line, " "): - // Command names (2 spaces indent) parts := strings.SplitN(strings.TrimSpace(line), " ", 2) if len(parts) == 2 { - cmdName := styles.InfoStyle.Render(parts[0]) - result.WriteString(" " + cmdName + " " + parts[1]) + result.WriteString(" " + cmdName.Render(parts[0]) + " " + parts[1]) } else { result.WriteString(line) } @@ -108,71 +89,286 @@ func colorizeHelp(text string) string { return result.String() } -// colorizeHelpWithAnimation applies colors to help text with fade-in animation -// Sections fade in sequentially: Usage → Commands → Flags → Examples -func colorizeHelpWithAnimation(text string) string { - lines := strings.Split(text, "\n") - var result strings.Builder +// ─── Theme context (lazy-loaded, cached) ───────────────────────────────────── - sectionDelay := 80 * time.Millisecond // Fast fade per section - lineDelay := 15 * time.Millisecond // Subtle delay per line +const helpCmdName = "help" //nolint:gochecknoglobals // sentinel name used to skip cobra's built-in help command - currentSection := "" - sectionStarted := false +var helpCtxCache *uithemeldr.Context //nolint:gochecknoglobals // lazy singleton for help rendering - for _, line := range lines { - // Detect section headers - switch { - case strings.HasPrefix(line, "Usage:"): - if !sectionStarted { - sectionStarted = true - } else { - time.Sleep(sectionDelay) - } - currentSection = "usage" - result.WriteString(styles.PrimaryStyle.Bold(true).Render(line)) +func loadHelpCtx() *uithemeldr.Context { + if helpCtxCache != nil { + return helpCtxCache + } + loader, err := uithemeldr.NewLoader() + if err != nil { + return nil + } + ctx, err := loader.DefaultContext() + if err != nil { + return nil + } + helpCtxCache = ctx + return helpCtxCache +} + +func helpTermWidth() int { + w, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || w < 40 { + return 100 + } + return w +} - case strings.HasPrefix(line, "Available Commands:"): - time.Sleep(sectionDelay) - currentSection = "commands" - result.WriteString(styles.PrimaryStyle.Bold(true).Render(line)) +// ─── Public entry-point ────────────────────────────────────────────────────── - case strings.HasPrefix(line, "Flags:"): - time.Sleep(sectionDelay) - currentSection = "flags" - result.WriteString(styles.PrimaryStyle.Bold(true).Render(line)) +// SetCustomHelpFunc installs the rich component-based help renderer on cmd. +func SetCustomHelpFunc(cmd *cobra.Command) { + cmd.SetHelpFunc(renderRichHelp) +} - case strings.HasPrefix(line, "Global Flags:"): - time.Sleep(sectionDelay) - currentSection = "globalflags" - result.WriteString(styles.PrimaryStyle.Bold(true).Render(line)) +// ─── Rich renderer ─────────────────────────────────────────────────────────── - case strings.HasPrefix(line, "Examples:"): - time.Sleep(sectionDelay) - currentSection = "examples" - result.WriteString(styles.PrimaryStyle.Bold(true).Render(line)) +func renderRichHelp(cmd *cobra.Command, _ []string) { + // Plain-text fallback when color is disabled. + if os.Getenv("NO_COLOR") != "" { + _, _ = fmt.Fprint(cmd.OutOrStdout(), colorizeHelp(cmd.UsageString())) + return + } - case strings.HasPrefix(line, " ") && !strings.HasPrefix(line, " "): - // Command names or flag names (2 spaces indent) - // Add small delay for items within a section (not for first item) - if currentSection != "" { - time.Sleep(lineDelay) - } + ctx := loadHelpCtx() + w := helpTermWidth() - parts := strings.SplitN(strings.TrimSpace(line), " ", 2) - if len(parts) == 2 { - cmdName := styles.InfoStyle.Render(parts[0]) - result.WriteString(" " + cmdName + " " + parts[1]) - } else { - result.WriteString(line) - } + var out strings.Builder - default: - result.WriteString(line) + // Brand header bar across the full terminal width. + if hdr := component.Header(ctx, w); hdr != "" { + out.WriteString(hdr) + out.WriteString("\n") + } + + switch { + case cmd == cmd.Root(): + out.WriteString(renderRootHelp(cmd, ctx, w)) + case cmd.HasSubCommands(): + out.WriteString(renderGroupHelp(cmd, ctx, w)) + default: + out.WriteString(renderLeafHelp(cmd, ctx, w)) + } + + _, _ = fmt.Fprintln(cmd.OutOrStdout(), out.String()) +} + +// renderRootHelp renders the root arc --help page as a two-column layout: +// +// LEFT (tree) | RIGHT (one card per command group + standalone commands) +func renderRootHelp(cmd *cobra.Command, ctx *uithemeldr.Context, w int) string { + const treeW = 32 + + rightW := w - treeW - 3 + if rightW < 30 { + rightW = 30 + } + _ = rightW + + // Left: command tree + treeBlock := lipgloss.NewStyle(). + Width(treeW). + Render(component.Tree(ctx, buildHelpCmdTree(cmd))) + + // Right: cards + nameStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(helpColorPrimary(ctx))). + Bold(true).Width(14) + descStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(helpColorMuted(ctx))) + + var cards []string + + // Standalone (non-group) commands listed in a "commands" card first. + var standaloneRows []string + for _, sub := range cmd.Commands() { + if sub.Hidden || sub.HasSubCommands() || sub.Name() == helpCmdName { + continue } + standaloneRows = append(standaloneRows, + nameStyle.Render(sub.Name())+descStyle.Render(sub.Short)) + } + if len(standaloneRows) > 0 { + cards = append(cards, component.Card(ctx, "commands", + strings.Join(standaloneRows, "\n"))) + } - result.WriteString("\n") + // One card per group command. + for _, sub := range cmd.Commands() { + if sub.Hidden || !sub.HasSubCommands() { + continue + } + cards = append(cards, buildHelpGroupCard(sub, ctx)) } - return result.String() + rightBlock := lipgloss.JoinVertical(lipgloss.Left, cards...) + + twoCol := lipgloss.JoinHorizontal(lipgloss.Top, + treeBlock, + lipgloss.NewStyle().Width(3).Render(""), + rightBlock, + ) + + return lipgloss.JoinVertical(lipgloss.Left, + twoCol, + "", + renderHelpFlagsSection(cmd, ctx), + renderHelpHint(cmd, ctx), + ) +} + +// renderGroupHelp renders help for a command group (e.g. arc workspace --help). +func renderGroupHelp(cmd *cobra.Command, ctx *uithemeldr.Context, _ int) string { + card := buildHelpGroupCard(cmd, ctx) + return lipgloss.JoinVertical(lipgloss.Left, + card, + "", + renderHelpFlagsSection(cmd, ctx), + renderHelpHint(cmd, ctx), + ) +} + +// renderLeafHelp renders help for a leaf command (e.g. arc workspace run --help). +func renderLeafHelp(cmd *cobra.Command, ctx *uithemeldr.Context, w int) string { + usageStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(helpColorPrimary(ctx))).Bold(true) + + lines := []string{ + usageStyle.Render("Usage:"), + " " + cmd.UseLine(), + } + + desc := cmd.Long + if desc == "" { + desc = cmd.Short + } + if desc != "" { + lines = append(lines, "", wrapHelpText(desc, w-6)) + } + + card := component.Card(ctx, cmd.CommandPath(), strings.Join(lines, "\n")) + return lipgloss.JoinVertical(lipgloss.Left, + card, + "", + renderHelpFlagsSection(cmd, ctx), + ) +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// buildHelpCmdTree converts cobra commands into a component.TreeNode hierarchy. +func buildHelpCmdTree(cmd *cobra.Command) component.TreeNode { + node := component.TreeNode{Label: cmd.Name()} + for _, sub := range cmd.Commands() { + if sub.Hidden || sub.Name() == helpCmdName { + continue + } + node.Children = append(node.Children, buildHelpCmdTree(sub)) + } + return node +} + +// buildHelpGroupCard renders a Card listing all visible sub-commands of cmd. +func buildHelpGroupCard(cmd *cobra.Command, ctx *uithemeldr.Context) string { + const nameColW = 18 + + nameStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(helpColorPrimary(ctx))). + Bold(true).Width(nameColW) + descStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(helpColorMuted(ctx))) + + var rows []string + for _, sub := range cmd.Commands() { + if sub.Hidden || sub.Name() == helpCmdName { + continue + } + rows = append(rows, nameStyle.Render(sub.Name())+descStyle.Render(sub.Short)) + } + if len(rows) == 0 { + rows = append(rows, descStyle.Render(cmd.Short)) + } + + return component.Card(ctx, cmd.Name(), strings.Join(rows, "\n")) +} + +// renderHelpFlagsSection renders a styled Flags + Global Flags block. +func renderHelpFlagsSection(cmd *cobra.Command, ctx *uithemeldr.Context) string { + local := strings.TrimRight(cmd.Flags().FlagUsages(), "\n") + inherited := strings.TrimRight(cmd.InheritedFlags().FlagUsages(), "\n") + + if local == "" && inherited == "" { + return "" + } + + titleStyle := lipgloss.NewStyle(). + Bold(true).Foreground(lipgloss.Color(helpColorPrimary(ctx))) + flagStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(helpColorMuted(ctx))) + + var parts []string + if local != "" { + parts = append(parts, titleStyle.Render("Flags:")+"\n"+flagStyle.Render(local)) + } + if inherited != "" { + parts = append(parts, titleStyle.Render("Global Flags:")+"\n"+flagStyle.Render(inherited)) + } + return strings.Join(parts, "\n") +} + +// renderHelpHint renders the "Use --help for more information." footer. +func renderHelpHint(cmd *cobra.Command, ctx *uithemeldr.Context) string { + if !cmd.HasSubCommands() { + return "" + } + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(helpColorMuted(ctx))).Faint(true). + Render(`Use "` + cmd.CommandPath() + ` [command] --help" for more information.`) +} + +// wrapHelpText wraps text at maxW characters on word boundaries. +func wrapHelpText(text string, maxW int) string { + if maxW <= 0 || maxW > 200 { + return text + } + words := strings.Fields(text) + var cur strings.Builder + lineLen := 0 + var lines []string + for _, word := range words { + if lineLen+len(word)+1 > maxW && lineLen > 0 { + lines = append(lines, cur.String()) + cur.Reset() + lineLen = 0 + } + if lineLen > 0 { + cur.WriteString(" ") + lineLen++ + } + cur.WriteString(word) + lineLen += len(word) + } + if cur.Len() > 0 { + lines = append(lines, cur.String()) + } + return strings.Join(lines, "\n") +} + +func helpColorPrimary(ctx *uithemeldr.Context) string { + if ctx != nil { + return ctx.Theme().Colors.Primary + } + return "#00ADD8" +} + +func helpColorMuted(ctx *uithemeldr.Context) string { + if ctx != nil { + return ctx.Theme().Colors.Muted + } + return "#6272A4" } diff --git a/pkg/cli/help_test.go b/pkg/cli/help_test.go index cd9fbce..28c8c66 100644 --- a/pkg/cli/help_test.go +++ b/pkg/cli/help_test.go @@ -3,95 +3,9 @@ package cli import ( "strings" "testing" - - "github.com/arc-framework/arc-cli/pkg/ui/animations" - "github.com/arc-framework/arc-cli/pkg/ui/styles" ) -func TestGetHelpTemplate(t *testing.T) { - // Note: Not using t.Parallel() - - template := GetHelpTemplate() - - if template == "" { - t.Error("GetHelpTemplate() returned empty string") - } - - // Should contain standard help sections - if len(template) < 50 { - t.Error("Help template seems too short") - } -} - -func TestGetHelpTemplate_Consistency(t *testing.T) { - // Note: Not using t.Parallel() - - // Calling twice should return same template - template1 := GetHelpTemplate() - template2 := GetHelpTemplate() - - if template1 != template2 { - t.Error("GetHelpTemplate() should return consistent results") - } -} - -func TestHelpTemplate_Structure(t *testing.T) { - // Note: Not using t.Parallel() - - template := GetHelpTemplate() - - // Should contain key help sections - expectedSections := []string{ - "Usage", - "Commands", - "Flags", - } - - found := 0 - for _, section := range expectedSections { - if contains(template, section) { - found++ - } - } - - if found == 0 { - t.Error("Help template should contain at least one standard section") - } -} - -func TestHelpTemplate_NoColor(t *testing.T) { - // Note: Not using t.Parallel() - - // Test help template generation works - template := GetHelpTemplate() - - if template == "" { - t.Error("Help template should work in any color mode") - } -} - -// Helper function to check if string contains substring -func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(substr) == 0 || - (len(s) > 0 && len(substr) > 0 && findSubstring(s, substr))) -} - -func findSubstring(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} - func TestColorizeHelp(t *testing.T) { - // Save original NoColor setting - origNoColor := styles.NoColor - defer func() { styles.NoColor = origNoColor }() - - styles.NoColor = false - helpText := ` Usage: arc [command] @@ -102,50 +16,10 @@ Available Commands: Flags: -h, --help help for arc - -Global Flags: - --no-animation Disable all animations ` result := colorizeHelp(helpText) - // Should contain styled headers - if !strings.Contains(result, "Usage:") { - t.Error("Expected Usage header in result") - } - - if !strings.Contains(result, "Available Commands:") { - t.Error("Expected Available Commands header in result") - } -} - -func TestColorizeHelpWithAnimation(t *testing.T) { - // Save original NoColor and NoAnimation settings - origNoColor := styles.NoColor - origNoAnimation := animations.NoAnimation - defer func() { - styles.NoColor = origNoColor - animations.NoAnimation = origNoAnimation - }() - - styles.NoColor = false - animations.NoAnimation = true // Disable actual animation delays for test - - helpText := ` -Usage: - arc [command] - -Available Commands: - info Show system information - theme Manage banner color themes - -Flags: - -h, --help help for arc -` - - result := colorizeHelpWithAnimation(helpText) - - // Should contain all sections if !strings.Contains(result, "Usage:") { t.Error("Expected Usage header in result") } @@ -153,18 +27,10 @@ Flags: if !strings.Contains(result, "Available Commands:") { t.Error("Expected Available Commands header in result") } - - if !strings.Contains(result, "Flags:") { - t.Error("Expected Flags header in result") - } } func TestColorizeHelpNoColor(t *testing.T) { - // Save original NoColor setting - origNoColor := styles.NoColor - defer func() { styles.NoColor = origNoColor }() - - styles.NoColor = true + t.Setenv("NO_COLOR", "1") helpText := ` Usage: @@ -174,21 +40,16 @@ Available Commands: info Show system information ` - // With NO_COLOR, should return unmodified text result := colorizeHelp(helpText) - // Text should be present but not styled (hard to test exact styling) if !strings.Contains(result, "Usage:") { t.Error("Expected Usage header in plain result") } } func TestGetHelpTemplateWithColors(t *testing.T) { - // Note: Not using t.Parallel() - template := GetHelpTemplate() - // Should contain standard template elements if !strings.Contains(template, "Usage:") { t.Error("Expected Usage section in template") } @@ -203,17 +64,10 @@ func TestGetHelpTemplateWithColors(t *testing.T) { } func TestGetHelpTemplateNoColor(t *testing.T) { - // Note: Not using t.Parallel() - - // Save original NoColor setting - origNoColor := styles.NoColor - defer func() { styles.NoColor = origNoColor }() - - styles.NoColor = true + t.Setenv("NO_COLOR", "1") template := GetHelpTemplate() - // Should return plain template if !strings.Contains(template, "Usage:") { t.Error("Expected Usage section in plain template") } diff --git a/pkg/cli/info.go b/pkg/cli/info.go deleted file mode 100644 index b9c9256..0000000 --- a/pkg/cli/info.go +++ /dev/null @@ -1,282 +0,0 @@ -package cli - -import ( - "encoding/json" - "fmt" - "os" - - "github.com/charmbracelet/lipgloss" - "github.com/spf13/cobra" - - "github.com/arc-framework/arc-cli/internal/app" - "github.com/arc-framework/arc-cli/internal/branding" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/animations" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" - "github.com/arc-framework/arc-cli/pkg/ui/views" -) - -const ( - keyQuit = "q" - keyCtrlC = "ctrl+c" -) - -var infoJSONFlag bool - -// infoAppContext holds the application context for UI rendering. -// This is set from the application context via SetInfoAppContext. -var infoAppContext *app.Context - -// SetInfoAppContext sets the app context instance for UI rendering. -// This should be called before executing the info command with the new UI. -func SetInfoAppContext(ctx *app.Context) { - infoAppContext = ctx -} - -// renderInfoTable renders system information as a formatted table. -// Uses profile theme colors when available, falls back to hardcoded colors otherwise. -func renderInfoTable(info *branding.SystemInfo) string { - // Try to load ProfileContext for theming - profileCtx := profiles.GetDefaultProfileContext() - theme := profileCtx.Theme() - - // Get colors from theme or use fallback - var primaryColor, mutedColor, foregroundColor lipgloss.Color - if theme != nil { - primaryColor = theme.Colors.PrimaryColor() - mutedColor = theme.Colors.MutedColor() - foregroundColor = theme.Colors.ForegroundColor() - } else { - // Fallback colors (enterprise theme defaults) - primaryColor = lipgloss.Color("#00ADD8") - mutedColor = lipgloss.Color("#7D7D7D") - foregroundColor = lipgloss.Color("#FFFFFF") - } - - // Header - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(primaryColor). - MarginBottom(1) - - // Table style - keyStyle := lipgloss.NewStyle(). - Foreground(mutedColor). - Width(20). - Align(lipgloss.Right) - - valueStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(foregroundColor) - - // Panels - use theme if available for panel rendering - panels := []string{ - renderCliInfoPanel(info, &keyStyle, &valueStyle, theme), - renderGoInfoPanel(info, &keyStyle, &valueStyle, theme), - renderHardwareInfoPanel(info, &keyStyle, &valueStyle, theme), - renderSystemInfoPanel(info, &keyStyle, &valueStyle, theme), - renderConfigInfoPanel(info, &keyStyle, &valueStyle, theme), - } - if info.IsGitRepo { - panels = append(panels, renderGitInfoPanel(info, &keyStyle, &valueStyle, theme)) - } - - output := titleStyle.Render("🔍 System Information") + "\n\n" - output += lipgloss.JoinVertical(lipgloss.Left, panels...) - return output + "\n" -} - -func renderCliInfoPanel(info *branding.SystemInfo, keyStyle, valueStyle *lipgloss.Style, theme *themes.Theme) string { - cliContent := renderInfoRow(keyStyle, valueStyle, "Version", info.CLIVersion) - cliContent += renderInfoRow(keyStyle, valueStyle, "Build Date", info.CLIBuildDate) - if info.CLICommit != "" { - cliContent += renderInfoRow(keyStyle, valueStyle, "Commit", info.CLICommit) - } - cliPanel := components.NewThemedPanel("🚀 CLI", cliContent, theme).SetWidth(80) - return cliPanel.Render() -} - -func renderGoInfoPanel(info *branding.SystemInfo, keyStyle, valueStyle *lipgloss.Style, theme *themes.Theme) string { - runtimeContent := renderInfoRow(keyStyle, valueStyle, "Version", info.GoVersion) - runtimeContent += renderInfoRow(keyStyle, valueStyle, "OS/Arch", fmt.Sprintf("%s/%s", info.GoOS, info.GoArch)) - runtimePanel := components.NewThemedPanel("⚙️ Go Runtime", runtimeContent, theme).SetWidth(80) - return runtimePanel.Render() -} - -func renderHardwareInfoPanel(info *branding.SystemInfo, keyStyle, valueStyle *lipgloss.Style, theme *themes.Theme) string { - hardwareContent := "" - if info.CPUModel != "" && info.CPUModel != "unknown" { - hardwareContent += renderInfoRow(keyStyle, valueStyle, "CPU", info.CPUModel) - } - hardwareContent += renderInfoRow(keyStyle, valueStyle, "Cores", fmt.Sprintf("%d", info.NumCPU)) - if info.MemoryTotal > 0 { - hardwareContent += renderInfoRow(keyStyle, valueStyle, "Memory", fmt.Sprintf("%s total, %s free", - branding.FormatBytes(int64(info.MemoryTotal)), - branding.FormatBytes(int64(info.MemoryFree)))) - } - if hardwareContent != "" { - hardwarePanel := components.NewThemedPanel("🖥️ Hardware", hardwareContent, theme).SetWidth(80) - return hardwarePanel.Render() - } - return "" -} - -func renderSystemInfoPanel(info *branding.SystemInfo, keyStyle, valueStyle *lipgloss.Style, theme *themes.Theme) string { - systemContent := "" - if info.Hostname != "" { - systemContent += renderInfoRow(keyStyle, valueStyle, "Hostname", info.Hostname) - } - if info.Username != "" { - systemContent += renderInfoRow(keyStyle, valueStyle, "User", info.Username) - } - if info.HomeDir != "" { - systemContent += renderInfoRow(keyStyle, valueStyle, "Home", info.HomeDir) - } - if info.WorkingDir != "" { - systemContent += renderInfoRow(keyStyle, valueStyle, "Working Dir", info.WorkingDir) - } - if systemContent != "" { - systemPanel := components.NewThemedPanel("💻 System", systemContent, theme).SetWidth(80) - return systemPanel.Render() - } - return "" -} - -func renderConfigInfoPanel(info *branding.SystemInfo, keyStyle, valueStyle *lipgloss.Style, theme *themes.Theme) string { - configContent := "" - if info.ConfigDir != "" { - configContent += renderInfoRow(keyStyle, valueStyle, "Config Dir", info.ConfigDir) - } - if info.StateDBPath != "" { - configContent += renderInfoRow(keyStyle, valueStyle, "State DB", info.StateDBPath) - // Check if state DB exists and get size - if size, err := branding.GetStateDBSize(); err == nil { - configContent += renderInfoRow(keyStyle, valueStyle, "DB Size", branding.FormatBytes(size)) - } - } - if configContent != "" { - configPanel := components.NewThemedPanel("⚙️ Configuration", configContent, theme).SetWidth(80) - return configPanel.Render() - } - return "" -} - -func renderGitInfoPanel(info *branding.SystemInfo, keyStyle, valueStyle *lipgloss.Style, theme *themes.Theme) string { - gitContent := renderInfoRow(keyStyle, valueStyle, "Branch", info.GitBranch) - gitContent += renderInfoRow(keyStyle, valueStyle, "Commit", info.GitCommit) - gitContent += renderInfoRow(keyStyle, valueStyle, "Status", info.GitStatus) - if info.GitRemote != "" { - gitContent += renderInfoRow(keyStyle, valueStyle, "Remote", info.GitRemote) - } - gitPanel := components.NewThemedPanel("🌿 Git Repository", gitContent, theme).SetWidth(80) - return gitPanel.Render() -} - -// renderInfoRow renders a single key-value row -func renderInfoRow(keyStyle, valueStyle *lipgloss.Style, key, value string) string { - return keyStyle.Render(key+":") + " " + valueStyle.Render(value) + "\n" -} - -// renderInfoJSON renders system information as JSON -func renderInfoJSON(info *branding.SystemInfo) (string, error) { - data, err := json.MarshalIndent(info, "", " ") - if err != nil { - return "", err - } - return string(data), nil -} - -// renderInfoWithNewUI renders system information using the new InfoView (Phase 5). -// This uses the profile-aware InfoView with Hero, Tree, and StatusBar components. -func renderInfoWithNewUI(ctx *app.Context, info *branding.SystemInfo) error { - // Get profile context and factory from app context - profileCtx := ctx.GetProfileContext() - if profileCtx == nil { - return fmt.Errorf("profile context not available") - } - - // Get border tier from SafeBorder - borderTier := ctx.SafeBorder.Tier() - - // Create component factory - factory := ui.NewComponentFactory(profileCtx, borderTier) - - // Create InfoView - view := views.NewInfoView(factory) - - // Initialize view context with theme and profile - viewCtx := engine.NewViewContext( - profileCtx.Profile(), - profileCtx.Theme(), - 120, // Default width, will be updated by terminal size - 40, // Default height, will be updated by terminal size - map[string]any{ - "systemInfo": info, - }, - ) - - // Call OnEnter to initialize the view with data - _ = view.OnEnter(viewCtx) - - // Render using engine (Phase 5) - return engine.Render(engine.RenderConfig{ - View: view, - Mode: engine.TUIMode, - }) -} - -var infoCmd = &cobra.Command{ - Use: "info", - Short: "Display system and CLI information", - Long: `Display comprehensive system information including: - - CLI version and build details - - Go runtime information - - System configuration - - State database details - - Git repository status (if applicable)`, - RunE: func(cmd *cobra.Command, args []string) error { - // Collect system info - info, err := branding.CollectSystemInfo() - if err != nil { - return err - } - - // If JSON output requested, skip animation - if infoJSONFlag { - output, jsonErr := renderInfoJSON(info) - if jsonErr != nil { - return jsonErr - } - fmt.Println(output) - return nil - } - - // Check for legacy UI fallback (Phase 5) - if os.Getenv("ARC_USE_LEGACY_UI") != "" { - fmt.Println(renderInfoTable(info)) - return nil - } - - // Use new InfoView if app context is available (Phase 5) - if infoAppContext != nil { - return renderInfoWithNewUI(infoAppContext, info) - } - - // Fall back to legacy rendering if app context not set - if !animations.ShouldAnimate() { - fmt.Println(renderInfoTable(info)) - return nil - } - - // Static fallback when no app context and no animations - fmt.Println(renderInfoTable(info)) - return nil - }, -} - -func init() { - infoCmd.Flags().BoolVar(&infoJSONFlag, "json", false, "Output information as JSON") -} diff --git a/pkg/cli/info_test.go b/pkg/cli/info_test.go deleted file mode 100644 index 1dd049e..0000000 --- a/pkg/cli/info_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package cli - -import ( - "testing" - - "github.com/arc-framework/arc-cli/internal/app" -) - -func TestInfoCommand_Exists(t *testing.T) { - // Note: Not using t.Parallel() - - // Test that info command is registered - cmd := rootCmd - foundCmd, _, err := cmd.Find([]string{"info"}) - if err != nil { - t.Fatalf("Info command not found: %v", err) - } - - if foundCmd == nil { - t.Fatal("Info command is nil") - } - - if foundCmd.Use != "info" { - t.Errorf("Info command Use = %q, want %q", foundCmd.Use, "info") - } -} - -func TestInfoCommand_Flags(t *testing.T) { - // Note: Not using t.Parallel() - - cmd := rootCmd - foundCmd, _, err := cmd.Find([]string{"info"}) - if err != nil { - t.Skip("Info command not registered") - return - } - - // Check if --json flag exists - jsonFlag := foundCmd.Flags().Lookup("json") - if jsonFlag == nil { - t.Error("Info command should have --json flag") - } -} - -func TestInfoCommand_HasShortDescription(t *testing.T) { - // Note: Not using t.Parallel() - - cmd := rootCmd - foundCmd, _, err := cmd.Find([]string{"info"}) - if err != nil { - t.Skip("Info command not registered") - return - } - - if foundCmd.Short == "" { - t.Error("Info command should have a short description") - } -} - -func TestSetInfoAppContext(t *testing.T) { - // Note: Not using t.Parallel() - - // Create a mock app context - ctx := &app.Context{} - - // Call SetInfoAppContext - SetInfoAppContext(ctx) - - // Verify that infoAppContext was set - if infoAppContext == nil { - t.Error("SetInfoAppContext should set infoAppContext") - } - - if infoAppContext != ctx { - t.Error("SetInfoAppContext should set infoAppContext to the provided context") - } - - // Clean up - infoAppContext = nil -} diff --git a/pkg/cli/init.go b/pkg/cli/init.go index 7a83d12..8ba1310 100644 --- a/pkg/cli/init.go +++ b/pkg/cli/init.go @@ -4,1038 +4,17 @@ import ( "fmt" "os" "path/filepath" - "strings" - "time" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/spf13/afero" "github.com/spf13/cobra" - "github.com/arc-framework/arc-cli/internal/preferences" - "github.com/arc-framework/arc-cli/internal/version" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/views" + newengine "github.com/arc-framework/arc-cli/pkg/ui/engine" + uithemeldr "github.com/arc-framework/arc-cli/pkg/ui/theme" + uiview "github.com/arc-framework/arc-cli/pkg/ui/view" "github.com/arc-framework/arc-cli/pkg/workspace" "github.com/arc-framework/arc-cli/pkg/workspace/store/local" ) -const ( - keyEnter = "enter" - keyEsc = "esc" - keyLeft = "left" - keyRight = "right" - // keyQuit and keyCtrlC are defined in info.go - - defaultProfileID = "enterprise" -) - -// installationCompleteMsg is sent when the installation is complete -type installationCompleteMsg struct { - err error // nil on success, error on failure -} - -// StackTier represents a platform stack complexity level using the Dragon Ball Super metaphor. -// Each tier defines a collection of services with minimum resource requirements. -type StackTier struct { - // ID is the unique identifier (lowercase kebab-case) - ID string - // Name is the human-readable tier name - Name string - // Description is a brief explanation of tier purpose (1-2 sentences) - Description string - // Services is the list of included service names - Services []string - // MinCPU is the minimum CPU cores required - MinCPU int - // MinRAM is the minimum RAM in gigabytes - MinRAM int - // Enabled indicates whether the tier is selectable in the wizard - Enabled bool - // Color is the visual theme color for UI rendering - Color lipgloss.Color -} - -// Tier constants - the three Dragon Ball Super-inspired platform tiers -var ( - // TierSuperSaiyan is the standard developer stack with essential services - TierSuperSaiyan = StackTier{ - ID: "super-saiyan", - Name: "Super Saiyan", - Description: "The standard developer stack with essential services for building and testing AI agents.", - Services: []string{"Traefik", "Kratos", "Postgres", "LiveKit"}, - MinCPU: 2, - MinRAM: 4, - Enabled: true, - Color: lipgloss.Color("#FFD700"), // Gold - } - - // TierSuperSaiyanBlue is the advanced custom stack (coming soon) - TierSuperSaiyanBlue = StackTier{ - ID: "super-saiyan-blue", - Name: "Super Saiyan Blue", - Description: "Custom stack configuration with advanced service orchestration. (Under development)", - Services: []string{}, - MinCPU: 4, - MinRAM: 8, - Enabled: false, - Color: lipgloss.Color("#00BFFF"), // Deep Sky Blue - } - - // TierUltraInstinct is the god-mode stack with full observability (coming soon) - TierUltraInstinct = StackTier{ - ID: "ultra-instinct", - Name: "Ultra Instinct", - Description: "God-mode stack with full observability, distributed tracing, and advanced scaling. (Under development)", - Services: []string{}, - MinCPU: 8, - MinRAM: 16, - Enabled: false, - Color: lipgloss.Color("#E6E6FA"), // Lavender - } -) - -// wizardStep represents the current step in the wizard state machine -type wizardStep int - -const ( - // ProfileSelection is the first step where the user selects a UI profile - ProfileSelection wizardStep = iota - // StackSelection is where the user selects a tier (shows profile-specific tier names) - StackSelection - // PathSelection is where the user specifies the installation path - PathSelection - // Installation is where the setup simulation occurs - Installation - // Completion is the final success screen - Completion -) - -// initModel implements the Bubble Tea model interface for the init wizard -type initModel struct { - // currentStep tracks the current wizard state - currentStep wizardStep - // selectedTierIndex is the currently selected tier (0-2) - selectedTierIndex int - // tiers is the list of available stack tiers - tiers []StackTier - // profiles is the list of available UI profiles - profiles []*profiles.Profile - // selectedProfileIndex is the currently selected profile - selectedProfileIndex int - // selectedProfile holds the ID of the selected profile (set after profile selection) - selectedProfile string - // installPath is the user-specified installation directory - installPath string - // showModal indicates whether the "Coming Soon" modal is visible - showModal bool - // spinner is the loading animation for the Installation step - spinner spinner.Model - // termWidth is the detected terminal width - termWidth int - // termHeight is the detected terminal height - termHeight int - // installationStartTime tracks when installation phase started - installationStartTime time.Time - // initError holds any error from initialization (nil on success) - initError error - // initSuccess indicates if initialization completed successfully - initSuccess bool -} - -// initialInitModel creates a new init wizard model with default values -func initialInitModel() *initModel { - tiers := []StackTier{ - TierSuperSaiyan, - TierSuperSaiyanBlue, - TierUltraInstinct, - } - - // Load available profiles - repo, err := profiles.NewRepository() - var profileList []*profiles.Profile - if err == nil { - profileList, _ = repo.ListProfiles() - } - - return &initModel{ - currentStep: ProfileSelection, // Start with profile selection - selectedTierIndex: 0, // Default to first tier - tiers: tiers, - profiles: profileList, - selectedProfileIndex: 0, // Default to first profile - installPath: "./", - showModal: false, - spinner: components.NewSpinner(), // Use default theme - } -} - -// Init initializes the Bubble Tea model -func (m *initModel) Init() tea.Cmd { - if m.currentStep == Installation { - return m.spinner.Tick - } - return nil -} - -// Update handles Bubble Tea messages and updates the model state. -// -// State Machine Flow: -// ProfileSelection -> StackSelection (when profile selected with Enter) -// StackSelection -> PathSelection (when enabled tier selected with Enter) -// StackSelection -> Modal (when disabled tier selected with Enter) -// PathSelection -> Installation (when path confirmed with Enter) -// Installation -> Completion (automatic after 2-3s simulation) -// -// The modal is an overlay state that can appear during StackSelection, -// and is dismissed with Enter or Escape to return to StackSelection. -func (m *initModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - // Global quit keys - work from any step - if msg.String() == keyQuit || msg.String() == keyCtrlC { - return m, tea.Quit - } - - // Handle modal key presses first (modal is an overlay, not a step) - // Modal can only appear during StackSelection step - // Must be checked before step handlers to intercept Enter/Escape - if m.showModal { - return m.handleModalKeys(msg) - } - - // Delegate to step-specific keyboard handlers - // Note: Installation and Completion steps don't handle keyboard input - switch m.currentStep { - case StackSelection: - return m.handleStackSelectionKeys(msg) - case ProfileSelection: - return m.handleProfileSelectionKeys(msg) - case PathSelection: - return m.handlePathSelectionKeys(msg) - } - - case spinner.TickMsg: - // Update spinner animation during Installation step - if m.currentStep == Installation { - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - } - - case installationCompleteMsg: - // Installation finished - check for errors - m.currentStep = Completion - if msg.err != nil { - m.initError = msg.err - m.initSuccess = false - } else { - m.initSuccess = true - } - return m, nil - - case tea.WindowSizeMsg: - // Track terminal dimensions for responsive rendering - m.termWidth = msg.Width - m.termHeight = msg.Height - } - - return m, nil -} - -// View renders the current wizard screen. -// -// Terminal Size Detection: -// Minimum supported size is 80x20 characters. If the terminal is smaller, -// a warning message is displayed instead of the wizard UI. This ensures -// the tier cards and UI elements render correctly without wrapping or truncation. -func (m *initModel) View() string { - // Guard: Check minimum terminal size (80 width x 20 height) - // Below this threshold, the UI becomes unusable due to card wrapping - if m.termWidth < 80 || m.termHeight < 20 { - return m.renderTerminalTooSmall() - } - - // Delegate rendering to step-specific view methods - // Each step has its own dedicated rendering function - switch m.currentStep { - case StackSelection: - return m.renderStackSelection() - case ProfileSelection: - return m.renderProfileSelection() - case PathSelection: - return m.renderPathSelection() - case Installation: - return m.renderInstallation() - case Completion: - return m.renderCompletion() - } - - return "" -} - -// handleStackSelectionKeys handles keyboard input for the StackSelection step -func (m *initModel) handleStackSelectionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case keyLeft, "h": - // Navigate left with wrapping - m.selectedTierIndex-- - if m.selectedTierIndex < 0 { - m.selectedTierIndex = len(m.tiers) - 1 - } - - case keyRight, "l": - // Navigate right with wrapping - m.selectedTierIndex = (m.selectedTierIndex + 1) % len(m.tiers) - - case keyEnter: - selectedTier := m.tiers[m.selectedTierIndex] - if selectedTier.Enabled { - // Transition to PathSelection - m.currentStep = PathSelection - } else { - // Show "Coming Soon" modal - m.showModal = true - } - - case keyEsc: - // Go back to profile selection - m.currentStep = ProfileSelection - } - - return m, nil -} - -// handleProfileSelectionKeys handles keyboard input for the ProfileSelection step -func (m *initModel) handleProfileSelectionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "up", "k": - // Navigate up in the vertical list - m.selectedProfileIndex-- - if m.selectedProfileIndex < 0 { - m.selectedProfileIndex = len(m.profiles) - 1 - } - - case "down", "j": - // Navigate down in the vertical list - m.selectedProfileIndex = (m.selectedProfileIndex + 1) % len(m.profiles) - - case keyEnter: - // Save selected profile and transition to StackSelection - if m.selectedProfileIndex < len(m.profiles) { - selectedProfile := m.profiles[m.selectedProfileIndex] - // Store the selected profile ID in the model - m.selectedProfile = selectedProfile.ID - // Save profile and theme to preferences atomically - prefs, err := preferences.Load() - if err == nil { - _ = prefs.SetProfileWithThemeSync(selectedProfile.ID, selectedProfile.ThemeID) - } - // Transition to stack selection with profile-specific tier names - m.currentStep = StackSelection - } - } - - return m, nil -} - -// handlePathSelectionKeys handles keyboard input for the PathSelection step -func (m *initModel) handlePathSelectionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch { - case msg.Type == tea.KeyEnter: - // Transition to Installation if path is non-empty - if m.installPath != "" { - m.currentStep = Installation - m.installationStartTime = time.Now() - // Capture installPath and selected tier for the closure - installPath := m.installPath - selectedTier := m.tiers[m.selectedTierIndex] - // Return both spinner tick and actual initialization - return m, tea.Batch( - m.spinner.Tick, - func() tea.Msg { - // Perform actual workspace initialization with selected tier - err := performInitialization(installPath, selectedTier.ID) - return installationCompleteMsg{err: err} - }, - ) - } - - case msg.Type == tea.KeyBackspace: - // Remove last character - if m.installPath != "" { - m.installPath = m.installPath[:len(m.installPath)-1] - } - - case msg.String() == keyEsc: - // Go back to stack selection - m.currentStep = StackSelection - - case msg.Type == tea.KeyRunes: - // Handle typing (single character keys) - if len(msg.Runes) == 1 { - m.installPath += string(msg.Runes[0]) - } - } - - return m, nil -} - -// handleModalKeys handles keyboard input when the "Coming Soon" modal is visible. -// -// Modal Behavior: -// The modal appears when a user selects a disabled tier (Super Saiyan Blue or Ultra Instinct). -// It's an overlay that blocks interaction with the underlying StackSelection screen. -// Escape dismisses the modal and returns to normal StackSelection interaction. -// Enter keeps modal shown if still on disabled tier (user confirms they want the disabled tier). -// The modal only appears during StackSelection step, never during other steps. -func (m *initModel) handleModalKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case keyEsc: - // Dismiss modal and return to StackSelection interaction - m.showModal = false - return m, nil - case keyEnter: - // Check if still on disabled tier - if m.selectedTierIndex >= 0 && m.selectedTierIndex < len(m.tiers) { - selectedTier := m.tiers[m.selectedTierIndex] - if !selectedTier.Enabled { - // Still on disabled tier, keep modal shown - return m, nil - } - } - // On enabled tier (shouldn't happen, but handle gracefully) - m.showModal = false - return m, nil - } - - return m, nil -} - -// renderStackSelection renders the stack tier selection screen -func (m *initModel) renderStackSelection() string { - var content strings.Builder - - // Determine which profile to use for banner and tier names - profileID := m.selectedProfile - if profileID == "" { - // Default to enterprise profile if no profile selected yet - profileID = defaultProfileID - } - - // Load profile context for banner and tier names - profileCtx := profiles.LoadProfileContext(profileID) - - // Render banner with profile-specific logo and theme - content.WriteString(RenderBanner(profileCtx)) - content.WriteString("\n") - - // Render title - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#00ADD8")). - MarginBottom(1) - content.WriteString(titleStyle.Render("Choose Your Power Tier")) - content.WriteString("\n\n") - - // Render tier cards with profile-specific names - var tierNames []string - if profileCtx != nil { - tierNames = profileCtx.TierNames() - } - - content.WriteString(m.renderTierCards(tierNames)) - content.WriteString("\n\n") - - // Render modal if shown - if m.showModal { - // Overlay modal on top - baseScreen := content.String() - modal := m.renderComingSoonModal() - // Simple overlay - in a real implementation you might use lipgloss.Place - content.Reset() - content.WriteString(baseScreen) - content.WriteString("\n") - content.WriteString(modal) - } - - // Render footer - content.WriteString("\n") - content.WriteString(m.renderFooter()) - - return content.String() -} - -// renderProfileSelection renders the profile selection screen with split-screen layout -func (m *initModel) renderProfileSelection() string { - var content strings.Builder - - // Render banner - content.WriteString(RenderBanner(nil)) - content.WriteString("\n") - - // Title - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FF79C6")). - Align(lipgloss.Center) - - content.WriteString(titleStyle.Render("Choose Your Profile Theme")) - content.WriteString("\n\n") - - // Check if profiles exist - if len(m.profiles) == 0 { - content.WriteString(lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF5555")). - Render("No profiles available")) - return content.String() - } - - // Render split-screen layout: List (left) + Preview (right) - leftPanel := m.renderProfileList() - rightPanel := m.renderProfilePreview() - - // Join panels horizontally - splitScreen := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel) - content.WriteString(splitScreen) - content.WriteString("\n\n") - - // Navigation hint - hintStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6272A4")). - Italic(true). - Align(lipgloss.Center) - - content.WriteString(hintStyle.Render("Use ↑/↓ or j/k to navigate • Enter to select")) - - // Render footer - content.WriteString("\n") - content.WriteString(m.renderFooter()) - - return content.String() -} - -// renderPathSelection renders the installation path input screen -func (m *initModel) renderPathSelection() string { - var content strings.Builder - - // Load profile context for banner (user has selected profile by this point) - var profileCtx *profiles.ProfileContext - if m.selectedProfile != "" { - profileCtx = profiles.LoadProfileContext(m.selectedProfile) - } - - // Render banner with profile-specific logo and theme - content.WriteString(RenderBanner(profileCtx)) - content.WriteString("\n") - - // Title - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#00ADD8")). - MarginBottom(1) - content.WriteString(titleStyle.Render("Installation Path")) - content.WriteString("\n\n") - - // Label - labelStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")) - content.WriteString(labelStyle.Render("Enter the directory where you want to initialize A.R.C.:")) - content.WriteString("\n\n") - - // Input field with cursor - inputStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#00ADD8")). - Padding(0, 1). - Width(60) - - inputText := m.installPath + "▌" // Cursor indicator - content.WriteString(inputStyle.Render(inputText)) - content.WriteString("\n\n") - - // Hint - hintStyle := lipgloss.NewStyle(). - Italic(true). - Foreground(lipgloss.Color("#7D7D7D")) - content.WriteString(hintStyle.Render("Press Enter to continue, Esc to go back, or Backspace to edit")) - content.WriteString("\n\n") - - // Render footer - content.WriteString(m.renderFooter()) - - return content.String() -} - -// renderInstallation renders the installation progress screen -func (m *initModel) renderInstallation() string { - var content strings.Builder - - // Load profile context for banner (user has selected profile by this point) - var profileCtx *profiles.ProfileContext - if m.selectedProfile != "" { - profileCtx = profiles.LoadProfileContext(m.selectedProfile) - } - - // Render banner with profile-specific logo and theme - content.WriteString(RenderBanner(profileCtx)) - content.WriteString("\n") - - // Title with spinner (T093: Enhanced animation and visual presentation) - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#00ADD8")). - MarginBottom(1) - content.WriteString(titleStyle.Render("Setting Up")) - content.WriteString("\n\n") - - // Spinner with enhanced visual presentation - spinnerStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#00ADD8")). - Bold(true) - - // Create a visually appealing spinner box - spinnerBoxStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#00ADD8")). - Padding(1, 3). - Width(60). - Align(lipgloss.Center) - - spinnerContent := fmt.Sprintf("%s Setting up your A.R.C. environment...", spinnerStyle.Render(m.spinner.View())) - content.WriteString(spinnerBoxStyle.Render(spinnerContent)) - content.WriteString("\n\n") - - // Progress message - messageStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#7D7D7D")). - Italic(true). - Align(lipgloss.Center) - content.WriteString(messageStyle.Render("This will only take a moment...")) - content.WriteString("\n\n") - - // Footer (dimmed) - footerStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#4D4D4D")) - content.WriteString(footerStyle.Render(m.renderFooter())) - - return content.String() -} - -// renderCompletion renders the success or error completion screen -func (m *initModel) renderCompletion() string { - var content strings.Builder - - // Render banner with selected profile if available - var profileCtx *profiles.ProfileContext - if m.selectedProfile != "" { - profileCtx = profiles.LoadProfileContext(m.selectedProfile) - } - content.WriteString(RenderBanner(profileCtx)) - content.WriteString("\n") - - // Check if initialization failed - if m.initError != nil { - return m.renderCompletionError(&content) - } - - return m.renderCompletionSuccess(&content) -} - -// renderCompletionError renders the error screen when initialization fails -func (m *initModel) renderCompletionError(content *strings.Builder) string { - // Error message - errorStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FF0000")). - MarginBottom(1). - Align(lipgloss.Center) - content.WriteString(errorStyle.Render("❌ Setup Failed")) - content.WriteString("\n\n") - - // Error details in a styled box - var errorBox strings.Builder - errorBox.WriteString(m.initError.Error()) - - errorBoxStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#FF0000")). - Padding(1, 2). - Width(60) - - content.WriteString(errorBoxStyle.Render(errorBox.String())) - content.WriteString("\n\n") - - // Guidance - hintStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#7D7D7D")). - Italic(true) - - if workspace.IsWorkspaceExists(m.initError) { - content.WriteString(hintStyle.Render("Tip: Use 'arc workspace init --force' to reinitialize")) - } else { - content.WriteString(hintStyle.Render("Check the path and try again")) - } - content.WriteString("\n\n") - - // Footer - content.WriteString(m.renderFooter()) - - return content.String() -} - -// renderCompletionSuccess renders the success screen when initialization succeeds -func (m *initModel) renderCompletionSuccess(content *strings.Builder) string { - // Success message with enhanced styling - successStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#00FF00")). - MarginBottom(1). - Align(lipgloss.Center) - content.WriteString(successStyle.Render("🎉 Setup Complete!")) - content.WriteString("\n\n") - - // Selected tier info in a styled box - selectedTier := m.tiers[m.selectedTierIndex] - - // Get profile-specific tier name if available - tierName, profileName := m.getProfileTierInfo(selectedTier.Name) - - // Create info box content - var infoBox strings.Builder - tierLabelStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#7D7D7D")) - - // Show profile name if available - if profileName != "" { - infoBox.WriteString(tierLabelStyle.Render("Profile: ")) - profileValueStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FF79C6")) - infoBox.WriteString(profileValueStyle.Render(profileName)) - infoBox.WriteString("\n") - } - - infoBox.WriteString(tierLabelStyle.Render("Stack: ")) - tierValueStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(selectedTier.Color) - infoBox.WriteString(tierValueStyle.Render(tierName)) - infoBox.WriteString("\n") - - infoStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")) - infoBox.WriteString(tierLabelStyle.Render("Location: ")) - infoBox.WriteString(infoStyle.Render(m.installPath)) - - // Show created files - infoBox.WriteString("\n\n") - infoBox.WriteString(tierLabelStyle.Render("Created:")) - infoBox.WriteString("\n") - filesStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00ADD8")) - infoBox.WriteString(filesStyle.Render(" • arc.yaml")) - infoBox.WriteString("\n") - infoBox.WriteString(filesStyle.Render(" • .env")) - infoBox.WriteString("\n") - infoBox.WriteString(filesStyle.Render(" • .gitignore")) - infoBox.WriteString("\n") - infoBox.WriteString(filesStyle.Render(" • .arc/")) - - // Style the info box - infoBoxStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#00ADD8")). - Padding(1, 2). - Width(60) - - content.WriteString(infoBoxStyle.Render(infoBox.String())) - content.WriteString("\n\n") - - // Next steps in a styled section - nextStepsStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#00ADD8")). - MarginTop(1) - content.WriteString(nextStepsStyle.Render("Next Steps:")) - content.WriteString("\n") - - stepStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")). - MarginLeft(2) - - content.WriteString(stepStyle.Render("• Run 'arc workspace run' to start your environment")) - content.WriteString("\n") - content.WriteString(stepStyle.Render("• Run 'arc help' for more commands")) - content.WriteString("\n\n") - - // Footer - content.WriteString(m.renderFooter()) - - return content.String() -} - -// renderFooter renders the bottom control bar with responsive behavior -func (m *initModel) renderFooter() string { - // Controls text varies based on current step - controlsText := m.getControlsText() - - // Version text - versionText := fmt.Sprintf("A.R.C. CLI v%s", version.String()) - - // Style controls - controlsStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#7D7D7D")) - controls := controlsStyle.Render(controlsText) - - // Style version - versionStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#7D7D7D")). - Italic(true) - versionRender := versionStyle.Render(versionText) - - // Calculate spacing - footerWidth := m.termWidth - if footerWidth == 0 { - footerWidth = 80 // Default if not detected - } - - // Use lipgloss to create left-right layout - controlsWidth := lipgloss.Width(controls) - versionWidth := lipgloss.Width(versionRender) - spacingWidth := footerWidth - controlsWidth - versionWidth - - if spacingWidth < 1 { - // Not enough space, just show controls - return controls - } - - spacing := strings.Repeat(" ", spacingWidth) - return controls + spacing + versionRender -} - -// getControlsText returns the appropriate control text based on terminal width and current step -func (m *initModel) getControlsText() string { - // Show ESC option for steps that support going back - showEsc := m.currentStep == StackSelection || m.currentStep == PathSelection - - if m.termWidth >= 80 { - // Full controls - if showEsc { - return "[←/→] Navigate • [Enter] Confirm • [Esc] Back • [q] Quit" - } - return "[←/→] Navigate • [Enter] Confirm • [q] Quit" - } - - // Truncated controls - if showEsc { - return "[←/→] • [Enter] • [Esc] • [q]" - } - return "[←/→] • [Enter] • [q]" -} - -// renderTerminalTooSmall renders a warning for undersized terminals -func (m *initModel) renderTerminalTooSmall() string { - warningStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FF0000")). - Align(lipgloss.Center). - Width(m.termWidth) - - message := "⚠️ Terminal Too Small" - instruction := fmt.Sprintf("Please resize to at least 80×20 (currently %d×%d)", m.termWidth, m.termHeight) - - var content strings.Builder - content.WriteString("\n\n") - content.WriteString(warningStyle.Render(message)) - content.WriteString("\n\n") - - instructionStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#7D7D7D")). - Align(lipgloss.Center). - Width(m.termWidth) - content.WriteString(instructionStyle.Render(instruction)) - content.WriteString("\n\n") - - return content.String() -} - -// renderTierCards renders the horizontal card layout for all tiers -// Accepts optional tierNames to display profile-specific names -func (m *initModel) renderTierCards(tierNames []string) string { - cards := make([]string, len(m.tiers)) - - // Use provided tier names or fallback to tier.Name - for i, tier := range m.tiers { - isSelected := i == m.selectedTierIndex - // Get tier name from provided slice or fallback to tier.Name - tierName := tier.Name - if tierNames != nil && i < len(tierNames) && tierNames[i] != "" { - tierName = tierNames[i] - } - cards[i] = m.renderTierCard(&tier, tierName, isSelected) - } - - // Join cards horizontally with spacing (T090: Improved spacing) - spacing := strings.Repeat(" ", 3) - return lipgloss.JoinHorizontal(lipgloss.Top, cards[0], spacing, cards[1], spacing, cards[2]) -} - -// renderTierCard renders a single tier card with styling -// tierName parameter allows displaying profile-specific tier names -func (m *initModel) renderTierCard(tier *StackTier, tierName string, isSelected bool) string { - cardWidth := 24 - cardHeight := 14 - - // Determine card styling based on state (T091: Enhanced glow effect) - var borderColor lipgloss.Color - var borderStyle lipgloss.Border - var titleColor lipgloss.Color - var backgroundColor lipgloss.Color - - if !tier.Enabled { - // Disabled tier - grey everything - borderColor = lipgloss.Color("#7D7D7D") - borderStyle = lipgloss.NormalBorder() - titleColor = lipgloss.Color("#7D7D7D") - backgroundColor = lipgloss.Color("") - } else if isSelected { - // Selected enabled tier - use tier color with bold border and subtle background - borderColor = tier.Color - borderStyle = lipgloss.ThickBorder() - titleColor = tier.Color - // Subtle background glow (very dark tint of tier color) - backgroundColor = lipgloss.Color("#1A1A1A") - } else { - // Unselected enabled tier - subtle styling - borderColor = lipgloss.Color("#7D7D7D") - borderStyle = lipgloss.NormalBorder() - titleColor = lipgloss.Color("#FFFFFF") - backgroundColor = lipgloss.Color("") - } - - // Build card content - var content strings.Builder - - // Title (tier name) - use provided tierName - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(titleColor). - Width(cardWidth - 2). - Align(lipgloss.Center) - content.WriteString(titleStyle.Render(tierName)) - content.WriteString("\n\n") - - // Description (wrapped) - descStyle := lipgloss.NewStyle(). - Width(cardWidth - 2). - Foreground(lipgloss.Color("#FFFFFF")) - - // Truncate description if too long - desc := tier.Description - if len(desc) > 60 { - desc = desc[:57] + "..." - } - content.WriteString(descStyle.Render(desc)) - content.WriteString("\n\n") - - // Services count or "Coming Soon" - if !tier.Enabled { - comingSoonStyle := lipgloss.NewStyle(). - Italic(true). - Foreground(lipgloss.Color("#7D7D7D")). - Width(cardWidth - 2). - Align(lipgloss.Center) - content.WriteString(comingSoonStyle.Render("(Coming Soon)")) - } else { - servicesStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#7D7D7D")). - Width(cardWidth - 2) - content.WriteString(servicesStyle.Render(fmt.Sprintf("Services: %d", len(tier.Services)))) - } - content.WriteString("\n") - - // System requirements - reqStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#7D7D7D")). - Width(cardWidth - 2) - content.WriteString(reqStyle.Render(fmt.Sprintf("CPU: %d+ cores", tier.MinCPU))) - content.WriteString("\n") - content.WriteString(reqStyle.Render(fmt.Sprintf("RAM: %dG+ GB", tier.MinRAM))) - - // Apply border styling (T091: Enhanced glow effect with background) - cardStyle := lipgloss.NewStyle(). - Border(borderStyle). - BorderForeground(borderColor). - Width(cardWidth). - Height(cardHeight). - Padding(0, 1). - Background(backgroundColor) - - return cardStyle.Render(content.String()) -} - -// renderComingSoonModal renders the modal overlay for disabled tiers -func (m *initModel) renderComingSoonModal() string { - selectedTier := m.tiers[m.selectedTierIndex] - - modalWidth := 50 - - // Modal content - var content strings.Builder - - // Title - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#00ADD8")). - Width(modalWidth - 4). - Align(lipgloss.Center) - content.WriteString(titleStyle.Render("🚧 Coming Soon")) - content.WriteString("\n\n") - - // Tier name - tierStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(selectedTier.Color). - Width(modalWidth - 4). - Align(lipgloss.Center) - content.WriteString(tierStyle.Render(selectedTier.Name)) - content.WriteString("\n\n") - - // Message - messageStyle := lipgloss.NewStyle(). - Width(modalWidth - 4). - Align(lipgloss.Center) - content.WriteString(messageStyle.Render("This tier is under development.")) - content.WriteString("\n") - content.WriteString(messageStyle.Render("Stay tuned!")) - content.WriteString("\n\n") - - // Instructions - instructionStyle := lipgloss.NewStyle(). - Italic(true). - Foreground(lipgloss.Color("#7D7D7D")). - Width(modalWidth - 4). - Align(lipgloss.Center) - content.WriteString(instructionStyle.Render("Press Enter or Escape to continue")) - - // Apply modal box styling (T092: Refined modal appearance) - modalStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#00ADD8")). - Background(lipgloss.Color("#1A1A1A")). - Width(modalWidth). - Padding(2, 3). - Align(lipgloss.Center) - - return modalStyle.Render(content.String()) -} - -// initCmd represents the init command var initCmd = &cobra.Command{ Use: "init", Short: "Initialize a new A.R.C. environment", @@ -1048,92 +27,45 @@ Choose from three power tiers inspired by Dragon Ball Super: The wizard will guide you through stack selection and environment setup.`, Run: func(cmd *cobra.Command, args []string) { - // New UI engine path (opt-out with ARC_USE_LEGACY_UI env var) - if os.Getenv("ARC_USE_LEGACY_UI") == "" { - if err := renderInitWizardWithNewUI(); err != nil { - GetLogger().Error("Failed to render InitWizardView", "error", err) - // Fall through to legacy rendering - } else { - return - } + loader, err := uithemeldr.NewLoader() + if err != nil { + fmt.Fprintf(os.Stderr, "error: failed to load themes: %v\n", err) + return } - - // Legacy rendering path - model := initialInitModel() - p := tea.NewProgram(model) - if _, err := p.Run(); err != nil { - fmt.Fprintf(os.Stderr, "Error running wizard: %v\n", err) + cfg := newengine.Config{ + Mode: newengine.ModeFocused, + Views: []newengine.View{uiview.NewInitWizard()}, + Title: "Initialize A.R.C.", + Subtitle: "Workspace setup wizard", + Loader: loader, + } + if startErr := newengine.Start(cfg); startErr != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", startErr) } - // Initialization happens inside the TUI during the Installation step - // The completion screen shows success/error status }, } -// renderInitWizardWithNewUI renders the init wizard using InitWizardView (T359). -func renderInitWizardWithNewUI() error { - profileCtx := profiles.GetDefaultProfileContext() - factory := ui.NewComponentFactory(profileCtx, components.BorderTierClassic) - view := views.NewInitWizardView(factory) - return engine.Render(engine.RenderConfig{ - View: view, - Mode: engine.TUIMode, - }) -} - -// performInitialization creates the actual workspace using the workspace.Initializer func performInitialization(installPath, tierID string) error { - // Convert to absolute path absPath, err := filepath.Abs(installPath) if err != nil { return fmt.Errorf("failed to resolve path: %w", err) } - // Create the target directory if it doesn't exist if mkdirErr := os.MkdirAll(absPath, 0o755); mkdirErr != nil { return fmt.Errorf("failed to create directory %s: %w", absPath, mkdirErr) } - // Create filesystem and state repository (Factory Pattern) fs := afero.NewOsFs() stateDir := filepath.Join(absPath, ".arc", "state") stateRepo := local.NewStateRepository(fs, stateDir) - - // Create initializer with dependencies (Dependency Injection) initializer := workspace.NewInitializer(fs, stateRepo) - // Initialize workspace with selected tier opts := workspace.InitializeOptions{ Path: absPath, - Force: false, // Wizard doesn't support force mode + Force: false, SkipGitignore: false, - Tier: tierID, // Pass the selected tier + Tier: tierID, } return initializer.Initialize(opts) } - -// getProfileTierInfo retrieves the profile-specific tier name and profile name -// Returns the tier name (either profile-specific or fallback) and profile name -func (m *initModel) getProfileTierInfo(fallbackTierName string) (tierName, profileName string) { - tierName = fallbackTierName - - if m.selectedProfile == "" { - return - } - - profileCtx := profiles.LoadProfileContext(m.selectedProfile) - if profileCtx == nil { - return - } - - if profile := profileCtx.Profile(); profile != nil { - profileName = profile.Name - } - - // Get profile-specific tier name - if tierNameFromProfile, err := profileCtx.GetTierName(m.selectedTierIndex); err == nil { - tierName = tierNameFromProfile - } - - return -} diff --git a/pkg/cli/init_profile_ui.go b/pkg/cli/init_profile_ui.go deleted file mode 100644 index 6e627e0..0000000 --- a/pkg/cli/init_profile_ui.go +++ /dev/null @@ -1,291 +0,0 @@ -package cli - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" -) - -// TECHNICAL DEBT: Hardcoded Colors in Profile Selection Wizard -// -// This file contains 19 instances of hardcoded colors (lipgloss.Color("#XXXXXX")). -// These colors are intentionally not using ProfileContext because: -// -// 1. Bootstrap Problem: The profile selection wizard runs BEFORE the user has -// selected a profile, creating a chicken-and-egg situation where ProfileContext -// cannot be initialized yet. -// -// 2. Neutral Theming: The wizard itself uses neutral colors (Dracula-inspired palette) -// to avoid biasing the user toward any specific profile theme during selection. -// -// 3. Scope Limitation: Refactoring this wizard to use ProfileContext would require: -// - Implementing a "pre-selection" theme system -// - Major restructuring of the init command flow -// - Adding wizard-specific theme management -// This is out of scope for 016-ui-layout-fix (which focuses on dashboard layout). -// -// Future Work (v2.0.0+): -// - Consider using Enterprise profile as default theme for wizard -// - Add live preview of selected profile's colors in detail pane -// - Refactor wizard to accept optional ProfileContext parameter -// -// Related: specs/016-ui-layout-fix/checklists/profile-integration-checklist.md -// Issue: #XXX (track for future refactoring) -// - -// getProfileEmoji returns the emoji icon for a profile -func getProfileEmoji(profileID string) string { - emojiMap := map[string]string{ - "enterprise": "🏢", - "jedi": "⚔️", - "saiyan": "🔥", - "pirate": "⚓", - "shinobi": "🥷", - "pokemon": "⚡", - "triforce": "🛡️", - "crystal": "💎", - "bending": "🌊", - "horcrux": "📚", - } - - if emoji, exists := emojiMap[profileID]; exists { - return emoji - } - return "✨" // Default emoji for unknown profiles -} - -// renderProfileList renders the left panel with scrollable profile list -func (m *initModel) renderProfileList() string { - const ( - listWidth = 28 - listHeight = 18 - visibleItems = 14 // Number of profiles visible at once - scrollPadding = 2 // Items to show above/below selection - ) - - var listContent strings.Builder - - // Calculate scroll window - startIdx := 0 - endIdx := len(m.profiles) - - // If we have more profiles than visible space, calculate scroll window - if len(m.profiles) > visibleItems { - // Keep selected item in view with padding - if m.selectedProfileIndex < scrollPadding { - startIdx = 0 - endIdx = visibleItems - } else if m.selectedProfileIndex >= len(m.profiles)-scrollPadding { - endIdx = len(m.profiles) - startIdx = endIdx - visibleItems - } else { - startIdx = m.selectedProfileIndex - scrollPadding - endIdx = startIdx + visibleItems - } - } - - // Render scroll indicator at top if not at start - if startIdx > 0 { - listContent.WriteString(lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6272A4")). - Align(lipgloss.Center). - Width(listWidth - 4). - Render("⬆ ⬆ ⬆")) - listContent.WriteString("\n") - } - - // Render visible profile items - for i := startIdx; i < endIdx && i < len(m.profiles); i++ { - profile := m.profiles[i] - emoji := getProfileEmoji(profile.ID) - isSelected := i == m.selectedProfileIndex - - // Item content with emoji and name - itemText := fmt.Sprintf("%s %s", emoji, profile.Name) - - // Style based on selection - var itemStyle lipgloss.Style - if isSelected { - // Selected item: highlighted with arrow - itemStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFD700")). - Background(lipgloss.Color("#2A2A2A")). - Bold(true). - Width(listWidth-4). - Padding(0, 1) - itemText = "▶ " + itemText - } else { - // Normal item - itemStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#F8F8F2")). - Width(listWidth-4). - Padding(0, 1) - itemText = " " + itemText - } - - listContent.WriteString(itemStyle.Render(itemText)) - listContent.WriteString("\n") - } - - // Render scroll indicator at bottom if not at end - if endIdx < len(m.profiles) { - listContent.WriteString(lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6272A4")). - Align(lipgloss.Center). - Width(listWidth - 4). - Render("⬇ ⬇ ⬇")) - } - - // Render scroll position indicator - scrollInfo := fmt.Sprintf("(%d/%d)", m.selectedProfileIndex+1, len(m.profiles)) - listContent.WriteString("\n") - listContent.WriteString(lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6272A4")). - Align(lipgloss.Center). - Width(listWidth - 4). - Render(scrollInfo)) - - // Wrap in panel with border - panelStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#6272A4")). - Width(listWidth). - Height(listHeight). - Padding(0, 1) - - // Add title to panel - titleStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#00ADD8")). - Bold(true). - Align(lipgloss.Center). - Width(listWidth - 4) - - header := titleStyle.Render("PROFILES") - separator := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6272A4")). - Render(strings.Repeat("─", listWidth-4)) - - panelContent := lipgloss.JoinVertical( - lipgloss.Left, - header, - separator, - listContent.String(), - ) - - return panelStyle.Render(panelContent) -} - -// renderProfilePreview renders the right panel with selected profile details -func (m *initModel) renderProfilePreview() string { - const ( - previewWidth = 50 - previewHeight = 18 - ) - - if m.selectedProfileIndex >= len(m.profiles) { - return "" - } - - selectedProfile := m.profiles[m.selectedProfileIndex] - emoji := getProfileEmoji(selectedProfile.ID) - - var previewContent strings.Builder - - // Profile name with emoji - nameStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#50FA7B")). - Bold(true). - Align(lipgloss.Center). - Width(previewWidth - 4) - - previewContent.WriteString(nameStyle.Render(fmt.Sprintf("%s %s", emoji, strings.ToUpper(selectedProfile.Name)))) - previewContent.WriteString("\n") - previewContent.WriteString(lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6272A4")). - Align(lipgloss.Center). - Width(previewWidth - 4). - Render(strings.Repeat("═", previewWidth-6))) - previewContent.WriteString("\n\n") - - // Description - descStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#F8F8F2")). - Align(lipgloss.Left). - Width(previewWidth - 4) - - previewContent.WriteString(descStyle.Render(selectedProfile.Description)) - previewContent.WriteString("\n\n") - - // Tier names section - tierTitleStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#8BE9FD")). - Bold(true) - - previewContent.WriteString(tierTitleStyle.Render("Tier Names:")) - previewContent.WriteString("\n") - - // Render tier names in a clean box - tierBoxStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#44475A")). - Padding(0, 1). - Width(previewWidth - 6). - Foreground(lipgloss.Color("#F8F8F2")) - - tierContent := fmt.Sprintf("1️⃣ %s\n2️⃣ %s\n3️⃣ %s", - selectedProfile.TierNames[0], - selectedProfile.TierNames[1], - selectedProfile.TierNames[2]) - - previewContent.WriteString(tierBoxStyle.Render(tierContent)) - previewContent.WriteString("\n\n") - - // Theme info - themeStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#BD93F9")). - Italic(true) - - previewContent.WriteString(themeStyle.Render(fmt.Sprintf("Theme: %s", selectedProfile.ThemeID))) - previewContent.WriteString("\n\n") - - // Call to action - ctaStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFD700")). - Bold(true). - Align(lipgloss.Center). - Width(previewWidth - 4) - - previewContent.WriteString(ctaStyle.Render("[ Press Enter to Select ]")) - - // Wrap in panel with border - panelStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#6272A4")). - Width(previewWidth). - Height(previewHeight). - Padding(1, 1). - MarginLeft(2) - - // Add title to panel - titleStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#00ADD8")). - Bold(true). - Align(lipgloss.Center). - Width(previewWidth - 4) - - header := titleStyle.Render("PREVIEW") - separator := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6272A4")). - Render(strings.Repeat("─", previewWidth-4)) - - panelContent := lipgloss.JoinVertical( - lipgloss.Left, - header, - separator, - "", - previewContent.String(), - ) - - return panelStyle.Render(panelContent) -} diff --git a/pkg/cli/init_test.go b/pkg/cli/init_test.go index 5fcc8ab..beef080 100644 --- a/pkg/cli/init_test.go +++ b/pkg/cli/init_test.go @@ -1,283 +1,23 @@ package cli import ( - "strings" "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) -// TestRenderTierCard_SelectedCardStyling tests that selected cards include tier color -func TestRenderTierCard_SelectedCardStyling(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - - // Test selected enabled tier (Super Saiyan) - tier := &TierSuperSaiyan - card := m.renderTierCard(tier, tier.Name, true) - - // Selected card should be rendered - if card == "" { - t.Error("Selected card should not be empty") - } - - // Should contain tier name - if !strings.Contains(card, tier.Name) { - t.Errorf("Selected card should contain tier name %q", tier.Name) - } - - // Should contain part of description (may be truncated) - if !strings.Contains(card, "standard") || !strings.Contains(card, "developer") { - t.Error("Selected card should contain tier description content") - } - - // Should contain services count - if !strings.Contains(card, "Services: 4") { - t.Error("Selected card should show correct services count") - } - - // Should contain CPU requirements - if !strings.Contains(card, "CPU: 2+ cores") { - t.Error("Selected card should contain CPU requirements") - } - - // Should contain RAM requirements - if !strings.Contains(card, "RAM: 4G+ GB") { - t.Error("Selected card should contain RAM requirements") - } -} - -// TestRenderTierCard_DisabledCardStyling tests that disabled cards use grey styling -func TestRenderTierCard_DisabledCardStyling(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - - // Test disabled tier (Super Saiyan Blue) - tier := &TierSuperSaiyanBlue - card := m.renderTierCard(tier, tier.Name, false) - - // Disabled card should be rendered - if card == "" { - t.Error("Disabled card should not be empty") - } - - // Should contain tier name - if !strings.Contains(card, tier.Name) { - t.Errorf("Disabled card should contain tier name %q", tier.Name) - } - - // Should contain "Coming Soon" indicator - if !strings.Contains(card, "(Coming Soon)") { - t.Error("Disabled card should contain '(Coming Soon)' indicator") - } - - // Should contain CPU requirements - if !strings.Contains(card, "CPU: 4+ cores") { - t.Error("Disabled card should contain CPU requirements") - } - - // Should contain RAM requirements - if !strings.Contains(card, "RAM: 8G+ GB") { - t.Error("Disabled card should contain RAM requirements") - } - - // Should NOT contain services count (since it's disabled) - if strings.Contains(card, "Services:") { - t.Error("Disabled card should not contain 'Services:' text") - } -} - -// TestRenderTierCard_UnselectedCardStyling tests unselected enabled tier styling -func TestRenderTierCard_UnselectedCardStyling(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - - // Test unselected enabled tier - tier := &TierSuperSaiyan - card := m.renderTierCard(tier, tier.Name, false) - - // Unselected card should be rendered - if card == "" { - t.Error("Unselected card should not be empty") - } - - // Should contain tier name - if !strings.Contains(card, tier.Name) { - t.Errorf("Unselected card should contain tier name %q", tier.Name) - } - - // Should contain services count (enabled tier) - if !strings.Contains(card, "Services: 4") { - t.Error("Unselected enabled card should show services count") - } -} - -// TestRenderTierCard_AllTiers tests rendering all three tier types -func TestRenderTierCard_AllTiers(t *testing.T) { - testCases := []struct { - name string - tier *StackTier - isSelected bool - wantName string - wantCPU string - wantRAM string - }{ - { - name: "Super Saiyan selected", - tier: &TierSuperSaiyan, - isSelected: true, - wantName: "Super Saiyan", - wantCPU: "CPU: 2+ cores", - wantRAM: "RAM: 4G+ GB", - }, - { - name: "Super Saiyan Blue unselected disabled", - tier: &TierSuperSaiyanBlue, - isSelected: false, - wantName: "Super Saiyan Blue", - wantCPU: "CPU: 4+ cores", - wantRAM: "RAM: 8G+ GB", - }, - { - name: "Ultra Instinct unselected disabled", - tier: &TierUltraInstinct, - isSelected: false, - wantName: "Ultra Instinct", - wantCPU: "CPU: 8+ cores", - wantRAM: "RAM: 16G+ GB", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - - card := m.renderTierCard(tc.tier, tc.tier.Name, tc.isSelected) - - if card == "" { - t.Error("Card should not be empty") - } - - if !strings.Contains(card, tc.wantName) { - t.Errorf("Card should contain name %q", tc.wantName) - } - - if !strings.Contains(card, tc.wantCPU) { - t.Errorf("Card should contain CPU requirements %q", tc.wantCPU) - } - - if !strings.Contains(card, tc.wantRAM) { - t.Errorf("Card should contain RAM requirements %q", tc.wantRAM) - } - }) - } -} - -// TestRenderTierCard_ContentFormatting tests that card content is properly formatted -func TestRenderTierCard_ContentFormatting(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - - tier := &TierSuperSaiyan - card := m.renderTierCard(tier, tier.Name, true) - - // Should not be empty - if card == "" { - t.Error("Card content should not be empty") - } - - // Check that all expected content sections are present - expectedContent := []string{ - tier.Name, - "Services: 4", - "CPU: 2+ cores", - "RAM: 4G+ GB", - } - - for _, expected := range expectedContent { - if !strings.Contains(card, expected) { - t.Errorf("Card should contain %q", expected) - } - } -} - -// TestRenderTierCards tests the horizontal card layout -func TestRenderTierCards(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.selectedTierIndex = 0 - - cards := m.renderTierCards(nil) - - // Should render all three tier names - if !strings.Contains(cards, "Super Saiyan") { - t.Error("Should contain Super Saiyan tier") - } - if !strings.Contains(cards, "Super Saiyan Blue") { - t.Error("Should contain Super Saiyan Blue tier") - } - if !strings.Contains(cards, "Ultra Instinct") { - t.Error("Should contain Ultra Instinct tier") - } - - // Should not be empty - if cards == "" { - t.Error("Tier cards output should not be empty") - } -} - -// TestRenderTierCard_LongDescription tests description truncation -func TestRenderTierCard_LongDescription(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - - // Create a tier with a very long description - longTier := &StackTier{ - ID: "test-tier", - Name: "Test Tier", - Description: "This is a very long description that exceeds the maximum character limit and should be truncated with an ellipsis to fit within the card boundaries.", - Services: []string{"Service1", "Service2"}, - MinCPU: 2, - MinRAM: 4, - Enabled: true, - Color: lipgloss.Color("#FFD700"), - } - - card := m.renderTierCard(longTier, longTier.Name, false) - - // Should contain ellipsis if description was truncated - if len(longTier.Description) > 60 && !strings.Contains(card, "...") { - t.Error("Long description should be truncated with ellipsis") - } -} - -// TestInitCommand_Exists tests that init command is registered func TestInitCommand_Exists(t *testing.T) { cmd := rootCmd foundCmd, _, err := cmd.Find([]string{"init"}) if err != nil { t.Fatalf("Init command not found: %v", err) } - if foundCmd == nil { t.Fatal("Init command is nil") } - if foundCmd.Use != "init" { t.Errorf("Init command Use = %q, want %q", foundCmd.Use, "init") } } -// TestInitCommand_HasShortDescription tests that init command has proper documentation func TestInitCommand_HasShortDescription(t *testing.T) { cmd := rootCmd foundCmd, _, err := cmd.Find([]string{"init"}) @@ -285,2157 +25,14 @@ func TestInitCommand_HasShortDescription(t *testing.T) { t.Skip("Init command not registered") return } - if foundCmd.Short == "" { t.Error("Init command should have a short description") } - - if foundCmd.Long == "" { - t.Error("Init command should have a long description") - } -} - -// TestInitCommand_HelpText tests that help text displays correctly -func TestInitCommand_HelpText(t *testing.T) { - cmd := rootCmd - foundCmd, _, err := cmd.Find([]string{"init"}) - if err != nil { - t.Skip("Init command not registered") - return - } - - // Check short description is descriptive - if len(foundCmd.Short) < 10 { - t.Error("Init command short description should be more descriptive") - } - - // Check long description mentions Dragon Ball Super - if !strings.Contains(foundCmd.Long, "Dragon Ball") && !strings.Contains(foundCmd.Long, "Super Saiyan") { - t.Error("Init command long description should mention Dragon Ball Super tier metaphor") - } - - // Check long description mentions the tiers - longDesc := foundCmd.Long - if !strings.Contains(longDesc, "Super Saiyan") { - t.Error("Long description should mention 'Super Saiyan' tier") - } - - // Check description is helpful - if !strings.Contains(longDesc, "wizard") && !strings.Contains(longDesc, "Initialize") { - t.Error("Long description should mention wizard or initialization") - } -} - -// TestInitCommand_IsCallable tests that the command can be invoked -func TestInitCommand_IsCallable(t *testing.T) { - cmd := rootCmd - foundCmd, _, err := cmd.Find([]string{"init"}) - if err != nil { - t.Skip("Init command not registered") - return - } - - // Command should have a Run or RunE function - if foundCmd.Run == nil && foundCmd.RunE == nil { - t.Error("Init command should have a Run or RunE function") - } - - // Command should not have any required args - if foundCmd.Args != nil { - // If Args is set, it should allow no arguments - testErr := foundCmd.Args(foundCmd, []string{}) - if testErr != nil { - t.Error("Init command should not require arguments") - } - } -} - -// TestInitCommand_NoFlags tests that command doesn't have unexpected flags -func TestInitCommand_NoFlags(t *testing.T) { - cmd := rootCmd - foundCmd, _, err := cmd.Find([]string{"init"}) - if err != nil { - t.Skip("Init command not registered") - return - } - - // Init command should not have flags in Phase 1 (interactive wizard only) - // Future phases might add --tier, --path, --yes flags - if foundCmd.Flags().HasFlags() { - // This is informational - flags might be added later - t.Logf("Init command has flags (this may be expected in future phases)") - } -} - -// TestInitCommand_InRootCommand tests that init is accessible from root -func TestInitCommand_InRootCommand(t *testing.T) { - // Check that rootCmd has init as a subcommand - hasInitCmd := false - for _, cmd := range rootCmd.Commands() { - if cmd.Name() == "init" { - hasInitCmd = true - break - } - } - - if !hasInitCmd { - t.Error("Init command should be registered in root command") - } -} - -// TestInitCommand_UsageString tests the usage string format -func TestInitCommand_UsageString(t *testing.T) { - cmd := rootCmd - foundCmd, _, err := cmd.Find([]string{"init"}) - if err != nil { - t.Skip("Init command not registered") - return - } - - // Usage should mention the command name - if foundCmd.Use == "" { - t.Error("Init command should have a Use string") - } - - // Use should start with "init" - if !strings.HasPrefix(foundCmd.Use, "init") { - t.Errorf("Init command Use should start with 'init', got %q", foundCmd.Use) - } -} - -// TestInitialInitModel tests the initial model creation -func TestInitialInitModel(t *testing.T) { - m := initialInitModel() - - // Check initial state - should start with ProfileSelection - if m.currentStep != ProfileSelection { - t.Errorf("Initial step should be ProfileSelection, got %v", m.currentStep) - } - - if m.selectedTierIndex != 0 { - t.Errorf("Initial selected tier index should be 0, got %d", m.selectedTierIndex) - } - - if len(m.tiers) != 3 { - t.Errorf("Should have 3 tiers, got %d", len(m.tiers)) - } - - if m.installPath != "./" { - t.Errorf("Initial install path should be './'. got %q", m.installPath) - } - - if m.showModal { - t.Error("Modal should not be shown initially") - } -} - -// TestRenderStackSelection tests the stack selection screen rendering -func TestRenderStackSelection(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = StackSelection - m.selectedProfile = "enterprise" // Set a selected profile for proper rendering - - output := m.renderStackSelection() - - // Should not be empty - if output == "" { - t.Error("Stack selection output should not be empty") - } - - // Should contain title - if !strings.Contains(output, "Choose Your Power Tier") { - t.Error("Should contain title 'Choose Your Power Tier'") - } - - // Should contain footer with controls - if !strings.Contains(output, "Navigate") || !strings.Contains(output, "Confirm") { - t.Error("Should contain navigation controls in footer") - } - - // Should contain version - if !strings.Contains(output, "A.R.C. CLI") { - t.Error("Should contain A.R.C. CLI version in footer") - } -} - -// TestRenderStackSelection_WithModal tests stack selection with modal displayed -func TestRenderStackSelection_WithModal(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.showModal = true - - output := m.renderStackSelection() - - // Should contain modal elements - if !strings.Contains(output, "Coming Soon") { - t.Error("Should contain 'Coming Soon' modal title") - } - - // Should contain modal instructions - if !strings.Contains(output, "Press Enter or Escape") { - t.Error("Should contain modal dismissal instructions") - } -} - -// TestRenderPathSelection tests the path selection screen rendering -func TestRenderPathSelection(t *testing.T) { - testCases := []struct { - name string - installPath string - wantPath string - }{ - { - name: "Default path", - installPath: "./", - wantPath: "./", - }, - { - name: "Custom path", - installPath: "/home/user/arc", - wantPath: "/home/user/arc", - }, - { - name: "Relative path", - installPath: "../projects/my-arc", - wantPath: "../projects/my-arc", - }, - { - name: "Empty path", - installPath: "", - wantPath: "", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = PathSelection - m.installPath = tc.installPath - - output := m.renderPathSelection() - - // Should not be empty - if output == "" { - t.Error("Path selection output should not be empty") - } - - // Should contain title - if !strings.Contains(output, "Installation Path") { - t.Error("Should contain title 'Installation Path'") - } - - // Should contain the install path with cursor - if tc.installPath != "" && !strings.Contains(output, tc.wantPath) { - t.Errorf("Should contain install path %q", tc.wantPath) - } - - // Should contain cursor indicator - if !strings.Contains(output, "▌") { - t.Error("Should contain cursor indicator") - } - - // Should contain hint text - if !strings.Contains(output, "Press Enter to continue") { - t.Error("Should contain hint text") - } - - // Should contain footer - if !strings.Contains(output, "A.R.C. CLI") { - t.Error("Should contain footer with version") - } - }) - } -} - -// TestRenderInstallation tests the installation progress screen rendering -func TestRenderInstallation(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = Installation - - output := m.renderInstallation() - - // Should not be empty - if output == "" { - t.Error("Installation output should not be empty") - } - - // Should contain title - if !strings.Contains(output, "Setting Up") { - t.Error("Should contain title 'Setting Up'") - } - - // Should contain setup message - if !strings.Contains(output, "Setting up your A.R.C. environment") { - t.Error("Should contain setup message") - } - - // Should contain progress indicator text - if !strings.Contains(output, "This will only take a moment") { - t.Error("Should contain progress indicator text") - } - - // Should contain footer - if !strings.Contains(output, "A.R.C. CLI") { - t.Error("Should contain footer with version") - } -} - -// TestRenderCompletion tests the completion screen rendering -func TestRenderCompletion(t *testing.T) { - testCases := []struct { - name string - selectedTierIndex int - installPath string - wantTierName string - }{ - { - name: "Super Saiyan tier", - selectedTierIndex: 0, - installPath: "./", - wantTierName: "Super Saiyan", - }, - { - name: "Custom path", - selectedTierIndex: 0, - installPath: "/home/user/my-arc", - wantTierName: "Super Saiyan", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = Completion - m.selectedTierIndex = tc.selectedTierIndex - m.installPath = tc.installPath - - output := m.renderCompletion() - - // Should not be empty - if output == "" { - t.Error("Completion output should not be empty") - } - - // Should contain success message - if !strings.Contains(output, "Setup Complete") { - t.Error("Should contain 'Setup Complete' message") - } - - // Should contain tier name - if !strings.Contains(output, tc.wantTierName) { - t.Errorf("Should contain tier name %q", tc.wantTierName) - } - - // Should contain installation path - if !strings.Contains(output, tc.installPath) { - t.Errorf("Should contain installation path %q", tc.installPath) - } - - // Should contain next steps - if !strings.Contains(output, "Next Steps") { - t.Error("Should contain 'Next Steps' section") - } - - if !strings.Contains(output, "arc workspace run") { - t.Error("Should mention 'arc workspace run' command") - } - - if !strings.Contains(output, "arc help") { - t.Error("Should mention 'arc help' command") - } - - // Should contain footer - if !strings.Contains(output, "A.R.C. CLI") { - t.Error("Should contain footer with version") - } - }) - } -} - -// TestRenderTerminalTooSmall tests the terminal size warning -func TestRenderTerminalTooSmall(t *testing.T) { - testCases := []struct { - name string - termWidth int - termHeight int - wantSize string - }{ - { - name: "Width too small", - termWidth: 60, - termHeight: 24, - wantSize: "60×24", - }, - { - name: "Height too small", - termWidth: 120, - termHeight: 15, - wantSize: "120×15", - }, - { - name: "Both too small", - termWidth: 70, - termHeight: 18, - wantSize: "70×18", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - m := initialInitModel() - m.termWidth = tc.termWidth - m.termHeight = tc.termHeight - - output := m.renderTerminalTooSmall() - - // Should not be empty - if output == "" { - t.Error("Terminal too small warning should not be empty") - } - - // Should contain warning message - if !strings.Contains(output, "Terminal Too Small") { - t.Error("Should contain 'Terminal Too Small' warning") - } - - // Should contain minimum size requirement - if !strings.Contains(output, "80×20") { - t.Error("Should mention minimum terminal size 80×20") - } - - // Should contain current size - if !strings.Contains(output, tc.wantSize) { - t.Errorf("Should contain current terminal size %q", tc.wantSize) - } - }) - } -} - -// TestView_Dispatcher tests the View method dispatching to correct render method -func TestView_Dispatcher(t *testing.T) { - testCases := []struct { - name string - currentStep wizardStep - wantContent string - }{ - { - name: "Stack selection step", - currentStep: StackSelection, - wantContent: "Choose Your Power Tier", - }, - { - name: "Path selection step", - currentStep: PathSelection, - wantContent: "Installation Path", - }, - { - name: "Installation step", - currentStep: Installation, - wantContent: "Setting Up", - }, - { - name: "Completion step", - currentStep: Completion, - wantContent: "Setup Complete", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = tc.currentStep - - output := m.View() - - // Should contain expected content for the step - if !strings.Contains(output, tc.wantContent) { - t.Errorf("View for %v should contain %q", tc.currentStep, tc.wantContent) - } - }) - } -} - -// TestRenderFooter_Responsiveness tests footer rendering at different terminal widths -func TestRenderFooter_Responsiveness(t *testing.T) { - testCases := []struct { - name string - termWidth int - wantFullControls bool - wantVersion bool - mustContain []string - mustNotContain []string - }{ - { - name: "Width 60 - truncated controls", - termWidth: 60, - wantFullControls: false, - wantVersion: true, - mustContain: []string{"A.R.C. CLI"}, - mustNotContain: []string{}, - }, - { - name: "Width 70 - truncated controls", - termWidth: 70, - wantFullControls: false, - wantVersion: true, - mustContain: []string{"A.R.C. CLI"}, - mustNotContain: []string{}, - }, - { - name: "Width 80 - full controls", - termWidth: 80, - wantFullControls: true, - wantVersion: true, - mustContain: []string{"Navigate", "Confirm", "Quit", "A.R.C. CLI"}, - mustNotContain: []string{}, - }, - { - name: "Width 100 - full controls", - termWidth: 100, - wantFullControls: true, - wantVersion: true, - mustContain: []string{"Navigate", "Confirm", "Quit", "A.R.C. CLI"}, - mustNotContain: []string{}, - }, - { - name: "Width 120 - full controls", - termWidth: 120, - wantFullControls: true, - wantVersion: true, - mustContain: []string{"Navigate", "Confirm", "Quit", "A.R.C. CLI"}, - mustNotContain: []string{}, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - m := initialInitModel() - m.termWidth = tc.termWidth - m.termHeight = 40 - - footer := m.renderFooter() - - // Should not be empty - if footer == "" { - t.Error("Footer should not be empty") - } - - // Check version is always present - if tc.wantVersion && !strings.Contains(footer, "A.R.C. CLI") { - t.Error("Footer should always contain 'A.R.C. CLI' version") - } - - // Check for required content - for _, content := range tc.mustContain { - if !strings.Contains(footer, content) { - t.Errorf("Footer should contain %q", content) - } - } - - // Check for content that must not be present - for _, content := range tc.mustNotContain { - if strings.Contains(footer, content) { - t.Errorf("Footer should not contain %q", content) - } - } - - // Check full controls at width >= 80 - if tc.wantFullControls { - if !strings.Contains(footer, "Navigate") { - t.Error("Footer should contain 'Navigate' at width >= 80") - } - if !strings.Contains(footer, "Confirm") { - t.Error("Footer should contain 'Confirm' at width >= 80") - } - if !strings.Contains(footer, "Quit") { - t.Error("Footer should contain 'Quit' at width >= 80") - } - } - }) - } -} - -// TestRenderFooter_VersionDisplay tests that version is displayed when there's space -func TestRenderFooter_VersionDisplay(t *testing.T) { - testCases := []struct { - name string - termWidth int - expectVersion bool - }{ - {"Very narrow terminal", 40, false}, // Too narrow, version won't fit - {"Narrow terminal", 60, true}, // Should have space for version - {"Standard terminal", 80, true}, // Definitely has space - {"Wide terminal", 120, true}, // Plenty of space - {"Very wide terminal", 200, true}, // Lots of space - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - m := initialInitModel() - m.termWidth = tc.termWidth - m.termHeight = 40 - - footer := m.renderFooter() - - // Check version presence based on expectation - hasVersion := strings.Contains(footer, "A.R.C. CLI") - if tc.expectVersion && !hasVersion { - t.Errorf("Footer should contain version at width %d", tc.termWidth) - } - if tc.expectVersion && !strings.Contains(footer, "v") { - t.Error("Footer should contain version number indicator 'v'") - } - }) - } -} - -// TestRenderFooter_ControlsTruncation tests control text truncation behavior -func TestRenderFooter_ControlsTruncation(t *testing.T) { - m := initialInitModel() - m.termHeight = 40 - - // Test at boundary - width < 80 should truncate - m.termWidth = 79 - footerNarrow := m.renderFooter() - - // Test at boundary - width >= 80 should show full - m.termWidth = 80 - footerWide := m.renderFooter() - - // Full controls should have more content than truncated - if len(footerWide) <= len(footerNarrow) { - t.Error("Full footer should be longer than truncated footer") - } - - // Full footer should contain detailed control labels - if !strings.Contains(footerWide, "Navigate") { - t.Error("Wide footer should contain 'Navigate'") - } - - if !strings.Contains(footerWide, "Confirm") { - t.Error("Wide footer should contain 'Confirm'") - } - - if !strings.Contains(footerWide, "Quit") { - t.Error("Wide footer should contain 'Quit'") - } } -// TestRenderFooter_Alignment tests footer left-right alignment -func TestRenderFooter_Alignment(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - - footer := m.renderFooter() - - // Should contain spacing (controls on left, version on right) - if !strings.Contains(footer, " ") { - t.Error("Footer should contain spacing between controls and version") - } - - // Controls should appear before version in the output - controlsIndex := strings.Index(footer, "Navigate") - versionIndex := strings.Index(footer, "A.R.C. CLI") - - if controlsIndex < 0 { - t.Error("Footer should contain controls") - } - - if versionIndex < 0 { - t.Error("Footer should contain version") - } - - if controlsIndex >= versionIndex { - t.Error("Controls should appear before version in footer") - } -} - -// TestRenderFooter_ZeroWidth tests footer with zero or default width -func TestRenderFooter_ZeroWidth(t *testing.T) { - m := initialInitModel() - m.termWidth = 0 // Zero width - should use default - m.termHeight = 40 - - footer := m.renderFooter() - - // Should still render something (defaults to 80) - if footer == "" { - t.Error("Footer should render with default width when termWidth is 0") - } - - // Should contain version - if !strings.Contains(footer, "A.R.C. CLI") { - t.Error("Footer should contain version even with zero width") - } -} - -// TestRenderFooter_ConsistentFormatting tests footer formatting consistency -func TestRenderFooter_ConsistentFormatting(t *testing.T) { - m := initialInitModel() - m.termWidth = 100 - m.termHeight = 40 - - footer := m.renderFooter() - - // Should use bullet separator - if !strings.Contains(footer, "•") { - t.Error("Footer should use '•' as separator between controls") - } - - // Should use brackets for keys - if !strings.Contains(footer, "[") || !strings.Contains(footer, "]") { - t.Error("Footer should use brackets for key indicators") - } -} - -// ============================================================================= -// Phase 7: State Machine Testing (T070-T074) -// ============================================================================= - -// TestStateMachine_StackSelectionToPathSelection tests valid transition from StackSelection to PathSelection -func TestStateMachine_StackSelectionToPathSelection(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = StackSelection // Set to StackSelection state - - // Ensure we're on an enabled tier (Super Saiyan at index 0) - m.selectedTierIndex = 0 - if !m.tiers[0].Enabled { - t.Fatal("Test setup error: tier at index 0 should be enabled") - } - - // Verify initial state - if m.currentStep != StackSelection { - t.Fatalf("Initial state = %v, want StackSelection", m.currentStep) - } - - // Press Enter on enabled tier - msg := tea.KeyMsg{Type: tea.KeyEnter} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - // Verify transition to PathSelection - if m.currentStep != PathSelection { - t.Errorf("After Enter on enabled tier: currentStep = %v, want PathSelection", m.currentStep) - } - if m.showModal { - t.Error("After Enter on enabled tier: modal should not be shown") - } -} - -// TestStateMachine_StackSelectionToModal tests that disabled tiers show modal instead of transitioning -func TestStateMachine_StackSelectionToModal(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = StackSelection // Set to StackSelection state - - // Select a disabled tier (Super Saiyan Blue at index 1) - m.selectedTierIndex = 1 - if m.tiers[1].Enabled { - t.Fatal("Test setup error: tier at index 1 should be disabled") - } - - // Verify initial state - if m.currentStep != StackSelection { - t.Fatalf("Initial state = %v, want StackSelection", m.currentStep) - } - if m.showModal { - t.Fatal("Initial modal state should be false") - } - - // Press Enter on disabled tier - msg := tea.KeyMsg{Type: tea.KeyEnter} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - // Verify we stay in StackSelection but modal is shown - if m.currentStep != StackSelection { - t.Errorf("After Enter on disabled tier: currentStep = %v, want StackSelection", m.currentStep) - } - if !m.showModal { - t.Error("After Enter on disabled tier: modal should be shown") - } -} - -// TestStateMachine_PathSelectionToInstallation tests transition from PathSelection to Installation -func TestStateMachine_PathSelectionToInstallation(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = PathSelection - m.installPath = "./my-arc-project" - - // Verify initial state - if m.currentStep != PathSelection { - t.Fatalf("Initial state = %v, want PathSelection", m.currentStep) - } - - // Press Enter with non-empty path - msg := tea.KeyMsg{Type: tea.KeyEnter} - updatedModel, cmd := m.Update(msg) - m = updatedModel.(*initModel) - - // Verify transition to Installation - if m.currentStep != Installation { - t.Errorf("After Enter with path: currentStep = %v, want Installation", m.currentStep) - } - if cmd == nil { - t.Error("After Enter with path: command should be returned for spinner and timer") - } - if m.installationStartTime.IsZero() { - t.Error("After Enter with path: installationStartTime should be set") - } -} - -// TestStateMachine_PathSelectionEmptyPath tests that empty path prevents transition -func TestStateMachine_PathSelectionEmptyPath(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = PathSelection - m.installPath = "" // Empty path - - // Press Enter with empty path - msg := tea.KeyMsg{Type: tea.KeyEnter} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - // Verify we stay in PathSelection - if m.currentStep != PathSelection { - t.Errorf("After Enter with empty path: currentStep = %v, want PathSelection", m.currentStep) - } -} - -// TestStateMachine_InstallationToCompletion tests transition from Installation to Completion -func TestStateMachine_InstallationToCompletion(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = Installation - - // Verify initial state - if m.currentStep != Installation { - t.Fatalf("Initial state = %v, want Installation", m.currentStep) - } - - // Send installationCompleteMsg - msg := installationCompleteMsg{} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - // Verify transition to Completion - if m.currentStep != Completion { - t.Errorf("After installationCompleteMsg: currentStep = %v, want Completion", m.currentStep) - } -} - -// TestStateMachine_AllValidTransitions tests the complete happy path through all states -func TestStateMachine_AllValidTransitions(t *testing.T) { - testCases := []struct { - name string - initialStep wizardStep - action string - expectedStep wizardStep - setupFunc func(*initModel) - validateFunc func(*testing.T, *initModel) - }{ - { - name: "StackSelection to ProfileSelection", - initialStep: StackSelection, - action: "esc", - expectedStep: ProfileSelection, - setupFunc: func(m *initModel) { - m.selectedTierIndex = 0 // Enabled tier - }, - validateFunc: func(t *testing.T, m *initModel) { - if m.showModal { - t.Error("Modal should not be shown after valid transition") - } - }, - }, - { - name: "PathSelection to Installation", - initialStep: PathSelection, - action: "enter", - expectedStep: Installation, - setupFunc: func(m *initModel) { - m.installPath = "./test-path" - }, - validateFunc: func(t *testing.T, m *initModel) { - if m.installationStartTime.IsZero() { - t.Error("Installation start time should be set") - } - }, - }, - { - name: "Installation to Completion", - initialStep: Installation, - action: "complete", - expectedStep: Completion, - setupFunc: func(m *initModel) {}, - validateFunc: func(t *testing.T, m *initModel) {}, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = tc.initialStep - tc.setupFunc(m) - - // Execute action - var updatedModel tea.Model - switch tc.action { - case "enter": - msg := tea.KeyMsg{Type: tea.KeyEnter} - updatedModel, _ = m.Update(msg) - case "esc": - msg := tea.KeyMsg{Type: tea.KeyEsc} - updatedModel, _ = m.Update(msg) - case "complete": - msg := installationCompleteMsg{} - updatedModel, _ = m.Update(msg) - } - - m = updatedModel.(*initModel) - - // Verify expected state - if m.currentStep != tc.expectedStep { - t.Errorf("currentStep = %v, want %v", m.currentStep, tc.expectedStep) - } - - // Run validation - tc.validateFunc(t, m) - }) - } -} - -// TestStateMachine_NoTransitionOnDisabledTier tests that disabled tiers don't trigger state transitions -func TestStateMachine_NoTransitionOnDisabledTier(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - - // Test all disabled tiers - for i, tier := range m.tiers { - if tier.Enabled { - continue - } - - m.selectedTierIndex = i - m.showModal = false - m.currentStep = StackSelection - - // Press Enter - msg := tea.KeyMsg{Type: tea.KeyEnter} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - if m.currentStep != StackSelection { - t.Errorf("Disabled tier %s: should stay in StackSelection, got %v", tier.Name, m.currentStep) - } - if !m.showModal { - t.Errorf("Disabled tier %s: should show modal", tier.Name) - } - } -} - -// TestKeyboardNavigation_RightArrow tests right arrow navigation -func TestKeyboardNavigation_RightArrow(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = StackSelection - m.selectedTierIndex = 0 // Start at Super Saiyan - - // Press right arrow - msg := tea.KeyMsg{Type: tea.KeyRight} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - // Should move to index 1 (Super Saiyan Blue) - if m.selectedTierIndex != 1 { - t.Errorf("After right arrow: selectedTierIndex = %d, want 1", m.selectedTierIndex) - } -} - -// TestKeyboardNavigation_LeftArrow tests left arrow navigation -func TestKeyboardNavigation_LeftArrow(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = StackSelection - m.selectedTierIndex = 2 // Start at Ultra Instinct - - // Press left arrow - msg := tea.KeyMsg{Type: tea.KeyLeft} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - // Should move to index 1 (Super Saiyan Blue) - if m.selectedTierIndex != 1 { - t.Errorf("After left arrow: selectedTierIndex = %d, want 1", m.selectedTierIndex) - } -} - -// TestKeyboardNavigation_RightWrap tests wrapping from last to first tier -func TestKeyboardNavigation_RightWrap(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = StackSelection - m.selectedTierIndex = 2 // Start at Ultra Instinct (last tier) - - // Press right arrow - msg := tea.KeyMsg{Type: tea.KeyRight} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - // Should wrap to index 0 (Super Saiyan) - if m.selectedTierIndex != 0 { - t.Errorf("After right arrow from last tier: selectedTierIndex = %d, want 0 (wrapped)", m.selectedTierIndex) - } -} - -// TestKeyboardNavigation_LeftWrap tests wrapping from first to last tier -func TestKeyboardNavigation_LeftWrap(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = StackSelection - m.selectedTierIndex = 0 // Start at Super Saiyan (first tier) - - // Press left arrow - msg := tea.KeyMsg{Type: tea.KeyLeft} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - // Should wrap to index 2 (Ultra Instinct) - if m.selectedTierIndex != 2 { - t.Errorf("After left arrow from first tier: selectedTierIndex = %d, want 2 (wrapped)", m.selectedTierIndex) - } -} - -// TestKeyboardNavigation_VimKeys tests h/l vim-style navigation -func TestKeyboardNavigation_VimKeys(t *testing.T) { - testCases := []struct { - name string - key string - startIndex int - expectedIndex int - }{ - {"l moves right", "l", 0, 1}, - {"h moves left", "h", 2, 1}, - {"l wraps right", "l", 2, 0}, - {"h wraps left", "h", 0, 2}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = StackSelection - m.selectedTierIndex = tc.startIndex - - // Create key message based on the key string - var msg tea.KeyMsg - switch tc.key { - case "h": - msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}} - case "l": - msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}} - } - - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - if m.selectedTierIndex != tc.expectedIndex { - t.Errorf("After '%s' from index %d: selectedTierIndex = %d, want %d", - tc.key, tc.startIndex, m.selectedTierIndex, tc.expectedIndex) - } - }) - } -} - -// TestKeyboardNavigation_CircularWrapComplete tests complete circular navigation -func TestKeyboardNavigation_CircularWrapComplete(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = StackSelection - m.selectedTierIndex = 0 // Start at Super Saiyan - - expectedSequence := []int{0, 1, 2, 0, 1, 2} // Full circle twice - - for i, expected := range expectedSequence { - if m.selectedTierIndex != expected { - t.Errorf("Step %d: selectedTierIndex = %d, want %d", i, m.selectedTierIndex, expected) - } - - // Press right arrow to move to next - msg := tea.KeyMsg{Type: tea.KeyRight} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - } -} - -// TestKeyboardNavigation_NoNavigationInOtherSteps tests that arrow keys don't affect other steps -func TestKeyboardNavigation_NoNavigationInOtherSteps(t *testing.T) { - testCases := []struct { - name string - step wizardStep - }{ - {"PathSelection", PathSelection}, - {"Installation", Installation}, - {"Completion", Completion}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = tc.step - m.selectedTierIndex = 0 - initialIndex := m.selectedTierIndex - - // Press right arrow - msg := tea.KeyMsg{Type: tea.KeyRight} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - // Index should not change in non-StackSelection steps - if m.selectedTierIndex != initialIndex { - t.Errorf("In step %v: selectedTierIndex changed from %d to %d (should not change)", - tc.step, initialIndex, m.selectedTierIndex) - } - }) - } -} - -// TestModal_AppearsOnDisabledTierSelection tests that modal appears when disabled tier is selected -func TestModal_AppearsOnDisabledTierSelection(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = StackSelection - m.selectedTierIndex = 1 // Super Saiyan Blue (disabled) - - if m.showModal { - t.Fatal("Modal should not be shown initially") - } - - // Press Enter on disabled tier - msg := tea.KeyMsg{Type: tea.KeyEnter} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - // Modal should appear - if !m.showModal { - t.Error("Modal should appear after selecting disabled tier") - } - - // Should stay in StackSelection state - if m.currentStep != StackSelection { - t.Errorf("After selecting disabled tier: currentStep = %v, want StackSelection", m.currentStep) - } -} - -// TestModal_DismissWithEnter tests that Enter key behavior when modal is shown -func TestModal_DismissWithEnter(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = StackSelection - m.showModal = true // Modal is visible - m.selectedTierIndex = 1 // Disabled tier - - // Press Enter while modal is shown on disabled tier - msg := tea.KeyMsg{Type: tea.KeyEnter} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - // Modal should still be shown (Enter on disabled tier shows modal again) - if !m.showModal { - t.Error("Modal should still be shown after pressing Enter on disabled tier") - } - - // Should remain in StackSelection - if m.currentStep != StackSelection { - t.Errorf("After pressing Enter: currentStep = %v, want StackSelection", m.currentStep) - } -} - -// TestModal_DismissWithEscape tests that Escape key dismisses modal -func TestModal_DismissWithEscape(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = StackSelection - m.showModal = true // Modal is visible - m.selectedTierIndex = 2 - - // Press Escape while modal is shown - should dismiss modal - msg := tea.KeyMsg{Type: tea.KeyEscape} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - // Modal should be dismissed (Escape is handled by modal handler) - if m.showModal { - t.Error("Modal should be dismissed after pressing Escape") - } - - // Should remain in StackSelection - if m.currentStep != StackSelection { - t.Errorf("After pressing Escape: currentStep = %v, want StackSelection", m.currentStep) - } -} - -// TestModal_SelectionPreserved tests that tier selection is preserved when modal is shown -func TestModal_SelectionPreserved(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = StackSelection - m.selectedTierIndex = 2 // Ultra Instinct - - initialSelectedIndex := m.selectedTierIndex - - // Open modal by selecting disabled tier - msg := tea.KeyMsg{Type: tea.KeyEnter} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - if !m.showModal { - t.Fatal("Modal should be shown after selecting disabled tier") - } - - // Selection should be preserved - if m.selectedTierIndex != initialSelectedIndex { - t.Errorf("After showing modal: selectedTierIndex = %d, want %d (should be preserved)", - m.selectedTierIndex, initialSelectedIndex) - } -} - -// TestModal_AllDisabledTiers tests modal appears for all disabled tiers -func TestModal_AllDisabledTiers(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - - // Find all disabled tiers and test modal behavior - for i, tier := range m.tiers { - if tier.Enabled { - continue - } - - t.Run(tier.Name, func(t *testing.T) { - m.currentStep = StackSelection - m.selectedTierIndex = i - m.showModal = false - - // Press Enter - msg := tea.KeyMsg{Type: tea.KeyEnter} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - if !m.showModal { - t.Errorf("Modal should appear for disabled tier %s", tier.Name) - } - - // Verify we stay in StackSelection - if m.currentStep != StackSelection { - t.Errorf("Should stay in StackSelection for disabled tier %s", tier.Name) - } - }) - } -} - -// TestModal_PersistsAcrossKeyPresses tests that modal stays open when pressing Enter on disabled tier -func TestModal_PersistsAcrossKeyPresses(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = StackSelection - m.selectedTierIndex = 1 // Disabled tier - - // Open modal - msg := tea.KeyMsg{Type: tea.KeyEnter} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - if !m.showModal { - t.Fatal("First press: modal should be shown") - } - - // Press Enter again (on disabled tier with modal already shown) - msg = tea.KeyMsg{Type: tea.KeyEnter} - updatedModel, _ = m.Update(msg) - m = updatedModel.(*initModel) - - // Modal should still be shown - if !m.showModal { - t.Error("Second press: modal should still be shown") - } - - // Should still be in StackSelection - if m.currentStep != StackSelection { - t.Errorf("After multiple Enter presses: currentStep = %v, want StackSelection", m.currentStep) - } -} - -// TestPathInput_TypingUpdatesPath tests that typing updates the install path -func TestPathInput_TypingUpdatesPath(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = PathSelection - m.installPath = "./" - - // Type 'm' - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - if m.installPath != "./m" { - t.Errorf("After typing 'm': installPath = %q, want \"./m\"", m.installPath) - } - - // Type 'y' - msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}} - updatedModel, _ = m.Update(msg) - m = updatedModel.(*initModel) - - if m.installPath != "./my" { - t.Errorf("After typing 'y': installPath = %q, want \"./my\"", m.installPath) - } - - // Type '-' - msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'-'}} - updatedModel, _ = m.Update(msg) - m = updatedModel.(*initModel) - - if m.installPath != "./my-" { - t.Errorf("After typing '-': installPath = %q, want \"./my-\"", m.installPath) - } - - // Type 'a', 'p', 'p' - for _, ch := range "app" { - msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}} - updatedModel, _ = m.Update(msg) - m = updatedModel.(*initModel) - } - - if m.installPath != "./my-app" { - t.Errorf("After typing 'app': installPath = %q, want \"./my-app\"", m.installPath) - } -} - -// TestPathInput_BackspaceRemovesCharacters tests backspace functionality -func TestPathInput_BackspaceRemovesCharacters(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = PathSelection - m.installPath = "./my-project" - - // Press backspace - msg := tea.KeyMsg{Type: tea.KeyBackspace} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - if m.installPath != "./my-projec" { - t.Errorf("After one backspace: installPath = %q, want \"./my-projec\"", m.installPath) - } - - // Press backspace 3 more times - for i := 0; i < 3; i++ { - msg = tea.KeyMsg{Type: tea.KeyBackspace} - updatedModel, _ = m.Update(msg) - m = updatedModel.(*initModel) - } - - if m.installPath != "./my-pro" { - t.Errorf("After 4 backspaces: installPath = %q, want \"./my-pro\"", m.installPath) - } -} - -// TestPathInput_BackspaceOnEmptyPath tests backspace on empty path -func TestPathInput_BackspaceOnEmptyPath(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = PathSelection - m.installPath = "" - - // Press backspace on empty path - msg := tea.KeyMsg{Type: tea.KeyBackspace} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - // Should remain empty (no panic/error) - if m.installPath != "" { - t.Errorf("After backspace on empty: installPath = %q, want \"\"", m.installPath) - } -} - -// TestPathInput_EnterWithNonEmptyPathTransitions tests Enter with valid path -func TestPathInput_EnterWithNonEmptyPathTransitions(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = PathSelection - m.installPath = "./my-arc" - - // Press Enter with non-empty path - msg := tea.KeyMsg{Type: tea.KeyEnter} - updatedModel, cmd := m.Update(msg) - m = updatedModel.(*initModel) - - // Should transition to Installation - if m.currentStep != Installation { - t.Errorf("After Enter with path: currentStep = %v, want Installation", m.currentStep) - } - - // Should have command for spinner/timer - if cmd == nil { - t.Error("After Enter with path: cmd should not be nil") - } - - // installationStartTime should be set - if m.installationStartTime.IsZero() { - t.Error("After Enter with path: installationStartTime should be set") - } -} - -// TestPathInput_EnterWithEmptyPathNoTransition tests Enter with empty path -func TestPathInput_EnterWithEmptyPathNoTransition(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = PathSelection - m.installPath = "" - - // Press Enter with empty path - msg := tea.KeyMsg{Type: tea.KeyEnter} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - // Should NOT transition - if m.currentStep != PathSelection { - t.Errorf("After Enter with empty path: currentStep = %v, want PathSelection", m.currentStep) - } -} - -// TestPathInput_SpecialCharacters tests typing various path characters -func TestPathInput_SpecialCharacters(t *testing.T) { - testCases := []struct { - name string - char rune - expected string - }{ - {"slash", '/', "./"}, - {"underscore", '_', "._"}, - {"dash", '-', ".-"}, - {"dot", '.', ".."}, - {"tilde", '~', ".~"}, - {"digit", '5', ".5"}, - {"uppercase", 'A', ".A"}, - {"lowercase", 'z', ".z"}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = PathSelection - m.installPath = "." - - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{tc.char}} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - if m.installPath != tc.expected { - t.Errorf("After typing %q: installPath = %q, want %q", tc.char, m.installPath, tc.expected) - } - }) - } -} - -// TestPathInput_CompletePathEntry tests entering a complete path -func TestPathInput_CompletePathEntry(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = PathSelection - m.installPath = "" - - completePath := "/home/user/projects/arc-app" - - // Type the complete path - for _, ch := range completePath { - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - } - - if m.installPath != completePath { - t.Errorf("After typing complete path: installPath = %q, want %q", m.installPath, completePath) - } - - // Still in PathSelection (not submitted yet) - if m.currentStep != PathSelection { - t.Errorf("Before Enter: currentStep = %v, want PathSelection", m.currentStep) - } -} - -// TestQuit_QKeyFromStackSelection tests 'q' key triggers quit from StackSelection -func TestQuit_QKeyFromStackSelection(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = StackSelection - - // Press 'q' - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}} - _, cmd := m.Update(msg) - - // Should return tea.Quit command - if cmd == nil { - t.Fatal("Quit command should not be nil") - } - if cmd() != tea.Quit() { - t.Error("Command should be tea.Quit") - } -} - -// TestQuit_CtrlCFromStackSelection tests Ctrl+C triggers quit from StackSelection -func TestQuit_CtrlCFromStackSelection(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = StackSelection - - // Press Ctrl+C - msg := tea.KeyMsg{Type: tea.KeyCtrlC} - _, cmd := m.Update(msg) - - // Should return tea.Quit command - if cmd == nil { - t.Fatal("Quit command should not be nil") - } - if cmd() != tea.Quit() { - t.Error("Command should be tea.Quit") - } -} - -// TestQuit_FromAllSteps tests quit works from all wizard steps -func TestQuit_FromAllSteps(t *testing.T) { - testCases := []struct { - name string - step wizardStep - key string - }{ - {"StackSelection with q", StackSelection, "q"}, - {"StackSelection with ctrl+c", StackSelection, "ctrl+c"}, - {"PathSelection with q", PathSelection, "q"}, - {"PathSelection with ctrl+c", PathSelection, "ctrl+c"}, - {"Installation with q", Installation, "q"}, - {"Installation with ctrl+c", Installation, "ctrl+c"}, - {"Completion with q", Completion, "q"}, - {"Completion with ctrl+c", Completion, "ctrl+c"}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = tc.step - - // Create appropriate key message - var msg tea.KeyMsg - if tc.key == "q" { - msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}} - } else { - msg = tea.KeyMsg{Type: tea.KeyCtrlC} - } - - _, cmd := m.Update(msg) - - // Should return tea.Quit command - if cmd == nil { - t.Fatalf("Quit command should not be nil for step %v with key %s", tc.step, tc.key) - } - if cmd() != tea.Quit() { - t.Errorf("Command should be tea.Quit for step %v with key %s", tc.step, tc.key) - } - }) - } -} - -// TestQuit_ModalOpenDoesNotBlockQuit tests that quit works even when modal is open -func TestQuit_ModalOpenDoesNotBlockQuit(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = StackSelection - m.showModal = true // Modal is open - - // Press 'q' while modal is shown - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}} - _, cmd := m.Update(msg) - - // Should still quit (global quit keys processed before modal) - if cmd == nil { - t.Fatal("Quit command should not be nil even with modal open") - } - if cmd() != tea.Quit() { - t.Error("Command should be tea.Quit even with modal open") - } -} - -// TestQuit_DuringPathInput tests quit during path input -func TestQuit_DuringPathInput(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = PathSelection - m.installPath = "./my-partial-path" - - // Press Ctrl+C during path entry - msg := tea.KeyMsg{Type: tea.KeyCtrlC} - _, cmd := m.Update(msg) - - // Should quit without saving path - if cmd == nil { - t.Fatal("Quit command should not be nil during path input") - } - if cmd() != tea.Quit() { - t.Error("Command should be tea.Quit during path input") - } -} - -// TestQuit_NoQuitOnOtherKeys tests that other keys don't trigger quit -func TestQuit_NoQuitOnOtherKeys(t *testing.T) { - testCases := []struct { - name string - key tea.KeyMsg - }{ - {"letter p", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}}, - {"letter Q (uppercase)", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'Q'}}}, - {"Enter", tea.KeyMsg{Type: tea.KeyEnter}}, - {"Escape", tea.KeyMsg{Type: tea.KeyEscape}}, - {"Left arrow", tea.KeyMsg{Type: tea.KeyLeft}}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = StackSelection - m.selectedTierIndex = 0 // Enabled tier - - _, cmd := m.Update(tc.key) - - // Should NOT return quit command - if cmd != nil && cmd() == tea.Quit() { - t.Errorf("Key %v should not trigger quit", tc.name) - } - }) - } -} - -// ============================================================================= -// Phase 8: Edge Cases & Polish (T084) -// ============================================================================= - -// TestEdgeCase_UnsupportedKeys tests that unsupported keys are ignored gracefully -func TestEdgeCase_UnsupportedKeys(t *testing.T) { - testCases := []struct { - name string - key tea.KeyMsg - step wizardStep - }{ - {"Space in StackSelection", tea.KeyMsg{Type: tea.KeySpace}, StackSelection}, - {"Tab in StackSelection", tea.KeyMsg{Type: tea.KeyTab}, StackSelection}, - {"F1 in StackSelection", tea.KeyMsg{Type: tea.KeyF1}, StackSelection}, - {"PageUp in PathSelection", tea.KeyMsg{Type: tea.KeyPgUp}, PathSelection}, - {"PageDown in PathSelection", tea.KeyMsg{Type: tea.KeyPgDown}, PathSelection}, - {"Delete in PathSelection", tea.KeyMsg{Type: tea.KeyDelete}, PathSelection}, - {"Home in StackSelection", tea.KeyMsg{Type: tea.KeyHome}, StackSelection}, - {"End in StackSelection", tea.KeyMsg{Type: tea.KeyEnd}, StackSelection}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = tc.step - m.selectedTierIndex = 1 - - initialStep := m.currentStep - initialIndex := m.selectedTierIndex - initialPath := m.installPath - - // Press unsupported key - updatedModel, cmd := m.Update(tc.key) - m = updatedModel.(*initModel) - - // Should not change state - if m.currentStep != initialStep { - t.Errorf("Unsupported key %v changed step from %v to %v", tc.name, initialStep, m.currentStep) - } - if m.selectedTierIndex != initialIndex { - t.Errorf("Unsupported key %v changed selectedTierIndex from %d to %d", tc.name, initialIndex, m.selectedTierIndex) - } - if m.installPath != initialPath { - t.Errorf("Unsupported key %v changed installPath from %q to %q", tc.name, initialPath, m.installPath) - } - - // Should not return quit command - if cmd != nil && cmd() == tea.Quit() { - t.Errorf("Unsupported key %v should not trigger quit", tc.name) - } - }) - } -} - -// TestEdgeCase_RapidKeyPresses tests that rapid navigation doesn't skip items -func TestEdgeCase_RapidKeyPresses(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = StackSelection - m.selectedTierIndex = 0 - - // Simulate 10 rapid right arrow presses - for i := 0; i < 10; i++ { - msg := tea.KeyMsg{Type: tea.KeyRight} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - } - - // After 10 presses from index 0: (0+10) % 3 = 1 - // 0 -> 1 -> 2 -> 0 -> 1 -> 2 -> 0 -> 1 -> 2 -> 0 -> 1 - expectedIndex := 1 - if m.selectedTierIndex != expectedIndex { - t.Errorf("After 10 rapid right presses from 0: selectedTierIndex = %d, want %d", m.selectedTierIndex, expectedIndex) - } - - // Simulate 7 rapid left arrow presses - for i := 0; i < 7; i++ { - msg := tea.KeyMsg{Type: tea.KeyLeft} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - } - - // After 7 left presses from index 1: 1 -> 0 -> 2 -> 1 -> 0 -> 2 -> 1 -> 0 - expectedIndex = 0 - if m.selectedTierIndex != expectedIndex { - t.Errorf("After 7 rapid left presses from 1: selectedTierIndex = %d, want %d", m.selectedTierIndex, expectedIndex) - } -} - -// TestEdgeCase_TerminalResizeDuringWizard tests terminal size changes mid-session -func TestEdgeCase_TerminalResizeDuringWizard(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = StackSelection - - // Verify normal rendering works - output := m.View() - if output == "" { - t.Fatal("View should return content for normal terminal size") - } - if strings.Contains(output, "Terminal Too Small") { - t.Error("Should not show terminal warning at 120x40") - } - - // Simulate terminal resize to below minimum - resizeMsg := tea.WindowSizeMsg{Width: 70, Height: 15} - updatedModel, _ := m.Update(resizeMsg) - m = updatedModel.(*initModel) - - // Verify warning is now shown - output = m.View() - if output == "" { - t.Fatal("View should return content even for small terminal") - } - if !strings.Contains(output, "Terminal Too Small") { - t.Error("Should show terminal warning after resize to 70x15") - } - if !strings.Contains(output, "70×15") { - t.Error("Warning should display current terminal size") - } - - // Resize back to normal - resizeMsg = tea.WindowSizeMsg{Width: 100, Height: 30} - updatedModel, _ = m.Update(resizeMsg) - m = updatedModel.(*initModel) - - // Verify normal rendering resumes - output = m.View() - if strings.Contains(output, "Terminal Too Small") { - t.Error("Should not show terminal warning after resize to 100x30") - } -} - -// TestEdgeCase_MinimumTerminalSizeBoundary tests exact boundary conditions -func TestEdgeCase_MinimumTerminalSizeBoundary(t *testing.T) { - testCases := []struct { - name string - termWidth int - termHeight int - shouldWarn bool - }{ - {"Exactly minimum", 80, 20, false}, - {"One pixel below width", 79, 20, true}, - {"One pixel below height", 80, 19, true}, - {"One pixel above minimum", 81, 21, false}, - {"Far below minimum", 40, 10, true}, - {"Wide but short", 200, 15, true}, - {"Tall but narrow", 50, 50, true}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - m := initialInitModel() - m.termWidth = tc.termWidth - m.termHeight = tc.termHeight - m.currentStep = StackSelection - - output := m.View() - - if tc.shouldWarn { - if !strings.Contains(output, "Terminal Too Small") { - t.Errorf("At %dx%d: should show warning", tc.termWidth, tc.termHeight) - } - } else { - if strings.Contains(output, "Terminal Too Small") { - t.Errorf("At %dx%d: should not show warning", tc.termWidth, tc.termHeight) - } - } - }) - } -} - -// TestEdgeCase_StatePreservedDuringResize tests that wizard state isn't lost during resize -func TestEdgeCase_StatePreservedDuringResize(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = PathSelection - m.selectedTierIndex = 2 - m.installPath = "./my-project" - m.showModal = true - - // Capture initial state - initialStep := m.currentStep - initialIndex := m.selectedTierIndex - initialPath := m.installPath - initialModal := m.showModal - - // Simulate terminal resize - resizeMsg := tea.WindowSizeMsg{Width: 100, Height: 30} - updatedModel, _ := m.Update(resizeMsg) - m = updatedModel.(*initModel) - - // Verify all state is preserved - if m.currentStep != initialStep { - t.Errorf("Resize changed currentStep from %v to %v", initialStep, m.currentStep) - } - if m.selectedTierIndex != initialIndex { - t.Errorf("Resize changed selectedTierIndex from %d to %d", initialIndex, m.selectedTierIndex) - } - if m.installPath != initialPath { - t.Errorf("Resize changed installPath from %q to %q", initialPath, m.installPath) - } - if m.showModal != initialModal { - t.Errorf("Resize changed showModal from %v to %v", initialModal, m.showModal) - } - - // Verify terminal dimensions are updated - if m.termWidth != 100 { - t.Errorf("termWidth = %d, want 100", m.termWidth) - } - if m.termHeight != 30 { - t.Errorf("termHeight = %d, want 30", m.termHeight) - } -} - -// TestEdgeCase_EmptyInstallPath tests handling of empty path edge case -func TestEdgeCase_EmptyInstallPath(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = PathSelection - m.installPath = "" - - // Try to proceed with empty path - msg := tea.KeyMsg{Type: tea.KeyEnter} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - // Should remain in PathSelection - if m.currentStep != PathSelection { - t.Errorf("Empty path submission: currentStep = %v, want PathSelection", m.currentStep) - } - - // Path should still be empty - if m.installPath != "" { - t.Errorf("Empty path submission: installPath = %q, want empty string", m.installPath) - } -} - -// TestEdgeCase_VeryLongPathInput tests handling of very long installation paths -func TestEdgeCase_VeryLongPathInput(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = PathSelection - m.installPath = "" - - // Type a very long path (200 characters) - longPath := strings.Repeat("very-long-directory-name/", 8) // 200 chars - for _, ch := range longPath { - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - } - - // Verify path was captured correctly - if m.installPath != longPath { - t.Errorf("Long path not captured correctly, got length %d, want %d", len(m.installPath), len(longPath)) - } - - // Verify we can still submit it - msg := tea.KeyMsg{Type: tea.KeyEnter} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - // Should transition to Installation - if m.currentStep != Installation { - t.Errorf("Long path submission: currentStep = %v, want Installation", m.currentStep) - } -} - -// ============================================================================= -// Phase 6: Profile Integration Tests (T051) -// ============================================================================= - -// TestProfileSelection_UpdatesSelectedProfile tests that profile selection updates selectedProfile field -func TestProfileSelection_UpdatesSelectedProfile(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = ProfileSelection - m.selectedProfileIndex = 0 - - // Initial selected profile should be empty - if m.selectedProfile != "" { - t.Errorf("Initial selectedProfile = %q, want empty string", m.selectedProfile) - } - - // Ensure we have profiles loaded - if len(m.profiles) == 0 { - t.Skip("No profiles available for testing") - } - - expectedProfileID := m.profiles[0].ID - - // Press Enter to select profile - msg := tea.KeyMsg{Type: tea.KeyEnter} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - // Should update selectedProfile field - if m.selectedProfile != expectedProfileID { - t.Errorf("After profile selection: selectedProfile = %q, want %q", m.selectedProfile, expectedProfileID) - } - - // Should transition to StackSelection - if m.currentStep != StackSelection { - t.Errorf("After profile selection: currentStep = %v, want StackSelection", m.currentStep) - } -} - -// TestProfileSelection_DifferentProfiles tests selecting different profiles -func TestProfileSelection_DifferentProfiles(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - - // Ensure we have at least 2 profiles - if len(m.profiles) < 2 { - t.Skip("Need at least 2 profiles for testing") - } - - // Test selecting second profile - m.currentStep = ProfileSelection - m.selectedProfileIndex = 1 - expectedProfileID := m.profiles[1].ID - - msg := tea.KeyMsg{Type: tea.KeyEnter} - updatedModel, _ := m.Update(msg) - m = updatedModel.(*initModel) - - if m.selectedProfile != expectedProfileID { - t.Errorf("After selecting profile 1: selectedProfile = %q, want %q", m.selectedProfile, expectedProfileID) - } -} - -// TestTierCards_ProfileSpecificNames tests that tier cards show profile-specific tier names -func TestTierCards_ProfileSpecificNames(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = StackSelection - - // Ensure we have profiles - if len(m.profiles) == 0 { - t.Skip("No profiles available for testing") - } - - // Set selected profile to first available profile - m.selectedProfile = m.profiles[0].ID - - // Render stack selection which should use profile-specific tier names - output := m.renderStackSelection() - - // Should contain content - if output == "" { - t.Error("Stack selection view should not be empty") - } - - // Should render tier cards (check for common card elements) - if !strings.Contains(output, "Choose Your Power Tier") { - t.Error("Should contain tier selection title") - } -} - -// TestTierCards_FallbackWhenNoProfile tests fallback to tier.Name when no profile selected -func TestTierCards_FallbackWhenNoProfile(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = StackSelection - m.selectedProfile = "" // No profile selected - - // Render tier cards with no profile - cards := m.renderTierCards(nil) - - // Should still render (fallback to tier.Name) - if cards == "" { - t.Error("Tier cards should render even without profile") - } - - // Should contain default tier names - if !strings.Contains(cards, "Super Saiyan") { - t.Error("Should contain default tier name 'Super Saiyan' when no profile") - } -} - -// TestTierCards_WithCustomTierNames tests rendering with custom tier names -func TestTierCards_WithCustomTierNames(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - - customTierNames := []string{"Beginner", "Intermediate", "Advanced"} - - // Render tier cards with custom names - cards := m.renderTierCards(customTierNames) - - // Should contain custom tier names - if !strings.Contains(cards, "Beginner") { - t.Error("Should contain custom tier name 'Beginner'") - } - if !strings.Contains(cards, "Intermediate") { - t.Error("Should contain custom tier name 'Intermediate'") - } - if !strings.Contains(cards, "Advanced") { - t.Error("Should contain custom tier name 'Advanced'") - } - - // Should NOT contain default tier names when custom names provided - if strings.Contains(cards, "Super Saiyan") { - t.Error("Should not contain default tier name when custom names provided") - } -} - -// TestCompletion_ShowsProfileName tests that completion screen shows selected profile name -func TestCompletion_ShowsProfileName(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = Completion - m.initSuccess = true - m.selectedTierIndex = 0 - - // Ensure we have profiles - if len(m.profiles) == 0 { - t.Skip("No profiles available for testing") - } - - // Set selected profile - m.selectedProfile = m.profiles[0].ID - - // Render completion screen - output := m.renderCompletion() - - // Should contain success message - if !strings.Contains(output, "Setup Complete") { - t.Error("Completion screen should contain success message") - } - - // Output should not be empty - if output == "" { - t.Error("Completion screen should not be empty") - } -} - -// TestCompletion_WithoutProfile tests completion screen when no profile selected -func TestCompletion_WithoutProfile(t *testing.T) { - m := initialInitModel() - m.termWidth = 120 - m.termHeight = 40 - m.currentStep = Completion - m.initSuccess = true - m.selectedProfile = "" // No profile - m.selectedTierIndex = 0 - - // Render completion screen - output := m.renderCompletion() - - // Should still render successfully - if output == "" { - t.Error("Completion screen should not be empty even without profile") - } - - // Should contain success message - if !strings.Contains(output, "Setup Complete") { - t.Error("Completion screen should contain success message") +func TestPerformInitialization_EmptyPath(t *testing.T) { + err := performInitialization("", "basic") + if err == nil { + t.Error("performInitialization with empty path should return an error") } } diff --git a/pkg/cli/middleware/.gitkeep b/pkg/cli/middleware/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/pkg/cli/middleware/README.md b/pkg/cli/middleware/README.md deleted file mode 100644 index 10f149c..0000000 --- a/pkg/cli/middleware/README.md +++ /dev/null @@ -1,206 +0,0 @@ -# ProfileMiddleware - -ProfileMiddleware integrates the complete UI refactor stack into the Cobra command execution chain. - -## Overview - -ProfileMiddleware combines four key components: - -- **ProfileContext**: Profile branding and tier names -- **SafeBorder**: Terminal capability detection -- **ComponentFactory**: Themed UI component creation -- **ErrorBoundary**: Unified error handling - -## Architecture - -``` -┌─────────────────────────────────────────────────────────┐ -│ ProfileMiddleware (T017-T018) │ -├─────────────────────────────────────────────────────────┤ -│ │ -│ PersistentPreRunE Chain: │ -│ 1. Load ProfileContext from app.Context │ -│ 2. Get BorderTier from SafeBorder │ -│ 3. Create ComponentFactory(ProfileContext, BorderTier) │ -│ 4. Create ErrorBoundary(Factory, UI, HintRegistry) │ -│ 5. Store in command context │ -│ 6. Wrap RunE with ErrorBoundary │ -│ │ -└─────────────────────────────────────────────────────────┘ - │ │ │ - ▼ ▼ ▼ - ProfileContext ComponentFactory ErrorBoundary - (T008-T009) (T012-T013) (T015-T016) -``` - -## Integration - -### In root.go - -```go -import ( - "github.com/arc-framework/arc-cli/internal/app" - "github.com/arc-framework/arc-cli/pkg/cli/middleware" - "github.com/arc-framework/arc-cli/pkg/ui/components" -) - -func Execute(ctx *app.Context) error { - // 1. Detect border capabilities once at startup - safeBorder := components.NewSafeBorder() - - // 2. Create middleware with default constructors - profileMiddleware := middleware.NewProfileMiddleware( - ctx, // app context - safeBorder, // border detection - nil, // default factory constructor - nil, // default boundary constructor - ) - - // 3. Integrate into root command - if err := profileMiddleware.Integrate(rootCmd); err != nil { - return err - } - - return rootCmd.Execute() -} -``` - -### In Commands - -```go -var myCmd = &cobra.Command{ - Use: "mycommand", - RunE: func(cmd *cobra.Command, args []string) error { - // Access injected ComponentFactory - factory := middleware.GetComponentFactory(cmd) - if factory != nil { - // Create themed components - output := factory.Card("Title", "Content") - fmt.Println(output) - } - - // Access SafeBorder for capability checks - safeBorder := middleware.GetSafeBorder(cmd) - if safeBorder != nil && safeBorder.IsClassic() { - // Use classic Unicode features - } - - return nil - }, -} -``` - -## Dependency Injection - -For testing or custom behavior, provide constructor functions: - -```go -customFactory := func( - profileCtx *profiles.ProfileContext, - tier components.BorderTier, -) (middleware.ComponentFactory, error) { - // Custom factory creation logic - return ui.NewComponentFactory(profileCtx, tier), nil -} - -customBoundary := func( - factory middleware.ComponentFactory, - uiService *ui.Service, - hints *clierrors.HintRegistry, -) middleware.ErrorBoundary { - // Custom boundary creation logic - return middleware.NewErrorBoundary(factory, uiService, hints) -} - -profileMiddleware := middleware.NewProfileMiddleware( - ctx, - safeBorder, - customFactory, // custom factory - customBoundary, // custom boundary -) -``` - -## Context Values - -The middleware injects three values into `cmd.Context()`: - -1. **ComponentFactory**: For creating themed UI components -2. **ErrorBoundary**: For rendering errors uniformly -3. **SafeBorder**: For terminal capability information - -Retrieve them with: - -- `middleware.GetComponentFactory(cmd)` → `ComponentFactory` -- `middleware.GetErrorBoundary(cmd)` → `ErrorBoundary` -- `middleware.GetSafeBorder(cmd)` → `*components.SafeBorder` - -## Error Handling - -Errors are automatically wrapped by ErrorBoundary: - -```go -RunE: func(cmd *cobra.Command, args []string) error { - // Any error returned here is automatically: - // 1. Enriched with hints (via HintRegistry) - // 2. Rendered through ErrorBoundary - // 3. Returned for exit code handling - - return fmt.Errorf("something went wrong") -} -``` - -For manual error rendering: - -```go -boundary := middleware.GetErrorBoundary(cmd) -if boundary != nil { - boundary.RenderError(err, cmd) -} -``` - -## Testing - -The middleware supports dependency injection for testing: - -```go -func TestMyCommand(t *testing.T) { - // Create test factories - testFactory := func(profileCtx *profiles.ProfileContext, tier components.BorderTier) (middleware.ComponentFactory, error) { - return &mockFactory{}, nil - } - - testBoundary := func(factory middleware.ComponentFactory, ui *ui.Service, hints *clierrors.HintRegistry) middleware.ErrorBoundary { - return &mockBoundary{} - } - - // Create middleware with test implementations - middleware := middleware.NewProfileMiddleware( - testCtx, - testBorder, - testFactory, - testBoundary, - ) - - // Test integration - err := middleware.Integrate(testCmd) - // assertions... -} -``` - -## Thread Safety - -- **ProfileMiddleware**: Safe for concurrent use across multiple commands -- **ComponentFactory**: NOT thread-safe. Create one per command execution. -- **ErrorBoundary**: NOT thread-safe. Create one per command execution. - -The middleware creates new factory/boundary instances for each command execution, ensuring isolation. - -## Spec References - -- **Spec**: 015-ui-refactor -- **Tasks**: T017 (implementation), T018 (tests) -- **Dependencies**: - - T006: SafeBorder (pkg/ui/components/safeborder.go) - - T008-T009: ProfileContext (pkg/ui/profiles/context.go) - - T012-T013: ComponentFactory (pkg/ui/factory.go) - - T015-T016: ErrorBoundary (pkg/cli/middleware/error_boundary.go) diff --git a/pkg/cli/middleware/error_boundary.go b/pkg/cli/middleware/error_boundary.go deleted file mode 100644 index 5af536a..0000000 --- a/pkg/cli/middleware/error_boundary.go +++ /dev/null @@ -1,314 +0,0 @@ -// Package middleware provides Cobra command middleware for error handling, -// profile loading, and other cross-cutting concerns. -package middleware - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/spf13/cobra" - "golang.org/x/term" - - clierrors "github.com/arc-framework/arc-cli/pkg/cli/errors" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -const ( - // DefaultOperationFailedMessage is the fallback context message for plain errors. - DefaultOperationFailedMessage = "Operation failed" -) - -// errorBoundary is the concrete implementation of ErrorBoundary interface. -// It wraps Cobra RunE functions with unified error handling. -// -// Design Pattern: Middleware — wraps RunE, intercepts errors, renders uniformly. -// -// Thread Safety: NOT thread-safe. Create one per command execution. -type errorBoundary struct { - factory ComponentFactory - uiService *ui.Service - hintRegistry *clierrors.HintRegistry - jsonMode bool - dashboardMode bool - writer io.Writer -} - -// NewErrorBoundary creates an ErrorBoundary with the given dependencies. -// All parameters are required except factory (can be nil if not in dashboard mode). -func NewErrorBoundary(factory ComponentFactory, uiService *ui.Service, hints *clierrors.HintRegistry) ErrorBoundary { - if uiService == nil { - panic("ErrorBoundary: uiService is required") - } - if hints == nil { - panic("ErrorBoundary: hints is required") - } - - return &errorBoundary{ - factory: factory, - uiService: uiService, - hintRegistry: hints, - jsonMode: false, - dashboardMode: false, - writer: os.Stdout, - } -} - -// SetDashboardMode enables toast-style error rendering. -// When true, errors are rendered as overlay notifications instead of full ErrorBoxes. -func (eb *errorBoundary) SetDashboardMode(enabled bool) { - eb.dashboardMode = enabled -} - -// SetJSONMode enables JSON error output. -func (eb *errorBoundary) SetJSONMode(enabled bool) { - eb.jsonMode = enabled -} - -// SetWriter sets the output writer (useful for testing). -func (eb *errorBoundary) SetWriter(w io.Writer) { - eb.writer = w -} - -// Wrap wraps a Cobra RunE function with error handling middleware. -// Returns a new RunE function that: -// 1. Executes the original function -// 2. If error returned, enriches it (adds hints via HintRegistry) -// 3. Renders the error through the appropriate path (TTY/non-TTY/JSON/dashboard) -// 4. Returns the error (for exit code handling) -func (eb *errorBoundary) Wrap(fn func(cmd *cobra.Command, args []string) error) func(cmd *cobra.Command, args []string) error { - if fn == nil { - return nil - } - - return func(cmd *cobra.Command, args []string) error { - err := fn(cmd, args) - if err == nil { - return nil - } - - // Enrich and render the error - eb.RenderError(err, cmd) - return err - } -} - -// RenderError renders an error through the appropriate path. -// Called by Wrap, but also available for manual use. -// -// The cmd parameter is optional and used to extract context from cmd.Use. -func (eb *errorBoundary) RenderError(err error, cmd *cobra.Command) { - if err == nil { - return - } - - // Special handling for context.Canceled — clean warning - if errors.Is(err, context.Canceled) { - eb.renderCanceled() - return - } - - // Try to cast to ArcError for rich metadata - var arcErr *clierrors.ArcError - isArcError := errors.As(err, &arcErr) - - // If plain error, wrap it with enriched metadata - if !isArcError { - arcErr = eb.enrichPlainError(err, cmd) - } - - // Render through the appropriate path - if eb.jsonMode { - eb.renderJSON(arcErr) - } else if eb.dashboardMode { - eb.renderDashboard(arcErr) - } else if eb.isTTY() { - eb.renderTTY(arcErr) - } else { - eb.renderNonTTY(arcErr) - } -} - -// enrichPlainError wraps a plain error with context and hints. -func (eb *errorBoundary) enrichPlainError(err error, cmd *cobra.Command) *clierrors.ArcError { - // Extract context from command if available - context := DefaultOperationFailedMessage - if cmd != nil && cmd.Use != "" { - // cmd.Use is typically "command [args]", extract just the command name - parts := strings.Fields(cmd.Use) - if len(parts) > 0 { - context = fmt.Sprintf("Command '%s' failed", parts[0]) - } - } - - // Match hint from registry - hint := eb.hintRegistry.Match(err.Error()) - - // Create enriched ArcError - return clierrors.New(context, err). - WithHint(hint). - WithSeverity(clierrors.SeverityError). - WithExitCode(1) -} - -// renderCanceled renders a clean warning for context.Canceled. -func (eb *errorBoundary) renderCanceled() { - if eb.jsonMode { - output := map[string]interface{}{ - "severity": "warning", - "context": "Operation canceled", - "message": "User interrupted the operation", - "hint": "Press Ctrl+C again to force quit", - } - _ = json.NewEncoder(eb.writer).Encode(output) - return - } - - // Non-JSON: simple warning message - theme := eb.uiService.Theme() - symbol := theme.Symbols.Warning - fmt.Fprintf(eb.writer, "%s Operation canceled by user\n", symbol) //nolint:errcheck // Writing to stdout/stderr rarely fails; error is non-critical for cancel messages -} - -// renderJSON renders error as JSON object. -func (eb *errorBoundary) renderJSON(arcErr *clierrors.ArcError) { - output := map[string]interface{}{ - "severity": arcErr.Severity.String(), - "error": arcErr.Err.Error(), - } - - if arcErr.Context != "" { - output["context"] = arcErr.Context - } - - if arcErr.Hint != "" { - output["hint"] = arcErr.Hint - } - - if arcErr.ExitCode != 0 { - output["exit_code"] = arcErr.ExitCode - } - - _ = json.NewEncoder(eb.writer).Encode(output) -} - -// renderDashboard renders error as a toast notification (tea.Cmd). -// In dashboard mode, we create a toast overlay instead of a full error box. -func (eb *errorBoundary) renderDashboard(arcErr *clierrors.ArcError) { - // Dashboard mode requires a ComponentFactory - if eb.factory == nil { - // Fallback to TTY rendering if factory is not available - eb.renderTTY(arcErr) - return - } - - // Create toast notification message - message := arcErr.Err.Error() - if arcErr.Context != "" { - message = arcErr.Context + ": " + message - } - - // For dashboard mode, we would send a tea.Cmd to show a toast. - // However, since we're in a middleware context (not inside a Bubble Tea model), - // we cannot directly emit tea.Cmd. - // - // Instead, we'll render a simple notification-style message. - // The actual dashboard integration would be handled by the command itself. - // - // For now, render a compact notification format: - theme := eb.uiService.Theme() - symbol := getSeveritySymbol(arcErr.Severity, theme) - - var output strings.Builder - output.WriteString(fmt.Sprintf("%s %s", symbol, message)) - if arcErr.Hint != "" { - output.WriteString(fmt.Sprintf(" (Hint: %s)", arcErr.Hint)) - } - output.WriteString("\n") - - fmt.Fprint(eb.writer, output.String()) //nolint:errcheck // Writing to stdout/stderr rarely fails; error is already being reported -} - -// renderTTY renders error as a themed ErrorBox for terminal output. -func (eb *errorBoundary) renderTTY(arcErr *clierrors.ArcError) { - // Convert clierrors.Severity to components.Severity - severity := mapSeverity(arcErr.Severity) - - opts := components.ErrorOptions{ - Severity: severity, - Context: arcErr.Context, - Hint: arcErr.Hint, - Theme: eb.uiService.Theme(), - IsTTY: true, - } - - output := components.ErrorBox(arcErr.Err, opts) - fmt.Fprintln(eb.writer, output) //nolint:errcheck // Writing to stdout/stderr rarely fails; error is already being reported -} - -// renderNonTTY renders error as plain text for non-TTY output (CI/CD). -// Format: [SEVERITY] context: message\nHint: hint -func (eb *errorBoundary) renderNonTTY(arcErr *clierrors.ArcError) { - // Convert clierrors.Severity to components.Severity - severity := mapSeverity(arcErr.Severity) - - opts := components.ErrorOptions{ - Severity: severity, - Context: arcErr.Context, - Hint: arcErr.Hint, - Theme: eb.uiService.Theme(), - IsTTY: false, - } - - output := components.ErrorBox(arcErr.Err, opts) - fmt.Fprintln(eb.writer, output) //nolint:errcheck // Writing to stdout/stderr rarely fails; error is already being reported -} - -// isTTY detects if output is to a terminal. -func (eb *errorBoundary) isTTY() bool { - if f, ok := eb.writer.(*os.File); ok { - return term.IsTerminal(int(f.Fd())) - } - return false -} - -// mapSeverity converts clierrors.Severity to components.Severity. -func mapSeverity(s clierrors.Severity) components.Severity { - switch s { - case clierrors.SeverityError: - return components.SeverityError - case clierrors.SeverityWarning: - return components.SeverityWarning - case clierrors.SeverityInfo: - return components.SeverityInfo - default: - return components.SeverityError - } -} - -// getSeveritySymbol returns the theme symbol for a severity level. -func getSeveritySymbol(severity clierrors.Severity, theme *themes.Theme) string { - switch severity { - case clierrors.SeverityError: - return theme.Symbols.Error - case clierrors.SeverityWarning: - return theme.Symbols.Warning - case clierrors.SeverityInfo: - return theme.Symbols.Info - default: - return theme.Symbols.Error - } -} - -// Ensure errorBoundary implements the ErrorBoundary interface. -var _ ErrorBoundary = (*errorBoundary)(nil) - -// Ensure tea.Cmd is available for dashboard mode documentation -var _ tea.Cmd diff --git a/pkg/cli/middleware/error_boundary_test.go b/pkg/cli/middleware/error_boundary_test.go deleted file mode 100644 index f9783e7..0000000 --- a/pkg/cli/middleware/error_boundary_test.go +++ /dev/null @@ -1,663 +0,0 @@ -package middleware - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "testing" - - "github.com/spf13/cobra" - - clierrors "github.com/arc-framework/arc-cli/pkg/cli/errors" - "github.com/arc-framework/arc-cli/pkg/log" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// mockComponentFactory is a minimal mock for testing dashboard mode. -type mockComponentFactory struct{} - -func (m *mockComponentFactory) Card(title, content string) string { - return fmt.Sprintf("[Card: %s]\n%s", title, content) -} - -func (m *mockComponentFactory) ProfileContext() *profiles.ProfileContext { - return nil -} - -// noopLogger is a minimal logger for testing. -type noopLogger struct{} - -func (n *noopLogger) Debug(msg string, keysAndValues ...any) {} -func (n *noopLogger) Info(msg string, keysAndValues ...any) {} -func (n *noopLogger) Warn(msg string, keysAndValues ...any) {} -func (n *noopLogger) Error(msg string, keysAndValues ...any) {} -func (n *noopLogger) Fatal(msg string, keysAndValues ...any) {} -func (n *noopLogger) With(keysAndValues ...any) log.Logger { return n } -func (n *noopLogger) SetLevel(level log.LogLevel) {} - -// setupTestBoundary creates a test ErrorBoundary with a buffer writer. -func setupTestBoundary(t *testing.T) (*errorBoundary, *bytes.Buffer) { - t.Helper() - - theme, err := themes.GetDefault() - if err != nil { - t.Fatalf("Failed to get default theme: %v", err) - } - - logger := &noopLogger{} - uiService := ui.NewService(theme, logger) - hints := clierrors.NewHintRegistry() - - boundary := NewErrorBoundary(&mockComponentFactory{}, uiService, hints).(*errorBoundary) - - // Replace writer with buffer for testing - buf := &bytes.Buffer{} - boundary.SetWriter(buf) - - return boundary, buf -} - -func TestNewErrorBoundary(t *testing.T) { - theme, err := themes.GetDefault() - if err != nil { - t.Fatalf("Failed to get default theme: %v", err) - } - - logger := &noopLogger{} - uiService := ui.NewService(theme, logger) - hints := clierrors.NewHintRegistry() - - t.Run("valid creation", func(t *testing.T) { - boundary := NewErrorBoundary(nil, uiService, hints) - if boundary == nil { - t.Fatal("NewErrorBoundary returned nil") - } - // We can't test private fields on the interface, - // but we can verify the interface works by calling methods - testFn := func(cmd *cobra.Command, args []string) error { - return nil - } - wrappedFn := boundary.Wrap(testFn) - if wrappedFn == nil { - t.Error("Wrap should return a non-nil function") - } - }) - - t.Run("nil uiService panics", func(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Error("NewErrorBoundary with nil uiService should panic") - } - }() - NewErrorBoundary(nil, nil, hints) - }) - - t.Run("nil hints panics", func(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Error("NewErrorBoundary with nil hints should panic") - } - }() - NewErrorBoundary(nil, uiService, nil) - }) -} - -func TestErrorBoundary_SetModes(t *testing.T) { - boundary, buf := setupTestBoundary(t) - - t.Run("SetJSONMode", func(t *testing.T) { - boundary.SetJSONMode(true) - - // Test that JSON mode works by rendering an error - testErr := errors.New("test error") - boundary.RenderError(testErr, &cobra.Command{Use: "test"}) - - // Verify JSON output - var result map[string]interface{} - if err := json.Unmarshal(buf.Bytes(), &result); err != nil { - t.Errorf("Expected JSON output, got: %s", buf.String()) - } - buf.Reset() - - boundary.SetJSONMode(false) - }) - - t.Run("SetDashboardMode", func(t *testing.T) { - boundary.SetDashboardMode(true) - // Dashboard mode doesn't have an easily testable side effect - // without rendering an error, which is tested elsewhere - - boundary.SetDashboardMode(false) - }) -} - -func TestErrorBoundary_Wrap(t *testing.T) { - boundary, buf := setupTestBoundary(t) - - t.Run("nil function returns nil", func(t *testing.T) { - wrapped := boundary.Wrap(nil) - if wrapped != nil { - t.Error("Wrap(nil) should return nil") - } - }) - - t.Run("success case - no error", func(t *testing.T) { - runE := func(cmd *cobra.Command, args []string) error { - return nil - } - - wrapped := boundary.Wrap(runE) - err := wrapped(&cobra.Command{Use: "test"}, []string{}) - if err != nil { - t.Errorf("Wrap should return nil error, got: %v", err) - } - - if buf.Len() > 0 { - t.Error("No output expected for successful execution") - } - }) - - t.Run("error case - plain error", func(t *testing.T) { - testErr := errors.New("test error") - runE := func(cmd *cobra.Command, args []string) error { - return testErr - } - - wrapped := boundary.Wrap(runE) - err := wrapped(&cobra.Command{Use: "test"}, []string{}) - - if !errors.Is(err, testErr) { - t.Errorf("Wrap should return original error, got: %v", err) - } - - // Output should contain the error (in non-TTY format) - output := buf.String() - if !strings.Contains(output, "test error") { - t.Errorf("Output should contain error message, got: %s", output) - } - }) - - t.Run("error case - ArcError", func(t *testing.T) { - buf.Reset() - - testErr := clierrors.New("Failed to load config", errors.New("file not found")). - WithHint("Check that config.yaml exists"). - WithSeverity(clierrors.SeverityError) - - runE := func(cmd *cobra.Command, args []string) error { - return testErr - } - - wrapped := boundary.Wrap(runE) - err := wrapped(&cobra.Command{Use: "test"}, []string{}) - - if !errors.Is(err, testErr) { - t.Errorf("Wrap should return original error, got: %v", err) - } - - output := buf.String() - if !strings.Contains(output, "Failed to load config") { - t.Errorf("Output should contain context, got: %s", output) - } - if !strings.Contains(output, "file not found") { - t.Errorf("Output should contain error message, got: %s", output) - } - if !strings.Contains(output, "Check that config.yaml exists") { - t.Errorf("Output should contain hint, got: %s", output) - } - }) -} - -func TestErrorBoundary_RenderError_NilError(t *testing.T) { - boundary, buf := setupTestBoundary(t) - - boundary.RenderError(nil, &cobra.Command{Use: "test"}) - - if buf.Len() > 0 { - t.Errorf("No output expected for nil error, got: %s", buf.String()) - } -} - -func TestErrorBoundary_RenderError_ContextCanceled(t *testing.T) { - boundary, buf := setupTestBoundary(t) - - t.Run("non-JSON mode", func(t *testing.T) { - boundary.RenderError(context.Canceled, &cobra.Command{Use: "test"}) - - output := buf.String() - if !strings.Contains(output, "Operation canceled") { - t.Errorf("Output should contain 'Operation canceled', got: %s", output) - } - }) - - t.Run("JSON mode", func(t *testing.T) { - buf.Reset() - boundary.SetJSONMode(true) - defer boundary.SetJSONMode(false) - - boundary.RenderError(context.Canceled, &cobra.Command{Use: "test"}) - - var result map[string]interface{} - if err := json.Unmarshal(buf.Bytes(), &result); err != nil { - t.Fatalf("Failed to parse JSON output: %v", err) - } - - if result["severity"] != "warning" { - t.Errorf("severity should be 'warning', got: %v", result["severity"]) - } - if result["context"] != "Operation canceled" { - t.Errorf("context should be 'Operation canceled', got: %v", result["context"]) - } - }) -} - -func TestErrorBoundary_JSONMode(t *testing.T) { - boundary, buf := setupTestBoundary(t) - boundary.SetJSONMode(true) - - t.Run("plain error", func(t *testing.T) { - buf.Reset() - - testErr := errors.New("something went wrong") - boundary.RenderError(testErr, &cobra.Command{Use: "test"}) - - var result map[string]interface{} - if err := json.Unmarshal(buf.Bytes(), &result); err != nil { - t.Fatalf("Failed to parse JSON output: %v", err) - } - - if result["error"] != "something went wrong" { - t.Errorf("error should be 'something went wrong', got: %v", result["error"]) - } - - if result["severity"] != "error" { - t.Errorf("severity should be 'error', got: %v", result["severity"]) - } - - // Context should be auto-extracted from cmd.Use - if result["context"] == nil { - t.Error("context should be set for plain errors") - } - - // Hint should be auto-matched - if result["hint"] == nil { - t.Error("hint should be set from registry") - } - }) - - t.Run("ArcError with all fields", func(t *testing.T) { - buf.Reset() - - testErr := clierrors.New("Custom context", errors.New("base error")). - WithHint("Custom hint"). - WithSeverity(clierrors.SeverityWarning). - WithExitCode(2) - - boundary.RenderError(testErr, &cobra.Command{Use: "test"}) - - var result map[string]interface{} - if err := json.Unmarshal(buf.Bytes(), &result); err != nil { - t.Fatalf("Failed to parse JSON output: %v", err) - } - - if result["severity"] != "warning" { - t.Errorf("severity should be 'warning', got: %v", result["severity"]) - } - - if result["context"] != "Custom context" { - t.Errorf("context should be 'Custom context', got: %v", result["context"]) - } - - if result["hint"] != "Custom hint" { - t.Errorf("hint should be 'Custom hint', got: %v", result["hint"]) - } - - if result["error"] != "base error" { - t.Errorf("error should be 'base error', got: %v", result["error"]) - } - - if result["exit_code"] != float64(2) { - t.Errorf("exit_code should be 2, got: %v", result["exit_code"]) - } - }) - - t.Run("ArcError with minimal fields", func(t *testing.T) { - buf.Reset() - - testErr := clierrors.New("", errors.New("simple error")) - boundary.RenderError(testErr, nil) - - var result map[string]interface{} - if err := json.Unmarshal(buf.Bytes(), &result); err != nil { - t.Fatalf("Failed to parse JSON output: %v", err) - } - - if result["error"] != "simple error" { - t.Errorf("error should be 'simple error', got: %v", result["error"]) - } - - // Empty context should not appear in JSON - if _, exists := result["context"]; exists && result["context"] != "" { - t.Errorf("empty context should not appear in JSON or be empty, got: %v", result["context"]) - } - }) -} - -func TestErrorBoundary_NonTTYMode(t *testing.T) { - boundary, buf := setupTestBoundary(t) - - // Note: buf is not a TTY, so we're already in non-TTY mode - - t.Run("plain error", func(t *testing.T) { - buf.Reset() - - testErr := errors.New("file not found") - boundary.RenderError(testErr, &cobra.Command{Use: "load"}) - - output := buf.String() - - // Should contain ASCII symbol - if !strings.Contains(output, "[ERROR]") { - t.Errorf("Output should contain '[ERROR]' symbol, got: %s", output) - } - - // Should contain error message - if !strings.Contains(output, "file not found") { - t.Errorf("Output should contain error message, got: %s", output) - } - - // Should contain hint (auto-matched) - if !strings.Contains(output, "Hint:") { - t.Errorf("Output should contain hint, got: %s", output) - } - }) - - t.Run("ArcError", func(t *testing.T) { - buf.Reset() - - testErr := clierrors.New("Failed to connect", errors.New("connection refused")). - WithHint("Check that the service is running"). - WithSeverity(clierrors.SeverityWarning) - - boundary.RenderError(testErr, &cobra.Command{Use: "connect"}) - - output := buf.String() - - // Should contain ASCII warning symbol - if !strings.Contains(output, "[WARNING]") { - t.Errorf("Output should contain '[WARNING]' symbol, got: %s", output) - } - - // Should contain context - if !strings.Contains(output, "Failed to connect") { - t.Errorf("Output should contain context, got: %s", output) - } - - // Should contain error message - if !strings.Contains(output, "connection refused") { - t.Errorf("Output should contain error message, got: %s", output) - } - - // Should contain hint - if !strings.Contains(output, "Check that the service is running") { - t.Errorf("Output should contain hint, got: %s", output) - } - }) -} - -func TestErrorBoundary_DashboardMode(t *testing.T) { - boundary, buf := setupTestBoundary(t) - boundary.SetDashboardMode(true) - - t.Run("with factory", func(t *testing.T) { - buf.Reset() - - testErr := clierrors.New("Operation failed", errors.New("network timeout")). - WithHint("Check network connectivity") - - boundary.RenderError(testErr, &cobra.Command{Use: "sync"}) - - output := buf.String() - - // In dashboard mode, should render compact notification - if !strings.Contains(output, "Operation failed") { - t.Errorf("Output should contain error context, got: %s", output) - } - - // Should include hint inline - if !strings.Contains(output, "Hint:") { - t.Errorf("Output should contain hint, got: %s", output) - } - }) - - t.Run("without factory", func(t *testing.T) { - buf.Reset() - - // Create boundary without factory - theme, _ := themes.GetDefault() - logger := &noopLogger{} - uiService := ui.NewService(theme, logger) - hints := clierrors.NewHintRegistry() - - boundaryNoFactory := NewErrorBoundary(nil, uiService, hints).(*errorBoundary) - boundaryNoFactory.SetWriter(buf) - boundaryNoFactory.SetDashboardMode(true) - - testErr := errors.New("test error") - boundaryNoFactory.RenderError(testErr, &cobra.Command{Use: "test"}) - - // Should fallback to TTY rendering - output := buf.String() - if len(output) == 0 { - t.Error("Output should not be empty when factory is nil") - } - }) -} - -func TestErrorBoundary_EnrichPlainError(t *testing.T) { - boundary, _ := setupTestBoundary(t) - - t.Run("with command context", func(t *testing.T) { - cmd := &cobra.Command{Use: "workspace init"} - testErr := errors.New("permission denied") - - arcErr := boundary.enrichPlainError(testErr, cmd) - - if !strings.Contains(arcErr.Context, "workspace") { - t.Errorf("Context should contain command name, got: %s", arcErr.Context) - } - - if !errors.Is(arcErr.Err, testErr) { - t.Error("Original error should be preserved") - } - - if arcErr.Hint == "" { - t.Error("Hint should be auto-matched from registry") - } - - // "permission denied" should match a pattern - if !strings.Contains(arcErr.Hint, "permission") && !strings.Contains(arcErr.Hint, "verbose") { - t.Errorf("Hint should be relevant, got: %s", arcErr.Hint) - } - - if arcErr.Severity != clierrors.SeverityError { - t.Errorf("Severity should be SeverityError, got: %v", arcErr.Severity) - } - - if arcErr.ExitCode != 1 { - t.Errorf("ExitCode should be 1, got: %v", arcErr.ExitCode) - } - }) - - t.Run("without command context", func(t *testing.T) { - testErr := errors.New("unknown error") - - arcErr := boundary.enrichPlainError(testErr, nil) - - if arcErr.Context != "Operation failed" { - t.Errorf("Context should be default, got: %s", arcErr.Context) - } - - if arcErr.Hint == "" { - t.Error("Hint should have fallback value") - } - }) - - t.Run("hint matching from registry", func(t *testing.T) { - // Test various error patterns that should match hints - tests := []struct { - name string - errorMsg string - expectHint string - }{ - { - name: "yaml error", - errorMsg: "yaml: unmarshal error", - expectHint: "YAML", - }, - { - name: "permission denied", - errorMsg: "open /etc/file: permission denied", - expectHint: "permission", - }, - { - name: "connection refused", - errorMsg: "dial tcp: connection refused", - expectHint: "service", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - testErr := errors.New(tt.errorMsg) - arcErr := boundary.enrichPlainError(testErr, &cobra.Command{Use: "test"}) - - if !strings.Contains(strings.ToLower(arcErr.Hint), strings.ToLower(tt.expectHint)) { - t.Errorf("Hint should contain '%s', got: %s", tt.expectHint, arcErr.Hint) - } - }) - } - }) -} - -func TestErrorBoundary_SeverityMapping(t *testing.T) { - tests := []struct { - name string - cliSeverity clierrors.Severity - wantCompSev string - }{ - { - name: "error severity", - cliSeverity: clierrors.SeverityError, - wantCompSev: "error", - }, - { - name: "warning severity", - cliSeverity: clierrors.SeverityWarning, - wantCompSev: "warning", - }, - { - name: "info severity", - cliSeverity: clierrors.SeverityInfo, - wantCompSev: "info", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - boundary, buf := setupTestBoundary(t) - boundary.SetJSONMode(true) - - testErr := clierrors.New("Test", errors.New("test error")). - WithSeverity(tt.cliSeverity) - - boundary.RenderError(testErr, nil) - - var result map[string]interface{} - if err := json.Unmarshal(buf.Bytes(), &result); err != nil { - t.Fatalf("Failed to parse JSON: %v", err) - } - - if result["severity"] != tt.wantCompSev { - t.Errorf("severity = %v, want %v", result["severity"], tt.wantCompSev) - } - }) - } -} - -// TestErrorBoundary_Integration tests end-to-end scenarios. -func TestErrorBoundary_Integration(t *testing.T) { - t.Run("full command execution flow", func(t *testing.T) { - boundary, buf := setupTestBoundary(t) - - // Simulate a command that fails - cmd := &cobra.Command{ - Use: "workspace init", - Short: "Initialize workspace", - RunE: func(cmd *cobra.Command, args []string) error { - return errors.New("workspace directory already exists") - }, - } - - // Wrap the RunE - cmd.RunE = boundary.Wrap(cmd.RunE) - - // Execute - err := cmd.Execute() - - if err == nil { - t.Fatal("Command should return error") - } - - output := buf.String() - - // Verify error was rendered - if !strings.Contains(output, "workspace directory already exists") { - t.Errorf("Output should contain error message, got: %s", output) - } - - // Verify hint was added - if !strings.Contains(output, "Hint:") { - t.Errorf("Output should contain hint, got: %s", output) - } - }) - - t.Run("wrapped ArcError preserves metadata", func(t *testing.T) { - boundary, buf := setupTestBoundary(t) - - originalErr := clierrors.New("Database connection failed", errors.New("timeout")). - WithHint("Increase timeout in config.yaml"). - WithSeverity(clierrors.SeverityWarning). - WithExitCode(2) - - cmd := &cobra.Command{ - Use: "db connect", - RunE: func(cmd *cobra.Command, args []string) error { - return originalErr - }, - } - - cmd.RunE = boundary.Wrap(cmd.RunE) - err := cmd.Execute() - - // Error should be preserved - if !errors.Is(err, originalErr) { - t.Error("Original ArcError should be preserved") - } - - output := buf.String() - - // All metadata should be in output - if !strings.Contains(output, "Database connection failed") { - t.Error("Context should be preserved") - } - if !strings.Contains(output, "Increase timeout in config.yaml") { - t.Error("Hint should be preserved") - } - }) -} diff --git a/pkg/cli/middleware/example_test.go b/pkg/cli/middleware/example_test.go deleted file mode 100644 index 275bf7c..0000000 --- a/pkg/cli/middleware/example_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package middleware_test - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/arc-framework/arc-cli/internal/app" - "github.com/arc-framework/arc-cli/pkg/cli/middleware" - "github.com/arc-framework/arc-cli/pkg/log" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" -) - -// ExampleProfileMiddleware demonstrates how to use ProfileMiddleware in a Cobra command. -func ExampleProfileMiddleware() { - // Step 1: Create application context - appCtx := &app.Context{ - Logger: log.Default(), - UI: &ui.Service{}, - } - - // Step 2: Detect border capabilities - safeBorder := components.NewSafeBorder() - - // Step 3: Create middleware (uses default factory and boundary constructors) - profileMiddleware := middleware.NewProfileMiddleware(appCtx, safeBorder, nil, nil) - - // Step 4: Create root command - rootCmd := &cobra.Command{ - Use: "myapp", - Short: "My Application", - RunE: func(cmd *cobra.Command, args []string) error { - // Access the injected factory - factory := middleware.GetComponentFactory(cmd) - if factory != nil { - output := factory.Card("Welcome", "This is a themed card!") - fmt.Println(output) - } - return nil - }, - } - - // Step 5: Integrate middleware into the command - if err := profileMiddleware.Integrate(rootCmd); err != nil { - panic(err) - } - - // Now when you execute the command, the middleware will: - // 1. Load ProfileContext - // 2. Create ComponentFactory with the profile's theme - // 3. Create ErrorBoundary for unified error handling - // 4. Inject everything into the command context - // 5. Wrap RunE with error handling - - // Output depends on the active profile and terminal capabilities -} - -// ExampleGetComponentFactory demonstrates how to use ComponentFactory in a command. -func ExampleGetComponentFactory() { - // In your command's RunE function: - exampleCmd := &cobra.Command{ - Use: "example", - RunE: func(cmd *cobra.Command, args []string) error { - // Get the factory from context (injected by ProfileMiddleware) - factory := middleware.GetComponentFactory(cmd) - if factory == nil { - return fmt.Errorf("ComponentFactory not available") - } - - // Use the factory to create themed components - card := factory.Card("System Info", "OS: Linux\nArch: amd64") - fmt.Println(card) - - return nil - }, - } - - // Execute the command (assuming ProfileMiddleware is already integrated in root) - _ = exampleCmd.Execute() - - // Output will be a themed card matching the active profile -} - -// ExampleGetErrorBoundary demonstrates how to manually render errors. -func ExampleGetErrorBoundary() { - exampleCmd := &cobra.Command{ - Use: "example", - RunE: func(cmd *cobra.Command, args []string) error { - // Get the error boundary from context - boundary := middleware.GetErrorBoundary(cmd) - if boundary == nil { - return fmt.Errorf("ErrorBoundary not available") - } - - // Manually render an error (normally handled automatically by Wrap) - err := fmt.Errorf("something went wrong") - boundary.RenderError(err, cmd) - - return err - }, - } - - _ = exampleCmd.Execute() - - // The error will be rendered through the ErrorBoundary pipeline -} - -// ExampleGetSafeBorder demonstrates how to access border detection info. -func ExampleGetSafeBorder() { - exampleCmd := &cobra.Command{ - Use: "example", - RunE: func(cmd *cobra.Command, args []string) error { - // Get SafeBorder from context - safeBorder := middleware.GetSafeBorder(cmd) - if safeBorder == nil { - return fmt.Errorf("SafeBorder not available") - } - - // Check terminal capabilities - info := safeBorder.TerminalInfo() - fmt.Printf("Terminal: %s\n", info.TermProgram) - fmt.Printf("Border Tier: %s\n", safeBorder.Tier()) - - return nil - }, - } - - _ = exampleCmd.Execute() - - // Output will show terminal detection info -} diff --git a/pkg/cli/middleware/profile.go b/pkg/cli/middleware/profile.go deleted file mode 100644 index 6e1b2ab..0000000 --- a/pkg/cli/middleware/profile.go +++ /dev/null @@ -1,289 +0,0 @@ -// Package middleware provides Cobra command middleware for the A.R.C. CLI. -// -// ProfileMiddleware integrates the complete UI refactor stack into the command -// execution chain. It combines: -// - ProfileContext (profile branding) -// - SafeBorder (terminal capability detection) -// - ComponentFactory (themed UI components) -// - ErrorBoundary (unified error handling) -// -// Design: Middleware pattern — wraps PersistentPreRunE and RunE. -// Spec: 015-ui-refactor, Tasks T017-T018 -package middleware - -import ( - "context" - "fmt" - - "github.com/spf13/cobra" - - "github.com/arc-framework/arc-cli/internal/app" - clierrors "github.com/arc-framework/arc-cli/pkg/cli/errors" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -// contextKey is a custom type for storing values in context.Context. -// Prevents collisions with other packages using context values. -type contextKey string - -const ( - // componentFactoryKey stores the ComponentFactory in command context. - componentFactoryKey contextKey = "arc.componentFactory" - // errorBoundaryKey stores the ErrorBoundary in command context. - errorBoundaryKey contextKey = "arc.errorBoundary" - // safeBorderKey stores the SafeBorder in command context. - safeBorderKey contextKey = "arc.safeBorder" -) - -// ComponentFactory is the interface for creating themed UI components. -// The actual implementation is in pkg/ui/factory.go (Task T012-T013). -// This is a minimal interface alias - the full implementation in pkg/ui has more methods. -type ComponentFactory interface { - // Card creates a themed card component with title and content. - Card(title, content string) string - - // ProfileContext returns the underlying ProfileContext. - ProfileContext() *profiles.ProfileContext -} - -// ErrorBoundary is the interface for unified error handling. -// The implementation is in this package (error_boundary.go, Task T015). -type ErrorBoundary interface { - // Wrap wraps a Cobra RunE function with error handling middleware. - Wrap(fn func(cmd *cobra.Command, args []string) error) func(cmd *cobra.Command, args []string) error - - // RenderError renders an error through the appropriate path. - // The cmd parameter is optional and used to extract context from cmd.Use. - RenderError(err error, cmd *cobra.Command) -} - -// ComponentFactoryFunc is a function that creates a ComponentFactory. -// This enables dependency injection for testing. -// Note: ComponentFactory concrete type is defined in pkg/ui/factory.go -type ComponentFactoryFunc func(profileCtx *profiles.ProfileContext, borderTier components.BorderTier) (ComponentFactory, error) - -// ErrorBoundaryFunc is a function that creates an ErrorBoundary. -// This enables dependency injection for testing. -// Note: ErrorBoundary is defined in this package (error_boundary.go) -type ErrorBoundaryFunc func(factory ComponentFactory, uiService *ui.Service, hints *clierrors.HintRegistry) ErrorBoundary - -// ProfileMiddleware integrates ProfileContext, SafeBorder, ComponentFactory, -// and ErrorBoundary into the command execution chain. -// -// Design Pattern: Middleware Chain -// Flow: -// 1. PersistentPreRunE: Load ProfileContext, detect borders, create factory/boundary -// 2. Store factory/boundary in command context -// 3. Wrap RunE with ErrorBoundary for unified error handling -// -// Thread Safety: Safe for concurrent use across multiple commands. -type ProfileMiddleware struct { - // appContext holds the application-wide dependencies. - appContext *app.Context - - // safeBorder is the cached SafeBorder instance (singleton). - safeBorder *components.SafeBorder - - // createFactory is the factory constructor (for dependency injection). - createFactory ComponentFactoryFunc - - // createBoundary is the boundary constructor (for dependency injection). - createBoundary ErrorBoundaryFunc -} - -// NewProfileMiddleware creates a ProfileMiddleware with the given dependencies. -// -// Parameters: -// - appCtx: Application context with ProfileContext, UI, Logger, etc. -// - safeBorder: SafeBorder instance for terminal capability detection. -// - factoryFn: Function to create ComponentFactory (nil uses default stub). -// - boundaryFn: Function to create ErrorBoundary (nil uses default stub). -// -// Returns middleware ready to integrate into PersistentPreRunE. -func NewProfileMiddleware( - appCtx *app.Context, - safeBorder *components.SafeBorder, - factoryFn ComponentFactoryFunc, - boundaryFn ErrorBoundaryFunc, -) *ProfileMiddleware { - // Use default stubs if functions not provided - if factoryFn == nil { - factoryFn = defaultFactoryStub - } - if boundaryFn == nil { - boundaryFn = defaultBoundaryStub - } - - return &ProfileMiddleware{ - appContext: appCtx, - safeBorder: safeBorder, - createFactory: factoryFn, - createBoundary: boundaryFn, - } -} - -// Integrate installs the middleware into the given Cobra command. -// -// Integration points: -// 1. Wraps existing PersistentPreRunE (preserves chain) -// 2. Wraps RunE with ErrorBoundary -// 3. Injects factory/boundary into command context -// -// Usage in root.go: -// -// middleware := NewProfileMiddleware(appCtx, safeBorder, factoryFn, boundaryFn) -// middleware.Integrate(rootCmd) -func (pm *ProfileMiddleware) Integrate(cmd *cobra.Command) error { - // Store the original PersistentPreRunE to preserve existing chain - originalPreRun := cmd.PersistentPreRunE - - // Wrap PersistentPreRunE with our middleware - cmd.PersistentPreRunE = func(cobraCmd *cobra.Command, args []string) error { - // Execute original PersistentPreRunE first (if exists) - if originalPreRun != nil { - if err := originalPreRun(cobraCmd, args); err != nil { - return err - } - } - - // Execute our middleware setup - return pm.Setup(cobraCmd, args) - } - - return nil -} - -// Setup performs the middleware initialization for a command. -// -// Flow: -// 1. Load ProfileContext from app.Context -// 2. Create ComponentFactory from ProfileContext + SafeBorder -// 3. Create ErrorBoundary from ComponentFactory + app.Context.UI + HintRegistry -// 4. Store factory/boundary in command context -// 5. Wrap RunE with ErrorBoundary -// -// Called by Integrate() via PersistentPreRunE. -func (pm *ProfileMiddleware) Setup(cmd *cobra.Command, args []string) error { - if pm.appContext == nil { - return fmt.Errorf("middleware: appContext is nil") - } - - // Step 1: Load ProfileContext - profileCtx := pm.appContext.GetProfileContext() - if profileCtx == nil { - return fmt.Errorf("middleware: failed to load ProfileContext") - } - - // Step 2: Get border tier from SafeBorder - borderTier := components.BorderTierNone // Safe default - if pm.safeBorder != nil { - borderTier = pm.safeBorder.Tier() - } - - // Step 3: Create ComponentFactory - factory, err := pm.createFactory(profileCtx, borderTier) - if err != nil { - return fmt.Errorf("middleware: failed to create ComponentFactory: %w", err) - } - - // Step 4: Get UI service and create HintRegistry - uiService := pm.appContext.UI - if uiService == nil { - // Create a default UI service if not provided - uiService = &ui.Service{} // Minimal fallback - } - - hints := clierrors.NewHintRegistry() - - // Step 5: Create ErrorBoundary - boundary := pm.createBoundary(factory, uiService, hints) - - // Step 6: Store in command context - ctx := cmd.Context() - if ctx == nil { - ctx = context.Background() - } - - // Inject factory, boundary, and safeBorder into context - ctx = context.WithValue(ctx, componentFactoryKey, factory) - ctx = context.WithValue(ctx, errorBoundaryKey, boundary) - ctx = context.WithValue(ctx, safeBorderKey, pm.safeBorder) - cmd.SetContext(ctx) - - // Step 7: Wrap RunE with ErrorBoundary (if command has RunE) - if cmd.RunE != nil { - cmd.RunE = boundary.Wrap(cmd.RunE) - } - - return nil -} - -// GetComponentFactory retrieves the ComponentFactory from the command context. -// Returns nil if not found (command wasn't processed by middleware). -// -// Usage in commands: -// -// factory := middleware.GetComponentFactory(cmd) -// if factory != nil { -// output := factory.Card("Title", "Content") -// } -func GetComponentFactory(cmd *cobra.Command) ComponentFactory { - ctx := cmd.Context() - if ctx == nil { - return nil - } - - factory, ok := ctx.Value(componentFactoryKey).(ComponentFactory) - if !ok { - return nil - } - - return factory -} - -// GetErrorBoundary retrieves the ErrorBoundary from the command context. -// Returns nil if not found (command wasn't processed by middleware). -func GetErrorBoundary(cmd *cobra.Command) ErrorBoundary { - ctx := cmd.Context() - if ctx == nil { - return nil - } - - boundary, ok := ctx.Value(errorBoundaryKey).(ErrorBoundary) - if !ok { - return nil - } - - return boundary -} - -// GetSafeBorder retrieves the SafeBorder from the command context. -// Returns nil if not found (command wasn't processed by middleware). -func GetSafeBorder(cmd *cobra.Command) *components.SafeBorder { - ctx := cmd.Context() - if ctx == nil { - return nil - } - - safeBorder, ok := ctx.Value(safeBorderKey).(*components.SafeBorder) - if !ok { - return nil - } - - return safeBorder -} - -// defaultFactoryStub is a stub factory constructor for testing. -// Uses the real ComponentFactory implementation from pkg/ui/factory.go -func defaultFactoryStub(profileCtx *profiles.ProfileContext, borderTier components.BorderTier) (ComponentFactory, error) { - factory := ui.NewComponentFactory(profileCtx, borderTier) - return factory, nil -} - -// defaultBoundaryStub is a stub boundary constructor for testing. -// Uses the real ErrorBoundary implementation from error_boundary.go -func defaultBoundaryStub(factory ComponentFactory, uiService *ui.Service, hints *clierrors.HintRegistry) ErrorBoundary { - return NewErrorBoundary(factory, uiService, hints) -} diff --git a/pkg/cli/middleware/profile_test.go b/pkg/cli/middleware/profile_test.go deleted file mode 100644 index f7c607f..0000000 --- a/pkg/cli/middleware/profile_test.go +++ /dev/null @@ -1,595 +0,0 @@ -package middleware - -import ( - "errors" - "testing" - - "github.com/spf13/cobra" - - "github.com/arc-framework/arc-cli/internal/app" - clierrors "github.com/arc-framework/arc-cli/pkg/cli/errors" - "github.com/arc-framework/arc-cli/pkg/log" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -// TestNewProfileMiddleware verifies middleware construction. -func TestNewProfileMiddleware(t *testing.T) { - t.Parallel() - - appCtx := &app.Context{ - Logger: log.Default(), - } - safeBorder := components.NewSafeBorder() - - tests := []struct { - name string - appCtx *app.Context - safeBorder *components.SafeBorder - factoryFn ComponentFactoryFunc - boundaryFn ErrorBoundaryFunc - expectNil bool - expectFactory bool - expectBound bool - }{ - { - name: "with all dependencies", - appCtx: appCtx, - safeBorder: safeBorder, - factoryFn: defaultFactoryStub, - boundaryFn: defaultBoundaryStub, - expectFactory: true, - expectBound: true, - }, - { - name: "with nil factory function (uses default stub)", - appCtx: appCtx, - safeBorder: safeBorder, - factoryFn: nil, - boundaryFn: defaultBoundaryStub, - expectFactory: true, - expectBound: true, - }, - { - name: "with nil boundary function (uses default stub)", - appCtx: appCtx, - safeBorder: safeBorder, - factoryFn: defaultFactoryStub, - boundaryFn: nil, - expectFactory: true, - expectBound: true, - }, - { - name: "with all nil functions (uses default stubs)", - appCtx: appCtx, - safeBorder: safeBorder, - factoryFn: nil, - boundaryFn: nil, - expectFactory: true, - expectBound: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - middleware := NewProfileMiddleware(tt.appCtx, tt.safeBorder, tt.factoryFn, tt.boundaryFn) - - if middleware == nil { - if !tt.expectNil { - t.Fatal("expected non-nil middleware") - } - return - } - - if middleware.appContext != tt.appCtx { - t.Errorf("expected appContext %v, got %v", tt.appCtx, middleware.appContext) - } - - if middleware.safeBorder != tt.safeBorder { - t.Errorf("expected safeBorder %v, got %v", tt.safeBorder, middleware.safeBorder) - } - - if tt.expectFactory && middleware.createFactory == nil { - t.Error("expected non-nil createFactory") - } - - if tt.expectBound && middleware.createBoundary == nil { - t.Error("expected non-nil createBoundary") - } - }) - } -} - -// TestProfileMiddleware_Integrate verifies middleware integration into commands. -func TestProfileMiddleware_Integrate(t *testing.T) { - t.Parallel() - - appCtx := &app.Context{ - Logger: log.Default(), - } - safeBorder := components.NewSafeBorder() - - tests := []struct { - name string - existingPreRun bool - expectError bool - expectedPreRunCount int - }{ - { - name: "integrate without existing PreRun", - existingPreRun: false, - expectError: false, - expectedPreRunCount: 1, // Only middleware PreRun - }, - { - name: "integrate with existing PreRun", - existingPreRun: true, - expectError: false, - expectedPreRunCount: 2, // Original + middleware PreRun - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - middleware := NewProfileMiddleware(appCtx, safeBorder, nil, nil) - - cmd := &cobra.Command{ - Use: "test", - RunE: func(cmd *cobra.Command, args []string) error { - return nil - }, - } - - preRunCount := 0 - if tt.existingPreRun { - cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { - preRunCount++ - return nil - } - } - - err := middleware.Integrate(cmd) - - if tt.expectError { - if err == nil { - t.Fatal("expected error, got nil") - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Verify PersistentPreRunE was set - if cmd.PersistentPreRunE == nil { - t.Fatal("expected PersistentPreRunE to be set") - } - }) - } -} - -// TestProfileMiddleware_Setup verifies middleware setup execution. -func TestProfileMiddleware_Setup(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - appCtx *app.Context - safeBorder *components.SafeBorder - factoryFn ComponentFactoryFunc - boundaryFn ErrorBoundaryFunc - expectError bool - errorMsg string - }{ - { - name: "successful setup with valid dependencies", - appCtx: &app.Context{ - Logger: log.Default(), - UI: &ui.Service{}, - }, - safeBorder: components.NewSafeBorder(), - factoryFn: func(profileCtx *profiles.ProfileContext, borderTier components.BorderTier) (ComponentFactory, error) { - return ui.NewComponentFactory(profileCtx, borderTier), nil - }, - boundaryFn: func(factory ComponentFactory, uiService *ui.Service, hints *clierrors.HintRegistry) ErrorBoundary { - return NewErrorBoundary(factory, uiService, hints) - }, - expectError: false, - }, - { - name: "fails with nil appContext", - appCtx: nil, - safeBorder: components.NewSafeBorder(), - expectError: true, - errorMsg: "appContext is nil", - }, - { - name: "fails when factory creation fails", - appCtx: &app.Context{ - Logger: log.Default(), - UI: &ui.Service{}, - }, - safeBorder: components.NewSafeBorder(), - factoryFn: func(profileCtx *profiles.ProfileContext, borderTier components.BorderTier) (ComponentFactory, error) { - return nil, errors.New("factory creation failed") - }, - expectError: true, - errorMsg: "failed to create ComponentFactory", - }, - // Note: ErrorBoundary creation can't fail (panics on nil params instead) - // so we test factory creation failure instead - { - name: "works with nil safeBorder (uses default tier)", - appCtx: &app.Context{ - Logger: log.Default(), - UI: &ui.Service{}, - }, - safeBorder: nil, - factoryFn: func(profileCtx *profiles.ProfileContext, borderTier components.BorderTier) (ComponentFactory, error) { - // Verify it received BorderTierNone as default - if borderTier != components.BorderTierNone { - t.Errorf("expected BorderTierNone, got %v", borderTier) - } - return ui.NewComponentFactory(profileCtx, borderTier), nil - }, - boundaryFn: func(factory ComponentFactory, uiService *ui.Service, hints *clierrors.HintRegistry) ErrorBoundary { - return NewErrorBoundary(factory, uiService, hints) - }, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - // Create middleware - var middleware *ProfileMiddleware - if tt.appCtx != nil || tt.safeBorder != nil { - middleware = NewProfileMiddleware(tt.appCtx, tt.safeBorder, tt.factoryFn, tt.boundaryFn) - } else { - // For nil appCtx test - middleware = &ProfileMiddleware{ - appContext: tt.appCtx, - safeBorder: tt.safeBorder, - createFactory: tt.factoryFn, - createBoundary: tt.boundaryFn, - } - } - - cmd := &cobra.Command{ - Use: "test", - RunE: func(cmd *cobra.Command, args []string) error { - return nil - }, - } - - err := middleware.Setup(cmd, []string{}) - - if tt.expectError { - if err == nil { - t.Fatal("expected error, got nil") - } - if tt.errorMsg != "" && !contains(err.Error(), tt.errorMsg) { - t.Errorf("expected error containing %q, got %q", tt.errorMsg, err.Error()) - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Verify context values were set - if cmd.Context() == nil { - t.Fatal("expected command context to be set") - } - - factory := GetComponentFactory(cmd) - if factory == nil { - t.Error("expected ComponentFactory in context") - } - - boundary := GetErrorBoundary(cmd) - if boundary == nil { - t.Error("expected ErrorBoundary in context") - } - - safeBorder := GetSafeBorder(cmd) - if tt.safeBorder != nil && safeBorder == nil { - t.Error("expected SafeBorder in context") - } - }) - } -} - -// TestProfileMiddleware_ContextInjection verifies that factory/boundary are injected into context. -func TestProfileMiddleware_ContextInjection(t *testing.T) { - t.Parallel() - - appCtx := &app.Context{ - Logger: log.Default(), - } - safeBorder := components.NewSafeBorder() - - middleware := NewProfileMiddleware(appCtx, safeBorder, nil, nil) - - cmd := &cobra.Command{ - Use: "test", - RunE: func(cmd *cobra.Command, args []string) error { - return nil - }, - } - - err := middleware.Setup(cmd, []string{}) - if err != nil { - t.Fatalf("Setup failed: %v", err) - } - - // Test GetComponentFactory - factory := GetComponentFactory(cmd) - if factory == nil { - t.Error("GetComponentFactory returned nil") - } - - // Test GetErrorBoundary - boundary := GetErrorBoundary(cmd) - if boundary == nil { - t.Error("GetErrorBoundary returned nil") - } - - // Test GetSafeBorder - border := GetSafeBorder(cmd) - if border == nil { - t.Error("GetSafeBorder returned nil") - } - if border != safeBorder { - t.Error("GetSafeBorder returned wrong instance") - } -} - -// TestProfileMiddleware_RunEWrapping verifies that RunE is wrapped by ErrorBoundary. -func TestProfileMiddleware_RunEWrapping(t *testing.T) { - t.Parallel() - - appCtx := &app.Context{ - Logger: log.Default(), - UI: &ui.Service{}, - } - safeBorder := components.NewSafeBorder() - - // Track if wrap was called - wrapCalled := false - boundaryFn := func(factory ComponentFactory, uiService *ui.Service, hints *clierrors.HintRegistry) ErrorBoundary { - return &testBoundary{ - onWrap: func() { - wrapCalled = true - }, - } - } - - middleware := NewProfileMiddleware(appCtx, safeBorder, nil, boundaryFn) - - cmd := &cobra.Command{ - Use: "test", - RunE: func(cmd *cobra.Command, args []string) error { - return nil - }, - } - - err := middleware.Setup(cmd, []string{}) - if err != nil { - t.Fatalf("Setup failed: %v", err) - } - - if !wrapCalled { - t.Error("expected ErrorBoundary.Wrap to be called") - } -} - -// TestGetHelpers_WithNilContext verifies helpers return nil for commands without context. -func TestGetHelpers_WithNilContext(t *testing.T) { - t.Parallel() - - cmd := &cobra.Command{ - Use: "test", - } - - if factory := GetComponentFactory(cmd); factory != nil { - t.Error("expected nil factory for command without context") - } - - if boundary := GetErrorBoundary(cmd); boundary != nil { - t.Error("expected nil boundary for command without context") - } - - if safeBorder := GetSafeBorder(cmd); safeBorder != nil { - t.Error("expected nil safeBorder for command without context") - } -} - -// TestMiddlewareChainExecution verifies that existing PreRun is preserved. -func TestMiddlewareChainExecution(t *testing.T) { - t.Parallel() - - appCtx := &app.Context{ - Logger: log.Default(), - } - safeBorder := components.NewSafeBorder() - - middleware := NewProfileMiddleware(appCtx, safeBorder, nil, nil) - - // Track execution order - execOrder := []string{} - - cmd := &cobra.Command{ - Use: "test", - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - execOrder = append(execOrder, "original") - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { - execOrder = append(execOrder, "run") - return nil - }, - } - - err := middleware.Integrate(cmd) - if err != nil { - t.Fatalf("Integrate failed: %v", err) - } - - // Execute the command - err = cmd.PersistentPreRunE(cmd, []string{}) - if err != nil { - t.Fatalf("PersistentPreRunE failed: %v", err) - } - - // Verify execution order - if len(execOrder) != 1 { - t.Errorf("expected 1 execution, got %d", len(execOrder)) - } - - if execOrder[0] != "original" { - t.Errorf("expected 'original' first, got %q", execOrder[0]) - } -} - -// TestBorderTierPropagation verifies SafeBorder tier is passed to factory. -func TestBorderTierPropagation(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - safeBorder *components.SafeBorder - expectedTier components.BorderTier - }{ - { - name: "with BorderTierNone", - safeBorder: components.NewSafeBorderWithOverride(components.BorderTierNone), - expectedTier: components.BorderTierNone, - }, - { - name: "with BorderTierBlock", - safeBorder: components.NewSafeBorderWithOverride(components.BorderTierBlock), - expectedTier: components.BorderTierBlock, - }, - { - name: "with BorderTierClassic", - safeBorder: components.NewSafeBorderWithOverride(components.BorderTierClassic), - expectedTier: components.BorderTierClassic, - }, - { - name: "with nil safeBorder (defaults to BorderTierNone)", - safeBorder: nil, - expectedTier: components.BorderTierNone, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - receivedTier := components.BorderTierNone - factoryFn := func(profileCtx *profiles.ProfileContext, borderTier components.BorderTier) (ComponentFactory, error) { - receivedTier = borderTier - return ui.NewComponentFactory(profileCtx, borderTier), nil - } - - appCtx := &app.Context{ - Logger: log.Default(), - UI: &ui.Service{}, - } - - middleware := NewProfileMiddleware(appCtx, tt.safeBorder, factoryFn, nil) - - cmd := &cobra.Command{ - Use: "test", - RunE: func(cmd *cobra.Command, args []string) error { - return nil - }, - } - - err := middleware.Setup(cmd, []string{}) - if err != nil { - t.Fatalf("Setup failed: %v", err) - } - - if receivedTier != tt.expectedTier { - t.Errorf("expected tier %v, got %v", tt.expectedTier, receivedTier) - } - }) - } -} - -// TestProfileContextLoading verifies ProfileContext is loaded from app.Context. -func TestProfileContextLoading(t *testing.T) { - t.Parallel() - - appCtx := &app.Context{ - Logger: log.Default(), - UI: &ui.Service{}, - } - safeBorder := components.NewSafeBorder() - - // Track if ProfileContext was passed to factory - var receivedProfileCtx *profiles.ProfileContext - factoryFn := func(profileCtx *profiles.ProfileContext, borderTier components.BorderTier) (ComponentFactory, error) { - receivedProfileCtx = profileCtx - return ui.NewComponentFactory(profileCtx, borderTier), nil - } - - middleware := NewProfileMiddleware(appCtx, safeBorder, factoryFn, nil) - - cmd := &cobra.Command{ - Use: "test", - RunE: func(cmd *cobra.Command, args []string) error { - return nil - }, - } - - err := middleware.Setup(cmd, []string{}) - if err != nil { - t.Fatalf("Setup failed: %v", err) - } - - if receivedProfileCtx == nil { - t.Error("expected ProfileContext to be passed to factory") - } -} - -// testBoundary is a test implementation of ErrorBoundary. -type testBoundary struct { - onWrap func() -} - -func (tb *testBoundary) Wrap(fn func(cmd *cobra.Command, args []string) error) func(cmd *cobra.Command, args []string) error { - if tb.onWrap != nil { - tb.onWrap() - } - return fn -} - -func (tb *testBoundary) RenderError(err error, cmd *cobra.Command) { - // No-op for testing -} - -// contains checks if a string contains a substring. -func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(substr) == 0 || (len(s) > 0 && len(substr) > 0 && hasSubstring(s, substr))) -} - -func hasSubstring(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} diff --git a/pkg/cli/root.go b/pkg/cli/root.go index fff4ed5..9c6016f 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -6,24 +6,18 @@ import ( "path/filepath" "github.com/spf13/cobra" + "golang.org/x/term" "github.com/arc-framework/arc-cli/internal/app" "github.com/arc-framework/arc-cli/internal/branding" - "github.com/arc-framework/arc-cli/internal/preferences" "github.com/arc-framework/arc-cli/pkg/cli/config" - "github.com/arc-framework/arc-cli/pkg/cli/dashboard" //nolint:staticcheck // legacy dashboard intentionally retained "github.com/arc-framework/arc-cli/pkg/cli/services" "github.com/arc-framework/arc-cli/pkg/cli/workspace" "github.com/arc-framework/arc-cli/pkg/log" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/animations" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/styles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" - "github.com/arc-framework/arc-cli/pkg/ui/views" - "github.com/arc-framework/arc-cli/pkg/version" + component "github.com/arc-framework/arc-cli/pkg/ui/component" + newengine "github.com/arc-framework/arc-cli/pkg/ui/engine" + uithemeldr "github.com/arc-framework/arc-cli/pkg/ui/theme" + uiview "github.com/arc-framework/arc-cli/pkg/ui/view" ) const ( @@ -43,117 +37,61 @@ var ( logger log.Logger ) -// init performs package initialization by loading user preferences and configuring global styles. -// -// KNOWN ISSUE - Global Side Effects (Technical Debt): -// This init() function has global side effects that run before main(): -// 1. Loads preferences from disk (~/.arc/state.json) -// 2. Mutates package-level styles in pkg/ui/styles -// 3. Creates hidden dependencies between packages -// -// Impact: -// - Hampers testability (difficult to mock preferences) -// - Violates Dependency Inversion Principle (hard coupling) -// - Makes parallel test execution risky -// - Hidden initialization order dependencies -// -// Future Refactoring (P3 - Technical Debt): -// Consider moving to explicit Bootstrap pattern: -// - Move preference loading to main.go -// - Pass context/config to commands explicitly -// - Make dependencies visible and testable -// - See: CODE_QUALITY_REVIEW.md Issue #5 -// -// For now, this works reliably for single-instance CLI usage. -func init() { - // Load active theme and update styles - appState, err := preferences.Load() - if err != nil { - // If state fails to load, use default - appState = preferences.Default() +func shouldLaunchTUI(cmd *cobra.Command, args []string) bool { + if len(args) != 0 { + return false } - themeName := appState.GetTheme() - - // Use YAML theme loader for consistent theme handling across CLI - // This enables user themes from ~/.config/arc/themes/ to work on startup - loader := themes.NewLoader() - theme, loadErr := loader.Load(themeName) - if loadErr != nil { - // Fall back to default YAML theme if configured theme not found - theme, _ = themes.GetDefault() + if help, _ := cmd.Flags().GetBool("help"); help { + return false } - - // Update global styles to match active theme - // Note: character-rainbow is a special case handled in banner rendering - if theme != nil { - styles.UpdateStylesFromTheme( - theme.Colors.PrimaryColor(), - theme.Colors.SecondaryColor(), - theme.Colors.SuccessColor(), - theme.Colors.ErrorColor(), - theme.Colors.WarningColor(), - theme.Colors.InfoColor(), - ) + if noTUI := cmd.Flags().Lookup("no-tui"); noTUI != nil { + if v, _ := cmd.Flags().GetBool("no-tui"); v { + return false + } } + return term.IsTerminal(int(os.Stdout.Fd())) } var rootCmd = &cobra.Command{ Use: "arc", Short: branding.Tagline, - Long: "", // Don't show anything here - banner already has tagline + Long: "", Run: func(cmd *cobra.Command, args []string) { - // Check if we should launch the interactive dashboard (Spec: 015-ui-refactor, Task T032) - // This must happen BEFORE rendering banner + help to avoid double output - if dashboard.ShouldLaunchDashboard(cmd, args) { - if appContext != nil { - // Launch dashboard in full-screen mode - if err := dashboard.Launch(appContext); err != nil { - logger.Error("Failed to launch dashboard: %v", err) + if shouldLaunchTUI(cmd, args) && appContext != nil { + loader, loaderErr := uithemeldr.NewLoader() + if loaderErr == nil { + cfg := app.EngineConfig( + appContext, + loader, + []newengine.View{ + uiview.NewHome(), + uiview.NewServicesList(), + uiview.NewServiceDetail(), + uiview.NewWorkspaceInfo(), + uiview.NewWorkspaceHistory(), + uiview.NewVersionView(), + uiview.NewConfigOverview( + loader.ListProfiles(), + loader.ListSkins(), + ), + }, + branding.Name, + "", + ) + if err := newengine.Start(cfg); err != nil { + logger.Error("Failed to launch UI: %v", err) os.Exit(1) } return } + logger.Warn("Failed to load theme loader: %v", loaderErr) } - // New UI: render HomeView via engine when app context is available and legacy UI is not forced - if appContext != nil && os.Getenv("ARC_USE_LEGACY_UI") == "" { - homeView := views.NewHomeView(appContext.Factory) - if err := engine.Render(engine.RenderConfig{ - View: homeView, - Mode: engine.TUIMode, - }); err != nil { - logger.Error("Failed to render HomeView: %v", err) - os.Exit(1) - } - return - } - - // Fallback: no subcommand, show banner + help - var profileCtx *profiles.ProfileContext - if appContext != nil { - profileCtx = appContext.GetProfileContext() - } - - banner := "" - if animations.ShouldAnimate() { - banner = RenderBannerAnimated(profileCtx) - } else { - banner = RenderBanner(profileCtx) - } - - // Print banner first - use Print to avoid buffering issues - fmt.Print(banner) + fmt.Print(component.Logo(nil, component.LogoLong)) fmt.Print("\n") - - // Flush stdout to ensure banner is fully printed before help _ = os.Stdout.Sync() - - // Then show help _ = cmd.Help() }, - // Silence errors and usage to prevent double printing. - // The middleware's ErrorBoundary handles all error rendering. - // Spec: 015-ui-refactor, Task T024 SilenceErrors: true, SilenceUsage: true, } @@ -183,64 +121,7 @@ func init() { return nil } - // Version command - displays build metadata (version + commit + build date) - // Spec: 016-ui-layout-fix, Phase 1 (Version Metadata) - versionCmd := &cobra.Command{ - Use: "version", - Short: "Show version and build information", - Long: `Display the A.R.C. CLI version, git commit hash, and build date. - -The version information is injected at build time via ldflags in the Makefile. -Use --verbose (-v) to see extended build information including the build date.`, - Example: ` # Show version and commit - arc version - - # Show extended version information - arc version --verbose`, - RunE: func(cmd *cobra.Command, args []string) error { - verboseFlag, _ := cmd.Flags().GetBool("verbose") - jsonFlag, _ := cmd.Flags().GetBool("json") - - // New UI engine path (opt-out with ARC_USE_LEGACY_UI env var) - if appContext != nil && os.Getenv("ARC_USE_LEGACY_UI") == "" { - versionView := views.NewVersionView( - appContext.Factory, - version.Version, - version.Commit, - version.BuildDate, - verboseFlag, - ) - - if jsonFlag { - return engine.Render(engine.RenderConfig{ - View: versionView, - Mode: engine.JSONMode, - JSONData: versionView.ToJSON(), - JSONIndent: true, - }) - } - - return engine.Render(engine.RenderConfig{ - View: versionView, - Mode: engine.TUIMode, - }) - } - - // Legacy fallback: direct print output - if verboseFlag { - fmt.Println(version.GetFullVersion()) - } else { - fmt.Println(version.GetVersionInfo()) - } - return nil - }, - } - versionCmd.Flags().BoolP("verbose", "v", false, "Show extended version information including build date") - versionCmd.Flags().Bool("json", false, "Output version information as JSON") - rootCmd.AddCommand(versionCmd) - - // Info command - rootCmd.AddCommand(infoCmd) + rootCmd.AddCommand(newVersionCmd()) // Init command rootCmd.AddCommand(initCmd) @@ -275,14 +156,6 @@ func Execute(ctx *app.Context) error { logger = GetLogger() } - // Sync context values to global state for backwards compatibility - if ctx.NoColor { - styles.NoColor = true - } - if ctx.NoAnimation { - animations.NoAnimation = true - } - // Set catalog from context for services commands if ctx.Catalog != nil { services.SetCatalog(ctx.Catalog) @@ -291,12 +164,6 @@ func Execute(ctx *app.Context) error { // Set app context for services commands (for new UI rendering) services.SetAppContext(ctx) - // Set app context for config commands (for ProfileContext invalidation) - config.SetAppContext(ctx) - - // Set app context for info command (for new UI rendering - Phase 5) - SetInfoAppContext(ctx) - return rootCmd.Execute() } @@ -467,75 +334,11 @@ func GetLogger() log.Logger { return logger } -// syncAppContextFlags syncs CLI flags to appContext and initializes UI components. -// Extracted to reduce complexity of PersistentPreRunE. func syncAppContextFlags(appContext *app.Context, noColor, noAnimation bool) { - // Sync flags to context if noColor { appContext.NoColor = true } if noAnimation { appContext.NoAnimation = true } - - // Apply to global state for backwards compatibility - styles.NoColor = appContext.NoColor - animations.NoAnimation = appContext.NoAnimation - - // Initialize SafeBorder if not already done (T024-T025) - // Detection runs once, result is cached in app.Context - if appContext.SafeBorder == nil { - appContext.SafeBorder = components.NewSafeBorder() - } - - // Initialize ComponentFactory from ProfileContext + SafeBorder (T024-T025) - // Factory is created eagerly to ensure all commands have access to themed components - if appContext.Factory == nil { - profileCtx := appContext.GetProfileContext() - borderTier := appContext.SafeBorder.Tier() - appContext.Factory = ui.NewComponentFactory(profileCtx, borderTier) - } -} - -// integrateProfileMiddleware sets up the ProfileMiddleware for the command tree. -// This function is currently DISABLED and will be activated in a later phase. -// -// IMPORTANT: This is infrastructure preparation only (Phase 2 wiring). -// Actual middleware integration will happen when the dashboard is implemented. -// -// When enabled, this will: -// 1. Create ProfileMiddleware with appContext + SafeBorder -// 2. Integrate into rootCmd's PersistentPreRunE chain -// 3. Wrap all command RunE functions with ErrorBoundary -// 4. Inject ComponentFactory into command context -// -// Spec: 015-ui-refactor, Task T024-T025 -// -//nolint:unused // Intentionally disabled, will be enabled in Phase 3 dashboard implementation -func integrateProfileMiddleware(cmd *cobra.Command, ctx *app.Context) error { - // DISABLED - will be enabled in Phase 3 (dashboard implementation) - // This function exists to document the integration point and pattern. - // - // Example implementation (when enabled): - // - // if ctx.SafeBorder == nil || ctx.Factory == nil { - // return fmt.Errorf("SafeBorder and Factory must be initialized before middleware") - // } - // - // // Create middleware with real factory and boundary constructors - // factoryFn := func(profileCtx *profiles.ProfileContext, tier components.BorderTier) (middleware.ComponentFactory, error) { - // factory := ui.NewComponentFactory(profileCtx, tier) - // return factory, nil - // } - // - // boundaryFn := func(factory middleware.ComponentFactory, uiService *ui.Service, hints *clierrors.HintRegistry) middleware.ErrorBoundary { - // return middleware.NewErrorBoundary(factory, uiService, hints) - // } - // - // mw := middleware.NewProfileMiddleware(ctx, ctx.SafeBorder, factoryFn, boundaryFn) - // return mw.Integrate(cmd) - - _ = cmd // Silence unused parameter - _ = ctx // Silence unused parameter - return nil } diff --git a/pkg/cli/services/deps.go b/pkg/cli/services/deps.go index c747f4c..3a5b3e9 100644 --- a/pkg/cli/services/deps.go +++ b/pkg/cli/services/deps.go @@ -10,10 +10,6 @@ import ( "github.com/spf13/cobra" "github.com/arc-framework/arc-cli/pkg/catalog" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/styles" - "github.com/arc-framework/arc-cli/pkg/ui/views" ) // depsOptions holds the flags for the deps command. @@ -72,51 +68,12 @@ func runDeps(codename string, opts *depsOptions) error { return outputDepsJSON(depTree, resolved) } - // New UI path (opt-out with ARC_USE_LEGACY_UI) - if os.Getenv("ARC_USE_LEGACY_UI") == "" { - if newUIErr := renderDepsWithNewUI(codename, depTree); newUIErr == nil { - return nil - } - } - - return legacyRenderDepsTree(depTree, resolved) -} - -// renderDepsWithNewUI renders the dependency tree using the new ServiceDepsView. -// -//nolint:dupl // new-UI render bootstrap is structurally identical to renderPortsWithNewUI by design -func renderDepsWithNewUI(codename string, depTree *catalog.DependencyNode) error { - if appContext == nil { - return fmt.Errorf("app context not available") - } - - profileCtx := appContext.GetProfileContext() - if profileCtx == nil { - return fmt.Errorf("profile context not available") + // New UI: ServiceDetail view shows service info + dependency tree. + if tuiErr := renderServiceDetailWithNewUI(codename); tuiErr == nil { + return nil } - borderTier := appContext.SafeBorder.Tier() - factory := ui.NewComponentFactory(profileCtx, borderTier) - - view := views.NewServiceDepsView(factory) - - viewCtx := engine.NewViewContext( - profileCtx.Profile(), - profileCtx.Theme(), - 80, - 40, - map[string]any{ - "service_name": codename, - "deps_tree": depTree, - }, - ) - - _ = view.OnEnter(viewCtx) - - return engine.Render(engine.RenderConfig{ - View: view, - Mode: engine.TUIMode, - }) + return renderDepsTree(depTree, resolved) } // depsJSONOutput represents the JSON output structure. @@ -189,19 +146,10 @@ func buildTreeJSON(node *catalog.DependencyNode) *treeNodeJSON { return jsonNode } -// legacyRenderDepsTree outputs the dependency tree with box-drawing characters. -func legacyRenderDepsTree(depTree *catalog.DependencyNode, resolved []*catalog.Service) error { - // Style definitions - headerStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(styles.PrimaryStyle.GetForeground()) - - codenameStyle := lipgloss.NewStyle(). - Foreground(styles.PrimaryStyle.GetForeground()). - Bold(true) - - techStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#50FA7B")) +func renderDepsTree(depTree *catalog.DependencyNode, resolved []*catalog.Service) error { + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00ADD8")) + codenameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00ADD8")).Bold(true) + techStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#50FA7B")) mutedStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#6272A4")) diff --git a/pkg/cli/services/info.go b/pkg/cli/services/info.go index 5e07e1d..20e5ecb 100644 --- a/pkg/cli/services/info.go +++ b/pkg/cli/services/info.go @@ -10,7 +10,9 @@ import ( "github.com/spf13/cobra" "github.com/arc-framework/arc-cli/pkg/catalog" - "github.com/arc-framework/arc-cli/pkg/ui/styles" + newengine "github.com/arc-framework/arc-cli/pkg/ui/engine" + uithemeldr "github.com/arc-framework/arc-cli/pkg/ui/theme" + uiview "github.com/arc-framework/arc-cli/pkg/ui/view" ) // infoOptions holds the flags for the info command. @@ -63,9 +65,33 @@ func runInfo(codename string, opts *infoOptions) error { return outputServiceJSON(service) } + // New UI: ServiceDetail view in focused mode. + if tuiErr := renderServiceDetailWithNewUI(codename); tuiErr == nil { + return nil + } + return outputServiceDetails(service) } +// renderServiceDetailWithNewUI opens ServiceDetail in focused mode for the given codename. +func renderServiceDetailWithNewUI(codename string) error { + loader, err := uithemeldr.NewLoader() + if err != nil { + return fmt.Errorf("theme loader: %w", err) + } + cfg := newengine.Config{ + Mode: newengine.ModeFocused, + Views: []newengine.View{uiview.NewServiceDetail()}, + Title: "Service Info", + Backend: newengine.Backend{ + Catalog: catalogInstance, + }, + InitialArgs: map[string]string{"service": codename}, + Loader: loader, + } + return newengine.Start(cfg) +} + // outputServiceJSON outputs service details as JSON. func outputServiceJSON(service *catalog.Service) error { enc := json.NewEncoder(os.Stdout) @@ -81,11 +107,11 @@ func outputServiceDetails(service *catalog.Service) error { // Style definitions headerStyle := lipgloss.NewStyle(). Bold(true). - Foreground(styles.PrimaryStyle.GetForeground()) + Foreground(lipgloss.Color("#00ADD8")) sectionStyle := lipgloss.NewStyle(). Bold(true). - Foreground(styles.SecondaryStyle.GetForeground()) + Foreground(lipgloss.Color("#BD93F9")) labelStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#6272A4")). diff --git a/pkg/cli/services/integration_test.go b/pkg/cli/services/integration_test.go index 3be3578..1da383f 100644 --- a/pkg/cli/services/integration_test.go +++ b/pkg/cli/services/integration_test.go @@ -264,9 +264,6 @@ func TestCLI_ServicesCommand_NewUI(t *testing.T) { SetCatalog(cat) t.Run("list command with new UI mode executes without error", func(t *testing.T) { - // Ensure legacy UI is not set - os.Unsetenv("ARC_USE_LEGACY_UI") - cmd := NewServicesCmd() _, err := executeCommandWithError(cmd, "list") // Command should execute without error @@ -276,10 +273,7 @@ func TestCLI_ServicesCommand_NewUI(t *testing.T) { } }) - t.Run("list command without legacy UI flag uses new UI path", func(t *testing.T) { - // Explicitly unset legacy UI - os.Unsetenv("ARC_USE_LEGACY_UI") - + t.Run("list command uses new UI path", func(t *testing.T) { cmd := NewServicesCmd() _, err := executeCommandWithError(cmd, "list") // Should succeed (falls back to legacy UI if app context unavailable) diff --git a/pkg/cli/services/list.go b/pkg/cli/services/list.go index 4707c89..3f3e07f 100644 --- a/pkg/cli/services/list.go +++ b/pkg/cli/services/list.go @@ -10,11 +10,9 @@ import ( "github.com/spf13/cobra" "github.com/arc-framework/arc-cli/pkg/catalog" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components/table" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/styles" - "github.com/arc-framework/arc-cli/pkg/ui/views" + newengine "github.com/arc-framework/arc-cli/pkg/ui/engine" + uithemeldr "github.com/arc-framework/arc-cli/pkg/ui/theme" + uiview "github.com/arc-framework/arc-cli/pkg/ui/view" ) // listOptions holds the flags for the list command. @@ -83,11 +81,6 @@ func runList(opts *listOptions) error { return outputJSON(services) } - // Check for legacy UI fallback (T114) - if os.Getenv("ARC_USE_LEGACY_UI") != "" { - return outputTable(services, opts.noTree) - } - // Use new ServicesListView if app context is available if appContext != nil && !opts.noTree { return renderWithNewUI(services) @@ -97,55 +90,28 @@ func runList(opts *listOptions) error { return outputTable(services, opts.noTree) } -// renderWithNewUI renders the services list using the new ServicesListView (T112). -func renderWithNewUI(services []*catalog.Service) error { - // Convert catalog services to table rows - rows := make([]table.Row, len(services)) - for i, svc := range services { - // Format: Name (Technology), Role, Description - name := fmt.Sprintf("%s (%s)", svc.Codename, svc.Technology) - role := string(svc.Role) - description := truncate(svc.Description, 40) - - rows[i] = table.Row{name, role, description} - } - - // Get profile context and factory from app context - profileCtx := appContext.GetProfileContext() - if profileCtx == nil { - // Fallback to legacy UI if profile context is not available - return fmt.Errorf("profile context not available, falling back to legacy UI") +// renderWithNewUI renders the services list using the new engine in focused mode (T052). +// The catalog is made available to the view via Backend; the view fetches services itself. +func renderWithNewUI(_ []*catalog.Service) error { + loader, err := uithemeldr.NewLoader() + if err != nil { + // Theme loader failed — fall back to legacy table output. + svcs, _ := catalogInstance.ListServices(catalog.FilterAll) + return outputTable(svcs, false) } - // Get border tier from SafeBorder - borderTier := appContext.SafeBorder.Tier() - - // Create component factory - factory := ui.NewComponentFactory(profileCtx, borderTier) - - // Create ServicesListView - view := views.NewServicesListView(factory) - - // Initialize view context with theme and profile - viewCtx := engine.NewViewContext( - profileCtx.Profile(), - profileCtx.Theme(), - 80, // Default width, will be updated by terminal size - 40, // Default height, will be updated by terminal size - map[string]any{ - "services": services, - "rows": rows, + cfg := newengine.Config{ + Mode: newengine.ModeFocused, + Views: []newengine.View{uiview.NewServicesList()}, + Title: "Services", + Backend: newengine.Backend{ + Catalog: catalogInstance, + Store: appContext.Store, }, - ) - - // Call OnEnter to initialize the view with data - _ = view.OnEnter(viewCtx) - - // Render using engine (T112) - return engine.Render(engine.RenderConfig{ - View: view, - Mode: engine.TUIMode, - }) + Prefs: appContext.Prefs, + Loader: loader, + } + return newengine.Start(cfg) } // outputJSON outputs services as JSON. @@ -173,15 +139,15 @@ func outputTable(services []*catalog.Service, noTree bool) error { // Style definitions headerStyle := lipgloss.NewStyle(). Bold(true). - Foreground(styles.PrimaryStyle.GetForeground()). + Foreground(lipgloss.Color("#00ADD8")). Underline(true) roleStyle := lipgloss.NewStyle(). Bold(true). - Foreground(styles.SecondaryStyle.GetForeground()) + Foreground(lipgloss.Color("#BD93F9")) codenameStyle := lipgloss.NewStyle(). - Foreground(styles.PrimaryStyle.GetForeground()). + Foreground(lipgloss.Color("#00ADD8")). Bold(true) techStyle := lipgloss.NewStyle(). diff --git a/pkg/cli/services/ports.go b/pkg/cli/services/ports.go index d780888..9962024 100644 --- a/pkg/cli/services/ports.go +++ b/pkg/cli/services/ports.go @@ -9,10 +9,6 @@ import ( "github.com/spf13/cobra" "github.com/arc-framework/arc-cli/pkg/catalog" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/styles" - "github.com/arc-framework/arc-cli/pkg/ui/views" ) // portsOptions holds the flags for the ports command. @@ -57,51 +53,7 @@ func runPorts(opts *portsOptions) error { return outputPortsJSON(allocations, conflicts) } - // New UI path (opt-out with ARC_USE_LEGACY_UI) - if os.Getenv("ARC_USE_LEGACY_UI") == "" { - if err := renderPortsWithNewUI(allocations, conflicts); err == nil { - return nil - } - } - - return legacyRenderPortsTable(allocations, conflicts) -} - -// renderPortsWithNewUI renders port allocations using the new PortsTableView. -// -//nolint:dupl // new-UI render bootstrap is structurally identical to renderDepsWithNewUI by design -func renderPortsWithNewUI(allocations []catalog.PortAllocation, conflicts []catalog.PortConflict) error { - if appContext == nil { - return fmt.Errorf("app context not available") - } - - profileCtx := appContext.GetProfileContext() - if profileCtx == nil { - return fmt.Errorf("profile context not available") - } - - borderTier := appContext.SafeBorder.Tier() - factory := ui.NewComponentFactory(profileCtx, borderTier) - - view := views.NewPortsTableView(factory) - - viewCtx := engine.NewViewContext( - profileCtx.Profile(), - profileCtx.Theme(), - 80, - 40, - map[string]any{ - "allocations": allocations, - "conflicts": conflicts, - }, - ) - - _ = view.OnEnter(viewCtx) - - return engine.Render(engine.RenderConfig{ - View: view, - Mode: engine.TUIMode, - }) + return renderPortsTable(allocations, conflicts) } // portsJSONOutput represents the JSON output structure. @@ -160,29 +112,13 @@ func outputPortsJSON(allocations []catalog.PortAllocation, conflicts []catalog.P return enc.Encode(output) } -// legacyRenderPortsTable outputs port info as a formatted table. -func legacyRenderPortsTable(allocations []catalog.PortAllocation, conflicts []catalog.PortConflict) error { - // Style definitions - headerStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(styles.PrimaryStyle.GetForeground()) - - columnHeaderStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(styles.SecondaryStyle.GetForeground()) - - portStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#50FA7B")) - - serviceStyle := lipgloss.NewStyle(). - Foreground(styles.PrimaryStyle.GetForeground()) - - conflictStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF5555")). - Bold(true) - - mutedStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6272A4")) +func renderPortsTable(allocations []catalog.PortAllocation, conflicts []catalog.PortConflict) error { + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00ADD8")) + columnHeaderStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#BD93F9")) + portStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#50FA7B")) + serviceStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00ADD8")) + conflictStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5555")).Bold(true) + mutedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6272A4")) // Build set of conflicting ports conflictPorts := make(map[int]bool) diff --git a/pkg/cli/theme.go b/pkg/cli/theme.go index a77281d..a1753d7 100644 --- a/pkg/cli/theme.go +++ b/pkg/cli/theme.go @@ -2,102 +2,31 @@ package cli import ( "fmt" - "os" "sort" - "time" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" "github.com/arc-framework/arc-cli/internal/preferences" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/animations" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/styles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" - "github.com/arc-framework/arc-cli/pkg/ui/views" + uithemeldr "github.com/arc-framework/arc-cli/pkg/ui/theme" ) -// ThemePreviewState manages theme preview animation state -// Note: Currently unused but available for future interactive preview features -type ThemePreviewState struct { - ThemeName string - StartTime time.Time - CurrentStep int - TotalSteps int - Animator components.Animator - Paused bool -} - -// NewThemePreviewState creates a new theme preview state -// Note: Currently unused but available for future interactive preview features -func NewThemePreviewState(themeName string) *ThemePreviewState { - return &ThemePreviewState{ - ThemeName: themeName, - StartTime: time.Now(), - CurrentStep: 0, - TotalSteps: 4, // Banner, Success, Error, Info, Warning - Animator: components.NewAnimator(), - Paused: false, - } -} - -// NextStep advances to the next preview step -func (tps *ThemePreviewState) NextStep() { - tps.CurrentStep++ - if tps.CurrentStep >= tps.TotalSteps { - tps.CurrentStep = 0 - } -} - -// IsComplete returns true if all preview steps are shown -func (tps *ThemePreviewState) IsComplete() bool { - return tps.CurrentStep >= tps.TotalSteps -} - -// Progress returns preview progress as percentage (0.0-1.0) -func (tps *ThemePreviewState) Progress() float64 { - if tps.TotalSteps == 0 { - return 1.0 - } - return float64(tps.CurrentStep) / float64(tps.TotalSteps) -} - -// Reset resets the preview state to the beginning -func (tps *ThemePreviewState) Reset() { - tps.CurrentStep = 0 - tps.StartTime = time.Now() -} - -// Pause pauses the preview animation -func (tps *ThemePreviewState) Pause() { - tps.Paused = true -} - -// Resume resumes the preview animation -func (tps *ThemePreviewState) Resume() { - tps.Paused = false -} +const themeCharacterRainbow = "character-rainbow" func init() { rootCmd.AddCommand(themeCmd) themeCmd.AddCommand(themeListCmd) themeCmd.AddCommand(themeSetCmd) themeCmd.AddCommand(themeShowCmd) - themeCmd.AddCommand(themePreviewCmd) - // Add flags themeListCmd.Flags().BoolP("preview", "p", false, "Show inline color previews") - themeSetCmd.Flags().Bool("no-animation", false, "Skip transition animation") - themePreviewCmd.Flags().Bool("no-animation", false, "Skip preview animations") } var themeCmd = &cobra.Command{ - Use: "theme", - Short: "Manage banner color themes", - Long: "List, set, and preview banner color themes. Themes are persisted across sessions.", + Use: "theme", + Hidden: true, + Short: "Manage banner color themes", + Long: "List, set, and preview banner color themes. Themes are persisted across sessions.", } var themeListCmd = &cobra.Command{ @@ -106,133 +35,86 @@ var themeListCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { showPreview, _ := cmd.Flags().GetBool("preview") - // New UI engine path (opt-out with ARC_USE_LEGACY_UI env var) - if os.Getenv("ARC_USE_LEGACY_UI") == "" { - if err := renderThemeListWithNewUI(); err != nil { - GetLogger().Error("Failed to render ThemeListView", "error", err) - // Fall through to legacy rendering - } else { - return - } - } - - // Legacy rendering path appState, err := preferences.Load() if err != nil { appState = preferences.Default() } currentTheme := appState.GetTheme() - // Use YAML-based theme loader - themeLoader := themes.NewLoader() - themeNames, err := themeLoader.List() + loader, err := uithemeldr.NewLoader() if err != nil { - styles.Error("Failed to list themes: %v", err) + fmt.Fprintf(cmd.ErrOrStderr(), "error: failed to load themes: %v\n", err) return } - // Add character-rainbow special theme - themeNames = append(themeNames, themeCharacterRainbow) + themeNames := append(loader.ListThemes(), themeCharacterRainbow) sort.Strings(themeNames) - styles.Info("Available Themes:") + primary := lipgloss.NewStyle().Foreground(lipgloss.Color("#00ADD8")).Bold(true) + success := lipgloss.NewStyle().Foreground(lipgloss.Color("#50FA7B")).Bold(true) + muted := lipgloss.NewStyle().Foreground(lipgloss.Color("#6272A4")) + + fmt.Println(primary.Render("Available Themes:")) fmt.Println() - for i, name := range themeNames { + for _, name := range themeNames { marker := " " if name == currentTheme { - marker = styles.SuccessStyle.Render("▶ ") + marker = success.Render("▶ ") } if name == themeCharacterRainbow { - fmt.Printf("%s%s - %s\n", - marker, - styles.PrimaryStyle.Render(name), - "Rainbow gradient on every character (ultimate colors!)") - } else { - // Load theme to get description - theme, loadErr := themeLoader.Load(name) - description := "Theme" - if loadErr == nil { - description = theme.Description - } - - fmt.Printf("%s%s - %s\n", - marker, - styles.PrimaryStyle.Render(name), - description) - - // Show inline color preview if requested - if showPreview && loadErr == nil { - if animations.ShouldAnimate() { - // Staggered animation effect (waterfall) - time.Sleep(time.Duration(i*30) * time.Millisecond) - } - - fmt.Printf(" Colors: ") - for j, colorHex := range theme.Colors.BannerGradient { - color := lipgloss.Color(colorHex) - colorStyle := lipgloss.NewStyle().Foreground(color) + fmt.Printf("%s%s - %s\n", marker, primary.Render(name), + muted.Render("Rainbow gradient on every character")) + continue + } - if animations.ShouldAnimate() { - // Subtle animation per color block - time.Sleep(20 * time.Millisecond) - } + theme, loadErr := loader.GetTheme(name) + displayName := "" + if loadErr == nil && theme.Name != "" { + displayName = theme.Name + } - fmt.Printf("%s", colorStyle.Render("██")) - if j < len(theme.Colors.BannerGradient)-1 { - fmt.Printf(" ") - } + line := fmt.Sprintf("%s%s", marker, primary.Render(name)) + if displayName != "" && displayName != name { + line += " - " + muted.Render(displayName) + } + fmt.Println(line) + + if showPreview && loadErr == nil { + fmt.Printf(" Colors: ") + for _, field := range []string{"primary", "secondary", "accent", "success", "warning", "error"} { + c := theme.Colors.ToLipglossColor(field) + if c != "" { + fmt.Printf("%s ", lipgloss.NewStyle().Foreground(c).Render("██")) } - fmt.Println() } + fmt.Println() } } fmt.Println() - styles.Info("Current theme: %s", currentTheme) + fmt.Printf("%s %s\n", muted.Render("Current theme:"), primary.Render(currentTheme)) fmt.Println() fmt.Println("Use 'arc theme set ' to change the theme") - fmt.Println("Use 'arc theme preview ' to see an animated preview") - fmt.Println("Use 'arc theme show' to preview the current theme") }, } -// renderThemeListWithNewUI renders the theme list using ThemeListView (T352). -func renderThemeListWithNewUI() error { - profileCtx := profiles.GetDefaultProfileContext() - factory := ui.NewComponentFactory(profileCtx, components.BorderTierClassic) - view := views.NewThemeListView(factory) - return engine.Render(engine.RenderConfig{ - View: view, - Mode: engine.TUIMode, - }) -} - var themeSetCmd = &cobra.Command{ Use: "set ", Short: "Set the banner color theme", - Long: "Set the banner color theme. The theme will be persisted across sessions.", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - themeSetLogger := GetLogger() themeName := args[0] - noAnimation, _ := cmd.Flags().GetBool("no-animation") - - themeSetLogger.Debug("Setting theme", "theme", themeName, "no_animation", noAnimation) - // Use YAML-based theme loader - themeLoader := themes.NewLoader() - themeNames, err := themeLoader.List() + loader, err := uithemeldr.NewLoader() if err != nil { - themeSetLogger.Warn("Failed to list themes", "error", err) - styles.Error("Failed to list available themes: %v", err) + fmt.Fprintf(cmd.ErrOrStderr(), "error: failed to load themes: %v\n", err) return } - // Check if theme exists (or is character-rainbow) validTheme := themeName == themeCharacterRainbow - for _, name := range themeNames { + for _, name := range loader.ListThemes() { if name == themeName { validTheme = true break @@ -240,54 +122,32 @@ var themeSetCmd = &cobra.Command{ } if !validTheme { - themeSetLogger.Warn("Invalid theme requested", "theme", themeName) - styles.Error("Unknown theme: %s", themeName) - fmt.Println() - fmt.Println("Available themes:") - for _, name := range themeNames { - fmt.Printf(" - %s\n", name) + fmt.Fprintf(cmd.ErrOrStderr(), "error: unknown theme %q\n\nAvailable themes:\n", themeName) + for _, name := range loader.ListThemes() { + fmt.Fprintf(cmd.ErrOrStderr(), " - %s\n", name) } - fmt.Printf(" - %s\n", themeCharacterRainbow) + fmt.Fprintf(cmd.ErrOrStderr(), " - %s\n", themeCharacterRainbow) return } - appState, err := preferences.Load() - if err != nil { + appState, loadErr := preferences.Load() + if loadErr != nil { appState = preferences.Default() } if err = appState.SetTheme(themeName); err != nil { - themeSetLogger.Error("Failed to save theme preference", "error", err) - styles.Error("Failed to save theme: %v", err) + fmt.Fprintf(cmd.ErrOrStderr(), "error: failed to save theme: %v\n", err) return } - // Update styles immediately so success message uses new theme colors - // Only update for non-character-rainbow themes (rainbow is handled specially) - if themeName != themeCharacterRainbow { - if loadedTheme, loadErr := themeLoader.Load(themeName); loadErr == nil { - styles.UpdateStylesFromTheme( - loadedTheme.Colors.PrimaryColor(), - loadedTheme.Colors.SecondaryColor(), - loadedTheme.Colors.SuccessColor(), - loadedTheme.Colors.ErrorColor(), - loadedTheme.Colors.WarningColor(), - loadedTheme.Colors.InfoColor(), - ) - } - } - - themeSetLogger.Info("Theme updated successfully", "theme", themeName) - styles.Success("Theme set to: %s", themeName) - fmt.Println() - styles.Info("Theme applied! Colors updated for this session.") - fmt.Println() + success := lipgloss.NewStyle().Foreground(lipgloss.Color("#50FA7B")).Bold(true) + fmt.Printf("%s theme set to: %s\n", success.Render("✓"), themeName) }, } var themeShowCmd = &cobra.Command{ Use: "show", - Short: "Show the current theme banner", + Short: "Show the current theme", Run: func(cmd *cobra.Command, args []string) { appState, err := preferences.Load() if err != nil { @@ -295,133 +155,25 @@ var themeShowCmd = &cobra.Command{ } currentTheme := appState.GetTheme() - fmt.Println(RenderBanner(nil)) - fmt.Println() - styles.Info("Current theme: %s", currentTheme) - }, -} + primary := lipgloss.NewStyle().Foreground(lipgloss.Color("#00ADD8")).Bold(true) + muted := lipgloss.NewStyle().Foreground(lipgloss.Color("#6272A4")) -var themePreviewCmd = &cobra.Command{ - Use: "preview [theme-name]", - Short: "Preview a theme with animated demonstration", - Long: "Display an animated preview of a theme showing banner, success, error, info, and warning styles", - Args: cobra.MaximumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { - previewLogger := GetLogger() - noAnimation, _ := cmd.Flags().GetBool("no-animation") + fmt.Printf("%s %s\n", muted.Render("Current theme:"), primary.Render(currentTheme)) - // Determine which theme to preview - var themeName string - if len(args) > 0 { - themeName = args[0] - } else { - appState, err := preferences.Load() - if err != nil { - appState = preferences.Default() - } - themeName = appState.GetTheme() - } - - previewLogger.Debug("Previewing theme", "theme", themeName) - - // Use YAML-based theme loader for validation - themeLoader := themes.NewLoader() - themeNames, listErr := themeLoader.List() - if listErr != nil { - previewLogger.Warn("Failed to list themes", "error", listErr) - styles.Error("Failed to list available themes: %v", listErr) - return - } - - // Check if theme exists (or is character-rainbow) - validTheme := themeName == themeCharacterRainbow - for _, name := range themeNames { - if name == themeName { - validTheme = true - break - } - } - - if !validTheme { - previewLogger.Warn("Invalid theme requested", "theme", themeName) - styles.Error("Unknown theme: %s", themeName) - fmt.Println() - fmt.Println("Available themes:") - for _, name := range themeNames { - fmt.Printf(" - %s\n", name) - } - fmt.Printf(" - %s\n", themeCharacterRainbow) + loader, err := uithemeldr.NewLoader() + if err != nil { return } - - // Load the theme for preview - var loadedTheme *themes.Theme - if themeName != themeCharacterRainbow { - if t, loadErr := themeLoader.Load(themeName); loadErr == nil { - loadedTheme = t - } - } - - // Display theme preview - fmt.Println() - styles.Info("🎨 Theme Preview: %s", themeName) - fmt.Println() - - // Show animated color preview (if animation enabled) - if !noAnimation && loadedTheme != nil { - if animations.ShouldAnimate() { - scheme := loadedTheme.ToScheme() - if err := animations.AnimateThemePreview(&scheme, 2*time.Second); err != nil { - previewLogger.Debug("Theme preview animation skipped", "error", err) + if t, getErr := loader.GetTheme(currentTheme); getErr == nil && t.Name != "" { + fmt.Printf("%s %s\n", muted.Render("Display name:"), t.Name) + fmt.Printf("Colors: ") + for _, field := range []string{"primary", "secondary", "accent", "success", "warning", "error"} { + c := t.Colors.ToLipglossColor(field) + if c != "" { + fmt.Printf("%s ", lipgloss.NewStyle().Foreground(c).Render("██")) } - fmt.Println() // Add newline after animation - } - } - - // Display banner - fmt.Println(RenderBanner(nil)) - fmt.Println() - - if !noAnimation { - time.Sleep(200 * time.Millisecond) - } - - // Show style examples - if loadedTheme != nil { - fmt.Println(styles.PrimaryStyle.Render("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")) - fmt.Println() - - // Success example - styles.Success("✓ Operation completed successfully") - if !noAnimation { - time.Sleep(150 * time.Millisecond) - } - - // Error example - styles.Error("✗ Operation failed with error") - if !noAnimation { - time.Sleep(150 * time.Millisecond) } - - // Info example - styles.Info("ℹ Information message") - if !noAnimation { - time.Sleep(150 * time.Millisecond) - } - - // Warning example - styles.Warn("⚠ Warning message") - if !noAnimation { - time.Sleep(150 * time.Millisecond) - } - fmt.Println() - fmt.Println(styles.PrimaryStyle.Render("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")) } - - fmt.Println() - styles.Info("Use 'arc theme set %s' to activate this theme", themeName) - - logger.Info("Theme preview completed", "theme", themeName) }, } diff --git a/pkg/cli/theme_test.go b/pkg/cli/theme_test.go deleted file mode 100644 index 8f5a123..0000000 --- a/pkg/cli/theme_test.go +++ /dev/null @@ -1,224 +0,0 @@ -package cli - -import ( - "bytes" - "testing" - - "github.com/arc-framework/arc-cli/pkg/ui/animations" -) - -func TestThemeCommand_Exists(t *testing.T) { - // Note: Not using t.Parallel() - - // Test that theme command is registered - cmd := rootCmd - foundCmd, _, err := cmd.Find([]string{"theme"}) - if err != nil { - t.Fatalf("Theme command not found: %v", err) - } - - if foundCmd == nil { - t.Fatal("Theme command is nil") - } -} - -func TestThemeCommand_HasSubcommands(t *testing.T) { - // Note: Not using t.Parallel() - - cmd := rootCmd - foundCmd, _, err := cmd.Find([]string{"theme"}) - if err != nil { - t.Skip("Theme command not registered") - return - } - - // Theme should have subcommands like list, set, show, preview - if !foundCmd.HasSubCommands() { - t.Error("Theme command should have subcommands") - } -} - -func TestThemeListCommand_Exists(t *testing.T) { - // Note: Not using t.Parallel() - - cmd := rootCmd - listCmd, _, err := cmd.Find([]string{"theme", "list"}) - if err != nil { - t.Skip("Theme list command not registered") - return - } - - if listCmd == nil { - t.Error("Theme list command is nil") - } -} - -func TestThemeSetCommand_Exists(t *testing.T) { - // Note: Not using t.Parallel() - - cmd := rootCmd - setCmd, _, err := cmd.Find([]string{"theme", "set"}) - if err != nil { - t.Skip("Theme set command not registered") - return - } - - if setCmd == nil { - t.Error("Theme set command is nil") - } -} - -func TestThemeShowCommand_Exists(t *testing.T) { - // Note: Not using t.Parallel() - - cmd := rootCmd - showCmd, _, err := cmd.Find([]string{"theme", "show"}) - if err != nil { - t.Skip("Theme show command not registered") - return - } - - if showCmd == nil { - t.Error("Theme show command is nil") - } -} - -func TestThemePreviewCommand_Exists(t *testing.T) { - // Note: Not using t.Parallel() - - cmd := rootCmd - previewCmd, _, err := cmd.Find([]string{"theme", "preview"}) - if err != nil { - t.Skip("Theme preview command not registered") - return - } - - if previewCmd == nil { - t.Error("Theme preview command is nil") - } -} - -func TestThemeCommand_NoAnimationFlag(t *testing.T) { - // Note: Not using t.Parallel() - - cmd := rootCmd - foundCmd, _, err := cmd.Find([]string{"theme", "set"}) - if err != nil { - t.Skip("Theme set command not registered") - return - } - - // Check if --no-animation flag exists on theme set - noAnimFlag := foundCmd.Flags().Lookup("no-animation") - if noAnimFlag == nil { - t.Log("Theme set command should ideally have --no-animation flag") - } -} - -func TestThemeCommand_HasDescription(t *testing.T) { - // Note: Not using t.Parallel() - - cmd := rootCmd - foundCmd, _, err := cmd.Find([]string{"theme"}) - if err != nil { - t.Skip("Theme command not registered") - return - } - - if foundCmd.Short == "" { - t.Error("Theme command should have a short description") - } -} - -func TestThemeListCommand_WithPreviewFlag(t *testing.T) { - // Save original animation state - origNoAnimation := animations.NoAnimation - defer func() { animations.NoAnimation = origNoAnimation }() - - // Disable animations for test - animations.NoAnimation = true - - cmd := rootCmd - listCmd, _, err := cmd.Find([]string{"theme", "list"}) - if err != nil { - t.Skip("Theme list command not registered") - return - } - - // Check that --preview flag exists - previewFlag := listCmd.Flags().Lookup("preview") - if previewFlag == nil { - t.Error("Theme list command should have --preview flag") - } -} - -func TestThemeListCommand_ExecutesWithoutError(t *testing.T) { - // Save original animation state - origNoAnimation := animations.NoAnimation - defer func() { animations.NoAnimation = origNoAnimation }() - - // Disable animations for test - animations.NoAnimation = true - - cmd := rootCmd - listCmd, _, err := cmd.Find([]string{"theme", "list"}) - if err != nil { - t.Skip("Theme list command not registered") - return - } - - // Capture output - buf := new(bytes.Buffer) - listCmd.SetOut(buf) - listCmd.SetErr(buf) - - // Execute command - err = listCmd.Execute() - if err != nil { - t.Errorf("Theme list command should execute without error: %v", err) - } -} - -func TestThemePreviewCommand_ExecutesWithoutError(t *testing.T) { - // Save original animation state - origNoAnimation := animations.NoAnimation - defer func() { animations.NoAnimation = origNoAnimation }() - - // Disable animations for test - animations.NoAnimation = true - - cmd := rootCmd - previewCmd, _, err := cmd.Find([]string{"theme", "preview"}) - if err != nil { - t.Skip("Theme preview command not registered") - return - } - - // Capture output - buf := new(bytes.Buffer) - previewCmd.SetOut(buf) - previewCmd.SetErr(buf) - - // Execute command (should preview current theme) - err = previewCmd.Execute() - if err != nil { - t.Errorf("Theme preview command should execute without error: %v", err) - } -} - -func TestThemePreviewCommand_WithNoAnimationFlag(t *testing.T) { - // Note: Not using t.Parallel() - - cmd := rootCmd - previewCmd, _, err := cmd.Find([]string{"theme", "preview"}) - if err != nil { - t.Skip("Theme preview command not registered") - return - } - - // Check that --no-animation flag exists - noAnimFlag := previewCmd.Flags().Lookup("no-animation") - if noAnimFlag == nil { - t.Error("Theme preview command should have --no-animation flag") - } -} diff --git a/pkg/cli/version.go b/pkg/cli/version.go new file mode 100644 index 0000000..998172c --- /dev/null +++ b/pkg/cli/version.go @@ -0,0 +1,75 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + goruntime "runtime" + + "github.com/spf13/cobra" + + newengine "github.com/arc-framework/arc-cli/pkg/ui/engine" + uithemeldr "github.com/arc-framework/arc-cli/pkg/ui/theme" + uiview "github.com/arc-framework/arc-cli/pkg/ui/view" + "github.com/arc-framework/arc-cli/pkg/version" +) + +// newVersionCmd builds the "arc version" subcommand. +// Spec: 016-ui-layout-fix, Phase 1 (Version Metadata) +func newVersionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "version", + Short: "Show version and build information", + Long: `Display the A.R.C. CLI version, git commit hash, and build date. + +The version information is injected at build time via ldflags in the Makefile. +Use --verbose (-v) to see extended build information including the build date.`, + Example: ` # Show version and commit + arc version + + # Show extended version information + arc version --verbose`, + RunE: func(cmd *cobra.Command, args []string) error { + verboseFlag, _ := cmd.Flags().GetBool("verbose") + _ = verboseFlag // consumed by VersionView + jsonFlag, _ := cmd.Flags().GetBool("json") + + // JSON mode: structured output, no TUI (T061). + if jsonFlag { + out := map[string]any{ + "version": version.Version, + "commit": version.Commit, + "build_date": version.BuildDate, + "go_version": goruntime.Version(), + "os": goruntime.GOOS, + "arch": goruntime.GOARCH, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(out) + } + + // New UI: focused VersionView. + loader, loaderErr := uithemeldr.NewLoader() + if loaderErr == nil { + cfg := newengine.Config{ + Mode: newengine.ModeFocused, + Views: []newengine.View{uiview.NewVersionView()}, + Title: "Version", + Loader: loader, + } + if err := newengine.Start(cfg); err == nil { + return nil + } + } + + // Fallback: direct print output. + fmt.Println(version.GetVersionInfo()) + return nil + }, + } + + cmd.Flags().BoolP("verbose", "v", false, "Show extended version information including build date") + cmd.Flags().Bool("json", false, "Output version information as JSON") + return cmd +} diff --git a/pkg/cli/workspace/history.go b/pkg/cli/workspace/history.go index 48e5a6c..6bb1182 100644 --- a/pkg/cli/workspace/history.go +++ b/pkg/cli/workspace/history.go @@ -1,217 +1,33 @@ package workspace import ( - "fmt" - "os" - "path/filepath" - - "github.com/spf13/afero" "github.com/spf13/cobra" - "github.com/arc-framework/arc-cli/internal/state" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/views" - "github.com/arc-framework/arc-cli/pkg/workspace" - "github.com/arc-framework/arc-cli/pkg/workspace/store/local" + newengine "github.com/arc-framework/arc-cli/pkg/ui/engine" + uithemeldr "github.com/arc-framework/arc-cli/pkg/ui/theme" + uiview "github.com/arc-framework/arc-cli/pkg/ui/view" ) -// historyFlags holds flags for the history command -type historyFlags struct { - noColor bool - limit int - opType string - statusOnly string -} - -// NewHistoryCmd creates the workspace history command func NewHistoryCmd() *cobra.Command { - flags := &historyFlags{} - - cmd := &cobra.Command{ + return &cobra.Command{ Use: "history", Short: "Show workspace operation history", - Long: `Display the complete operation history for the current workspace. - -This command shows all operations performed on the workspace including: - - init: Workspace initialization - - generate: Configuration file generation - - run: Platform launch operations - -Each entry shows: - - Timestamp - - Operation type - - Status (success, failed, running, pending) - - Duration - - Operation ID - -Use --limit to restrict the number of entries shown. -Use --type to filter by operation type. -Use --status to filter by operation status.`, - Example: ` # Show full operation history - arc workspace history - - # Show last 10 operations - arc workspace history --limit 10 - - # Show only generation operations - arc workspace history --type generate - - # Show only failed operations - arc workspace history --status failed - - # Combine filters - arc workspace history --type generate --status failed --limit 5`, - Args: cobra.NoArgs, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return runHistory(flags) + return runHistory() }, } - - cmd.Flags().BoolVar(&flags.noColor, "no-color", false, "Disable colored output") - cmd.Flags().IntVarP(&flags.limit, "limit", "n", 0, "Limit number of entries shown (0 = all)") - cmd.Flags().StringVarP(&flags.opType, "type", "t", "", "Filter by operation type (init, generate, run)") - cmd.Flags().StringVarP(&flags.statusOnly, "status", "s", "", "Filter by status (success, failed, running, pending)") - - return cmd } -func runHistory(flags *historyFlags) error { - if os.Getenv("ARC_USE_LEGACY_UI") == "" { - if err := renderHistoryWithNewUI(flags); err == nil { - return nil - } - } - return legacyRunHistory(flags) -} - -// renderHistoryWithNewUI renders workspace history using the new WorkspaceHistoryView (T312). -func renderHistoryWithNewUI(flags *historyFlags) error { - fs := afero.NewOsFs() - detector := workspace.NewDetector(fs) - workspaceRoot, err := detector.DetectRoot(".") +func runHistory() error { + loader, err := uithemeldr.NewLoader() if err != nil { return err } - absPath := workspaceRoot - - stateDir := filepath.Join(absPath, ".arc", "state") - stateRepo := local.NewStateRepository(fs, stateDir) - - history, histErr := stateRepo.LoadHistory() - if histErr != nil { - return fmt.Errorf("failed to load history: %w", histErr) - } - - operations := history - - if flags.opType != "" { - opType := state.OperationType(flags.opType) - operations = workspace.FilterHistoryByType(operations, opType) - } - - if flags.statusOnly != "" { - status := state.OperationStatus(flags.statusOnly) - operations = workspace.FilterHistoryByStatus(operations, status) - } - - if flags.limit > 0 { - operations = workspace.LimitHistory(operations, flags.limit) - } - - historyMaps := make([]map[string]any, 0, len(operations)) - for _, op := range operations { - details := "" - if len(op.Errors) > 0 { - details = op.Errors[0] - } - historyMaps = append(historyMaps, map[string]any{ - "timestamp": op.Timestamp.Format("2006-01-02 15:04:05"), - "operation": string(op.OperationType), - "details": details, - "status": string(op.Status), - }) - } - - profileCtx := profiles.GetDefaultProfileContext() - factory := ui.NewComponentFactory(profileCtx, components.BorderTierClassic) - view := views.NewWorkspaceHistoryView(factory) - - limit := flags.limit - if limit <= 0 { - limit = 50 - } - - viewCtx := engine.NewViewContext( - profileCtx.Profile(), - profileCtx.Theme(), - 80, 40, - map[string]any{ - "history": historyMaps, - "limit": limit, - }, - ) - _ = view.OnEnter(viewCtx) - - return engine.Render(engine.RenderConfig{ - View: view, - Mode: engine.TUIMode, + return newengine.Start(newengine.Config{ + Mode: newengine.ModeFocused, + Views: []newengine.View{uiview.NewWorkspaceHistory()}, + Title: "Workspace History", + Loader: loader, }) } - -func legacyRunHistory(flags *historyFlags) error { - // Detect workspace root - fs := afero.NewOsFs() - detector := workspace.NewDetector(fs) - workspaceRoot, err := detector.DetectRoot(".") - if err != nil { - fmt.Fprintln(os.Stderr, "Error: Not in an A.R.C. workspace") - fmt.Fprintln(os.Stderr, "\nTo create a new workspace, run:") - fmt.Fprintln(os.Stderr, " arc workspace init") - return err - } - - // workspaceRoot is already absolute from DetectRoot - absPath := workspaceRoot - - // Create repositories - stateDir := filepath.Join(absPath, ".arc", "state") - stateRepo := local.NewStateRepository(fs, stateDir) - - // Load history - history, histErr := stateRepo.LoadHistory() - if histErr != nil { - return fmt.Errorf("failed to load history: %w", histErr) - } - - // Apply filters - operations := history - - // Filter by type - if flags.opType != "" { - opType := state.OperationType(flags.opType) - operations = workspace.FilterHistoryByType(operations, opType) - } - - // Filter by status - if flags.statusOnly != "" { - status := state.OperationStatus(flags.statusOnly) - operations = workspace.FilterHistoryByStatus(operations, status) - } - - // Apply limit - if flags.limit > 0 { - operations = workspace.LimitHistory(operations, flags.limit) - } - - // Format and display - useColor := !flags.noColor && isTerminal() - formatter := workspace.NewFormatter(useColor) - output := formatter.FormatHistory(operations) - - fmt.Print(output) - - return nil -} diff --git a/pkg/cli/workspace/history_test.go b/pkg/cli/workspace/history_test.go index a72143d..404154d 100644 --- a/pkg/cli/workspace/history_test.go +++ b/pkg/cli/workspace/history_test.go @@ -13,182 +13,12 @@ func TestNewHistoryCmd(t *testing.T) { cmd := NewHistoryCmd() require.NotNil(t, cmd) - t.Run("command properties", func(t *testing.T) { - assert.Equal(t, "history", cmd.Use) - assert.NotEmpty(t, cmd.Short) - assert.NotEmpty(t, cmd.Long) - assert.NotEmpty(t, cmd.Example) - }) + assert.Equal(t, "history", cmd.Use) + assert.NotEmpty(t, cmd.Short) - t.Run("has expected flags", func(t *testing.T) { - // No-color flag - noColorFlag := cmd.Flags().Lookup("no-color") - require.NotNil(t, noColorFlag) - assert.Equal(t, "false", noColorFlag.DefValue) + err := cmd.Args(cmd, []string{}) + assert.NoError(t, err) - // Limit flag - limitFlag := cmd.Flags().Lookup("limit") - require.NotNil(t, limitFlag) - assert.Equal(t, "n", limitFlag.Shorthand) - assert.Equal(t, "0", limitFlag.DefValue) - - // Type flag - typeFlag := cmd.Flags().Lookup("type") - require.NotNil(t, typeFlag) - assert.Equal(t, "t", typeFlag.Shorthand) - - // Status flag - statusFlag := cmd.Flags().Lookup("status") - require.NotNil(t, statusFlag) - assert.Equal(t, "s", statusFlag.Shorthand) - }) - - t.Run("accepts no args", func(t *testing.T) { - // No args should be allowed - err := cmd.Args(cmd, []string{}) - assert.NoError(t, err) - - // Args should fail - err = cmd.Args(cmd, []string{"extra-arg"}) - assert.Error(t, err) - }) -} - -func TestHistoryFlags(t *testing.T) { - t.Parallel() - - t.Run("default values", func(t *testing.T) { - flags := &historyFlags{} - assert.False(t, flags.noColor) - assert.Equal(t, 0, flags.limit) - assert.Empty(t, flags.opType) - assert.Empty(t, flags.statusOnly) - }) -} - -func TestNewHistoryCmd_FlagParsing(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - args []string - wantNoColor bool - wantLimit int - wantType string - wantStatusOnly string - }{ - { - name: "no flags", - args: []string{}, - wantNoColor: false, - wantLimit: 0, - wantType: "", - wantStatusOnly: "", - }, - { - name: "no-color flag", - args: []string{"--no-color"}, - wantNoColor: true, - wantLimit: 0, - wantType: "", - wantStatusOnly: "", - }, - { - name: "limit short flag", - args: []string{"-n", "10"}, - wantNoColor: false, - wantLimit: 10, - wantType: "", - wantStatusOnly: "", - }, - { - name: "limit long flag", - args: []string{"--limit", "5"}, - wantNoColor: false, - wantLimit: 5, - wantType: "", - wantStatusOnly: "", - }, - { - name: "type short flag", - args: []string{"-t", "generate"}, - wantNoColor: false, - wantLimit: 0, - wantType: "generate", - wantStatusOnly: "", - }, - { - name: "type long flag", - args: []string{"--type", "init"}, - wantNoColor: false, - wantLimit: 0, - wantType: "init", - wantStatusOnly: "", - }, - { - name: "status short flag", - args: []string{"-s", "failed"}, - wantNoColor: false, - wantLimit: 0, - wantType: "", - wantStatusOnly: "failed", - }, - { - name: "status long flag", - args: []string{"--status", "success"}, - wantNoColor: false, - wantLimit: 0, - wantType: "", - wantStatusOnly: "success", - }, - { - name: "combined flags", - args: []string{"--no-color", "-n", "20", "-t", "generate", "-s", "failed"}, - wantNoColor: true, - wantLimit: 20, - wantType: "generate", - wantStatusOnly: "failed", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cmd := NewHistoryCmd() - - // Parse flags - err := cmd.Flags().Parse(tt.args) - require.NoError(t, err) - - // Check parsed values - noColor, _ := cmd.Flags().GetBool("no-color") - limit, _ := cmd.Flags().GetInt("limit") - opType, _ := cmd.Flags().GetString("type") - statusOnly, _ := cmd.Flags().GetString("status") - - assert.Equal(t, tt.wantNoColor, noColor, "no-color flag") - assert.Equal(t, tt.wantLimit, limit, "limit flag") - assert.Equal(t, tt.wantType, opType, "type flag") - assert.Equal(t, tt.wantStatusOnly, statusOnly, "status flag") - }) - } -} - -func TestNewHistoryCmd_HelpOutput(t *testing.T) { - t.Parallel() - - cmd := NewHistoryCmd() - - t.Run("long description mentions key features", func(t *testing.T) { - assert.Contains(t, cmd.Long, "init") - assert.Contains(t, cmd.Long, "generate") - assert.Contains(t, cmd.Long, "Timestamp") - assert.Contains(t, cmd.Long, "Duration") - }) - - t.Run("examples are provided", func(t *testing.T) { - assert.Contains(t, cmd.Example, "arc workspace history") - assert.Contains(t, cmd.Example, "--limit") - assert.Contains(t, cmd.Example, "--type") - assert.Contains(t, cmd.Example, "--status") - }) + err = cmd.Args(cmd, []string{"extra-arg"}) + assert.Error(t, err) } diff --git a/pkg/cli/workspace/info.go b/pkg/cli/workspace/info.go index 4e236e1..bc0a410 100644 --- a/pkg/cli/workspace/info.go +++ b/pkg/cli/workspace/info.go @@ -1,6 +1,7 @@ package workspace import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -8,11 +9,9 @@ import ( "github.com/spf13/afero" "github.com/spf13/cobra" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/views" + newengine "github.com/arc-framework/arc-cli/pkg/ui/engine" + uithemeldr "github.com/arc-framework/arc-cli/pkg/ui/theme" + uiview "github.com/arc-framework/arc-cli/pkg/ui/view" "github.com/arc-framework/arc-cli/pkg/workspace" "github.com/arc-framework/arc-cli/pkg/workspace/store/local" ) @@ -20,6 +19,7 @@ import ( // infoFlags holds flags for the info command type infoFlags struct { noColor bool + json bool } // NewInfoCmd creates the workspace info command @@ -43,7 +43,10 @@ Use 'arc workspace history' to see the complete operation history.`, arc workspace info # Show info without colors (for piping/scripts) - arc workspace info --no-color`, + arc workspace info --no-color + + # Output as JSON + arc workspace info --json`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return runInfo(flags) @@ -51,73 +54,78 @@ Use 'arc workspace history' to see the complete operation history.`, } cmd.Flags().BoolVar(&flags.noColor, "no-color", false, "Disable colored output") + cmd.Flags().BoolVar(&flags.json, "json", false, "Output as JSON") return cmd } func runInfo(flags *infoFlags) error { - if os.Getenv("ARC_USE_LEGACY_UI") == "" { - if err := renderInfoWithNewUI(); err == nil { - return nil - } + // JSON mode: output structured data without TUI. + if flags.json { + return outputWorkspaceInfoJSON() + } + + // New UI: focused WorkspaceInfo view. + if err := renderInfoWithNewUI(); err == nil { + return nil } return legacyRunInfo(flags) } -// renderInfoWithNewUI renders workspace info using the new WorkspaceInfoView (T304). +// renderInfoWithNewUI renders workspace info using the new engine in focused mode (T060). func renderInfoWithNewUI() error { + loader, err := uithemeldr.NewLoader() + if err != nil { + return fmt.Errorf("theme loader: %w", err) + } + cfg := newengine.Config{ + Mode: newengine.ModeFocused, + Views: []newengine.View{uiview.NewWorkspaceInfo()}, + Title: "Workspace", + Loader: loader, + } + return newengine.Start(cfg) +} + +// outputWorkspaceInfoJSON serializes workspace info as structured JSON (T061). +func outputWorkspaceInfoJSON() error { fs := afero.NewOsFs() detector := workspace.NewDetector(fs) - workspaceRoot, err := detector.DetectRoot(".") + wsRoot, err := detector.DetectRoot(".") if err != nil { - return err + return fmt.Errorf("not in an A.R.C. workspace: %w", err) } - absPath := workspaceRoot - stateDir := filepath.Join(absPath, ".arc", "state") + stateDir := filepath.Join(wsRoot, ".arc", "state") stateRepo := local.NewStateRepository(fs, stateDir) manifestRepo := local.NewManifestRepository(fs) - manager, mgrErr := workspace.NewManager(&workspace.ManagerOptions{ + mgr, mgrErr := workspace.NewManager(&workspace.ManagerOptions{ Filesystem: fs, StateRepo: stateRepo, ManifestRepo: manifestRepo, }) if mgrErr != nil { - return fmt.Errorf("failed to create workspace manager: %w", mgrErr) + return fmt.Errorf("workspace manager: %w", mgrErr) } - info, infoErr := manager.Info(absPath) + info, infoErr := mgr.Info(wsRoot) if infoErr != nil { - return fmt.Errorf("failed to get workspace info: %w", infoErr) + return fmt.Errorf("workspace info: %w", infoErr) } - workspaceMap := map[string]any{ - "name": filepath.Base(absPath), - "status": "active", - "path": absPath, - "manifest": info.ManifestPath, - "tier": info.Tier, - "version": info.ManifestVersion, - "services": info.EnabledFeatures, + out := map[string]any{ + "workspace_root": info.WorkspaceRoot, + "manifest_path": info.ManifestPath, + "manifest_version": info.ManifestVersion, + "tier": info.Tier, + "enabled_features": info.EnabledFeatures, + "operation_history": info.OperationHistory, } - profileCtx := profiles.GetDefaultProfileContext() - factory := ui.NewComponentFactory(profileCtx, components.BorderTierClassic) - view := views.NewWorkspaceInfoView(factory) - - viewCtx := engine.NewViewContext( - profileCtx.Profile(), - profileCtx.Theme(), - 80, 40, - map[string]any{"workspace": workspaceMap}, - ) - _ = view.OnEnter(viewCtx) - - return engine.Render(engine.RenderConfig{ - View: view, - Mode: engine.TUIMode, - }) + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(out) } func legacyRunInfo(flags *infoFlags) error { diff --git a/pkg/cli/workspace/info_test.go b/pkg/cli/workspace/info_test.go index 19ab004..9241954 100644 --- a/pkg/cli/workspace/info_test.go +++ b/pkg/cli/workspace/info_test.go @@ -38,15 +38,6 @@ func TestNewInfoCmd(t *testing.T) { }) } -func TestInfoFlags(t *testing.T) { - t.Parallel() - - t.Run("default values", func(t *testing.T) { - flags := &infoFlags{} - assert.False(t, flags.noColor) - }) -} - func TestNewInfoCmd_FlagParsing(t *testing.T) { t.Parallel() diff --git a/pkg/cli/workspace/init.go b/pkg/cli/workspace/init.go index 6509dab..173c5cd 100644 --- a/pkg/cli/workspace/init.go +++ b/pkg/cli/workspace/init.go @@ -8,11 +8,9 @@ import ( "github.com/spf13/afero" "github.com/spf13/cobra" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/views" + newengine "github.com/arc-framework/arc-cli/pkg/ui/engine" + uithemeldr "github.com/arc-framework/arc-cli/pkg/ui/theme" + uiview "github.com/arc-framework/arc-cli/pkg/ui/view" "github.com/arc-framework/arc-cli/pkg/workspace" "github.com/arc-framework/arc-cli/pkg/workspace/store/local" ) @@ -62,32 +60,26 @@ observability) and customize service settings.`, } func runInit(flags *initFlags, args []string) error { - if os.Getenv("ARC_USE_LEGACY_UI") == "" { - if err := renderInitWithNewUI(); err == nil { - return nil - } + if err := renderInitWithNewUI(); err == nil { + return nil } return legacyRunInit(flags, args) } -// renderInitWithNewUI renders the workspace init wizard using WorkspaceInitWizardView (T328). +// renderInitWithNewUI renders the workspace init wizard using InitWizard view (T066). func renderInitWithNewUI() error { - profileCtx := profiles.GetDefaultProfileContext() - factory := ui.NewComponentFactory(profileCtx, components.BorderTierClassic) - view := views.NewWorkspaceInitWizardView(factory) - - viewCtx := engine.NewViewContext( - profileCtx.Profile(), - profileCtx.Theme(), - 80, 40, - nil, - ) - _ = view.OnEnter(viewCtx) - - return engine.Render(engine.RenderConfig{ - View: view, - Mode: engine.TUIMode, - }) + loader, err := uithemeldr.NewLoader() + if err != nil { + return err + } + cfg := newengine.Config{ + Mode: newengine.ModeFocused, + Views: []newengine.View{uiview.NewInitWizard()}, + Title: "Workspace Init", + Subtitle: "Initialize a new A.R.C. workspace", + Loader: loader, + } + return newengine.Start(cfg) } func legacyRunInit(flags *initFlags, args []string) error { diff --git a/pkg/cli/workspace/init_test.go b/pkg/cli/workspace/init_test.go index 3b55ebc..2590769 100644 --- a/pkg/cli/workspace/init_test.go +++ b/pkg/cli/workspace/init_test.go @@ -48,16 +48,6 @@ func TestNewInitCmd(t *testing.T) { }) } -func TestInitFlags(t *testing.T) { - t.Parallel() - - t.Run("default values", func(t *testing.T) { - flags := &initFlags{} - assert.False(t, flags.force) - assert.False(t, flags.skipGitignore) - }) -} - func TestNewInitCmd_FlagParsing(t *testing.T) { t.Parallel() diff --git a/pkg/cli/workspace/run.go b/pkg/cli/workspace/run.go index 23a4611..71fb9be 100644 --- a/pkg/cli/workspace/run.go +++ b/pkg/cli/workspace/run.go @@ -11,11 +11,9 @@ import ( "github.com/spf13/cobra" "github.com/arc-framework/arc-cli/internal/state" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/views" + newengine "github.com/arc-framework/arc-cli/pkg/ui/engine" + uithemeldr "github.com/arc-framework/arc-cli/pkg/ui/theme" + uiview "github.com/arc-framework/arc-cli/pkg/ui/view" "github.com/arc-framework/arc-cli/pkg/workspace" "github.com/arc-framework/arc-cli/pkg/workspace/store" "github.com/arc-framework/arc-cli/pkg/workspace/store/local" @@ -77,7 +75,7 @@ ensuring your arc.yaml is always the single source of truth.`, } func runRun(flags *runFlags) error { - if os.Getenv("ARC_USE_LEGACY_UI") == "" { + if os.Getenv("ARC_NO_TUI") == "" { if err := renderRunWithNewUI(flags); err == nil { return nil } @@ -85,36 +83,24 @@ func runRun(flags *runFlags) error { return legacyRunRun(flags) } -// renderRunWithNewUI renders a workspace run progress display using WorkspaceRunView (T320). -func renderRunWithNewUI(_ *runFlags) error { - workspaceRoot, _, _, err := setupWorkspace() +// renderRunWithNewUI renders a workspace run view using WorkspaceRun (T066). +func renderRunWithNewUI(flags *runFlags) error { + loader, err := uithemeldr.NewLoader() if err != nil { return err } - - profileCtx := profiles.GetDefaultProfileContext() - factory := ui.NewComponentFactory(profileCtx, components.BorderTierClassic) - view := views.NewWorkspaceRunView(factory) - - workspaceName := filepath.Base(workspaceRoot) - - viewCtx := engine.NewViewContext( - profileCtx.Profile(), - profileCtx.Theme(), - 80, 40, - map[string]any{ - "workspaceName": workspaceName, - "logs": []string{}, - "progress": 0.0, - "status": "running", - }, - ) - _ = view.OnEnter(viewCtx) - - return engine.Render(engine.RenderConfig{ - View: view, - Mode: engine.TUIMode, - }) + v := uiview.NewWorkspaceRun() + v.Detached = flags.detached + v.GenerateOnly = flags.generateOnly + v.NoValidate = flags.noValidate + cfg := newengine.Config{ + Mode: newengine.ModeFocused, + Views: []newengine.View{v}, + Title: "Workspace Run", + Subtitle: "Platform orchestration", + Loader: loader, + } + return newengine.Start(cfg) } func legacyRunRun(flags *runFlags) error { diff --git a/pkg/cli/workspace/run_test.go b/pkg/cli/workspace/run_test.go index 53e1b3c..2ef9d42 100644 --- a/pkg/cli/workspace/run_test.go +++ b/pkg/cli/workspace/run_test.go @@ -45,17 +45,6 @@ func TestNewRunCmd(t *testing.T) { }) } -func TestRunFlags(t *testing.T) { - t.Parallel() - - t.Run("default values", func(t *testing.T) { - flags := &runFlags{} - assert.False(t, flags.detached) - assert.False(t, flags.generateOnly) - assert.False(t, flags.noValidate) - }) -} - func TestNewRunCmd_FlagParsing(t *testing.T) { t.Parallel() diff --git a/pkg/ui/animations/color.go b/pkg/ui/animations/color.go deleted file mode 100644 index 0cae106..0000000 --- a/pkg/ui/animations/color.go +++ /dev/null @@ -1,104 +0,0 @@ -package animations - -import ( - "fmt" - "strconv" - "strings" -) - -// Color represents an RGB color. -type Color struct { - R uint8 - G uint8 - B uint8 -} - -// ColorTransition represents a transition between two colors. -type ColorTransition struct { - From Color - To Color - Progress float64 -} - -// HexToColor converts a hex color string to a Color. -// Supports formats: #RGB, #RRGGBB -// Returns a default color (black) if parsing fails. -func HexToColor(hex string) Color { - // Remove # prefix if present - hex = strings.TrimPrefix(hex, "#") - - // Handle 3-character hex (e.g., #FFF) - if len(hex) == 3 { - r, _ := strconv.ParseUint(string(hex[0])+string(hex[0]), 16, 8) - g, _ := strconv.ParseUint(string(hex[1])+string(hex[1]), 16, 8) - b, _ := strconv.ParseUint(string(hex[2])+string(hex[2]), 16, 8) - return Color{R: uint8(r), G: uint8(g), B: uint8(b)} - } - - // Handle 6-character hex (e.g., #FFFFFF) - if len(hex) == 6 { - r, _ := strconv.ParseUint(hex[0:2], 16, 8) - g, _ := strconv.ParseUint(hex[2:4], 16, 8) - b, _ := strconv.ParseUint(hex[4:6], 16, 8) - return Color{R: uint8(r), G: uint8(g), B: uint8(b)} - } - - // Invalid format, return black - return Color{R: 0, G: 0, B: 0} -} - -// ToHex converts a Color to a hex string (#RRGGBB). -func (c Color) ToHex() string { - return fmt.Sprintf("#%02X%02X%02X", c.R, c.G, c.B) -} - -// Interpolate returns a color that is t% between from and to. -// t should be in the range [0.0, 1.0] where: -// - 0.0 returns from -// - 0.5 returns the midpoint -// - 1.0 returns to -// -// Values outside [0.0, 1.0] are clamped. -func Interpolate(from, to Color, t float64) Color { - // Clamp t to [0, 1] - if t < 0 { - t = 0 - } - if t > 1 { - t = 1 - } - - return Color{ - R: interpolateChannel(from.R, to.R, t), - G: interpolateChannel(from.G, to.G, t), - B: interpolateChannel(from.B, to.B, t), - } -} - -// interpolateChannel linearly interpolates a single color channel. -func interpolateChannel(from, to uint8, t float64) uint8 { - diff := float64(to) - float64(from) - value := float64(from) + (diff * t) - return uint8(value) -} - -// NewColorTransition creates a new color transition from hex strings. -func NewColorTransition(fromHex, toHex string) *ColorTransition { - return &ColorTransition{ - From: HexToColor(fromHex), - To: HexToColor(toHex), - Progress: 0.0, - } -} - -// At returns the interpolated color at the given progress. -// progress should be in the range [0.0, 1.0]. -func (ct *ColorTransition) At(progress float64) Color { - ct.Progress = progress - return Interpolate(ct.From, ct.To, progress) -} - -// AtHex returns the interpolated color at the given progress as a hex string. -func (ct *ColorTransition) AtHex(progress float64) string { - return ct.At(progress).ToHex() -} diff --git a/pkg/ui/animations/color_test.go b/pkg/ui/animations/color_test.go deleted file mode 100644 index 3867631..0000000 --- a/pkg/ui/animations/color_test.go +++ /dev/null @@ -1,311 +0,0 @@ -package animations - -import ( - "testing" -) - -func TestHexToColor(t *testing.T) { - tests := []struct { - name string - hex string - expected Color - }{ - { - name: "6-char hex with #", - hex: "#FF0000", - expected: Color{R: 255, G: 0, B: 0}, - }, - { - name: "6-char hex without #", - hex: "00FF00", - expected: Color{R: 0, G: 255, B: 0}, - }, - { - name: "3-char hex", - hex: "#F00", - expected: Color{R: 255, G: 0, B: 0}, - }, - { - name: "3-char hex without #", - hex: "0F0", - expected: Color{R: 0, G: 255, B: 0}, - }, - { - name: "Black", - hex: "#000000", - expected: Color{R: 0, G: 0, B: 0}, - }, - { - name: "White", - hex: "#FFFFFF", - expected: Color{R: 255, G: 255, B: 255}, - }, - { - name: "Gray", - hex: "#808080", - expected: Color{R: 128, G: 128, B: 128}, - }, - { - name: "Lowercase hex", - hex: "#ff00ff", - expected: Color{R: 255, G: 0, B: 255}, - }, - { - name: "Invalid format", - hex: "invalid", - expected: Color{R: 0, G: 0, B: 0}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := HexToColor(tt.hex) - if result != tt.expected { - t.Errorf("HexToColor(%q) = %+v, want %+v", tt.hex, result, tt.expected) - } - }) - } -} - -func TestColorToHex(t *testing.T) { - tests := []struct { - name string - color Color - expected string - }{ - { - name: "Red", - color: Color{R: 255, G: 0, B: 0}, - expected: "#FF0000", - }, - { - name: "Green", - color: Color{R: 0, G: 255, B: 0}, - expected: "#00FF00", - }, - { - name: "Blue", - color: Color{R: 0, G: 0, B: 255}, - expected: "#0000FF", - }, - { - name: "Black", - color: Color{R: 0, G: 0, B: 0}, - expected: "#000000", - }, - { - name: "White", - color: Color{R: 255, G: 255, B: 255}, - expected: "#FFFFFF", - }, - { - name: "Gray", - color: Color{R: 128, G: 128, B: 128}, - expected: "#808080", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.color.ToHex() - if result != tt.expected { - t.Errorf("Color.ToHex() = %q, want %q", result, tt.expected) - } - }) - } -} - -func TestHexToColorRoundTrip(t *testing.T) { - hexColors := []string{ - "#FF0000", - "#00FF00", - "#0000FF", - "#FFFFFF", - "#000000", - "#123456", - "#ABCDEF", - } - - for _, hex := range hexColors { - color := HexToColor(hex) - result := color.ToHex() - if result != hex { - t.Errorf("Round trip failed: %q -> %+v -> %q", hex, color, result) - } - } -} - -func TestInterpolate(t *testing.T) { - tests := []struct { - name string - from Color - to Color - t float64 - expected Color - }{ - { - name: "Start of transition (t=0)", - from: Color{R: 255, G: 0, B: 0}, - to: Color{R: 0, G: 255, B: 0}, - t: 0.0, - expected: Color{R: 255, G: 0, B: 0}, - }, - { - name: "End of transition (t=1)", - from: Color{R: 255, G: 0, B: 0}, - to: Color{R: 0, G: 255, B: 0}, - t: 1.0, - expected: Color{R: 0, G: 255, B: 0}, - }, - { - name: "Middle of transition (t=0.5)", - from: Color{R: 255, G: 0, B: 0}, - to: Color{R: 0, G: 255, B: 0}, - t: 0.5, - expected: Color{R: 127, G: 127, B: 0}, - }, - { - name: "Quarter transition (t=0.25)", - from: Color{R: 0, G: 0, B: 0}, - to: Color{R: 100, G: 100, B: 100}, - t: 0.25, - expected: Color{R: 25, G: 25, B: 25}, - }, - { - name: "Same color", - from: Color{R: 128, G: 128, B: 128}, - to: Color{R: 128, G: 128, B: 128}, - t: 0.5, - expected: Color{R: 128, G: 128, B: 128}, - }, - { - name: "Clamp negative t", - from: Color{R: 255, G: 0, B: 0}, - to: Color{R: 0, G: 255, B: 0}, - t: -0.5, - expected: Color{R: 255, G: 0, B: 0}, - }, - { - name: "Clamp t > 1", - from: Color{R: 255, G: 0, B: 0}, - to: Color{R: 0, G: 255, B: 0}, - t: 1.5, - expected: Color{R: 0, G: 255, B: 0}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := Interpolate(tt.from, tt.to, tt.t) - if result != tt.expected { - t.Errorf("Interpolate(%+v, %+v, %f) = %+v, want %+v", - tt.from, tt.to, tt.t, result, tt.expected) - } - }) - } -} - -func TestNewColorTransition(t *testing.T) { - ct := NewColorTransition("#FF0000", "#00FF00") - - if ct == nil { - t.Fatal("NewColorTransition returned nil") - } - - expectedFrom := Color{R: 255, G: 0, B: 0} - expectedTo := Color{R: 0, G: 255, B: 0} - - if ct.From != expectedFrom { - t.Errorf("From = %+v, want %+v", ct.From, expectedFrom) - } - - if ct.To != expectedTo { - t.Errorf("To = %+v, want %+v", ct.To, expectedTo) - } - - if ct.Progress != 0.0 { - t.Errorf("Progress = %f, want 0.0", ct.Progress) - } -} - -func TestColorTransition_At(t *testing.T) { - ct := NewColorTransition("#FF0000", "#00FF00") - - // Test at 0% - color := ct.At(0.0) - if color != ct.From { - t.Errorf("At(0.0) = %+v, want %+v", color, ct.From) - } - - // Test at 50% - color = ct.At(0.5) - expected := Color{R: 127, G: 127, B: 0} - if color != expected { - t.Errorf("At(0.5) = %+v, want %+v", color, expected) - } - - // Test at 100% - color = ct.At(1.0) - if color != ct.To { - t.Errorf("At(1.0) = %+v, want %+v", color, ct.To) - } - - // Verify progress was updated - if ct.Progress != 1.0 { - t.Errorf("Progress = %f, want 1.0", ct.Progress) - } -} - -func TestColorTransition_AtHex(t *testing.T) { - ct := NewColorTransition("#FF0000", "#00FF00") - - tests := []struct { - progress float64 - expected string - }{ - {0.0, "#FF0000"}, - {0.5, "#7F7F00"}, - {1.0, "#00FF00"}, - } - - for _, tt := range tests { - result := ct.AtHex(tt.progress) - if result != tt.expected { - t.Errorf("AtHex(%f) = %q, want %q", tt.progress, result, tt.expected) - } - } -} - -func BenchmarkHexToColor(b *testing.B) { - hex := "#FF00FF" - for i := 0; i < b.N; i++ { - _ = HexToColor(hex) - } -} - -func BenchmarkColorToHex(b *testing.B) { - color := Color{R: 255, G: 128, B: 64} - for i := 0; i < b.N; i++ { - _ = color.ToHex() - } -} - -func BenchmarkInterpolate(b *testing.B) { - from := Color{R: 255, G: 0, B: 0} - to := Color{R: 0, G: 255, B: 0} - for i := 0; i < b.N; i++ { - _ = Interpolate(from, to, 0.5) - } -} - -// BenchmarkColorTransitionAt benchmarks color transition calculation -func BenchmarkColorTransitionAt(b *testing.B) { - from := HexToColor("#FF0000") - to := HexToColor("#00FF00") - transition := ColorTransition{From: from, To: to} - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = transition.At(0.5) - } -} diff --git a/pkg/ui/animations/config.go b/pkg/ui/animations/config.go deleted file mode 100644 index 2202af6..0000000 --- a/pkg/ui/animations/config.go +++ /dev/null @@ -1,157 +0,0 @@ -package animations - -import ( - "os" - "path/filepath" - - "gopkg.in/yaml.v3" -) - -// AnimationConfig represents the animation configuration settings. -type AnimationConfig struct { - // Enabled controls whether animations are active globally - Enabled bool `yaml:"enabled"` - - // TargetFPS is the desired frame rate (1-120) - TargetFPS int `yaml:"target_fps"` - - // Spring contains physics parameters for smooth animations - Spring SpringConfig `yaml:"spring"` - - // Duration contains timing constraints - Duration DurationConfig `yaml:"duration"` - - // AdaptiveFramerate enables FPS reduction on slow terminals - AdaptiveFramerate bool `yaml:"adaptive_framerate"` -} - -// SpringConfig defines spring physics parameters. -type SpringConfig struct { - // Damping controls bounce/settle behavior (0.1-2.0) - // 1.0 = critically damped (no overshoot, smooth) - // <1.0 = under-damped (bouncy, overshoots) - // >1.0 = over-damped (sluggish) - Damping float64 `yaml:"damping"` - - // Stiffness controls animation speed (1.0-30.0) - // Higher = faster animations - Stiffness float64 `yaml:"stiffness"` -} - -// DurationConfig defines timing limits. -type DurationConfig struct { - // Max is the maximum animation duration in milliseconds - Max int `yaml:"max"` - - // Min is the minimum operation duration to trigger animation (ms) - // Operations faster than this skip animation - Min int `yaml:"min"` -} - -// LoadConfig loads the animation configuration from available sources. -// It checks (in order): -// 1. User config at ~/.arc/config/animation.yaml -// 2. Project config at ./configs/animation.yaml -// 3. Embedded defaults -// -// Returns a validated AnimationConfig with sensible defaults. -func LoadConfig() AnimationConfig { - // Try user config first - if homeDir, err := os.UserHomeDir(); err == nil { - userConfigPath := filepath.Join(homeDir, ".arc", "config", "animation.yaml") - if cfg, loadErr := loadConfigFile(userConfigPath); loadErr == nil { - return validateConfig(cfg) - } - } - - // Try project config - projectConfigPath := "configs/animation.yaml" - if cfg, loadErr := loadConfigFile(projectConfigPath); loadErr == nil { - return validateConfig(cfg) - } - - // Fall back to embedded defaults - return defaultConfig() -} - -// loadConfigFile reads and parses a YAML config file. -func loadConfigFile(path string) (AnimationConfig, error) { - data, err := os.ReadFile(path) - if err != nil { - return AnimationConfig{}, err - } - - var config AnimationConfig - if unmarshalErr := yaml.Unmarshal(data, &config); unmarshalErr != nil { - return AnimationConfig{}, unmarshalErr - } - - return config, nil -} - -// defaultConfig returns the default animation configuration. -func defaultConfig() AnimationConfig { - return AnimationConfig{ - Enabled: true, - TargetFPS: 60, - Spring: SpringConfig{ - Damping: 1.0, - Stiffness: 10.0, - }, - Duration: DurationConfig{ - Max: 300, - Min: 200, - }, - AdaptiveFramerate: true, - } -} - -// validateConfig ensures config values are within acceptable ranges. -func validateConfig(config AnimationConfig) AnimationConfig { - // Validate FPS (1-120) - if config.TargetFPS < 1 { - config.TargetFPS = 1 - } - if config.TargetFPS > 120 { - config.TargetFPS = 120 - } - - // Validate damping (0.1-2.0) - if config.Spring.Damping < 0.1 { - config.Spring.Damping = 0.1 - } - if config.Spring.Damping > 2.0 { - config.Spring.Damping = 2.0 - } - - // Validate stiffness (1.0-30.0) - if config.Spring.Stiffness < 1.0 { - config.Spring.Stiffness = 1.0 - } - if config.Spring.Stiffness > 30.0 { - config.Spring.Stiffness = 30.0 - } - - // Validate duration max (50-1000ms) - if config.Duration.Max < 50 { - config.Duration.Max = 50 - } - if config.Duration.Max > 1000 { - config.Duration.Max = 1000 - } - - // Validate duration min (0-500ms) - if config.Duration.Min < 0 { - config.Duration.Min = 0 - } - if config.Duration.Min > 500 { - config.Duration.Min = 500 - } - - // Ensure min <= max - if config.Duration.Min > config.Duration.Max { - config.Duration.Min = config.Duration.Max - } - - return config -} diff --git a/pkg/ui/animations/config_test.go b/pkg/ui/animations/config_test.go deleted file mode 100644 index f830392..0000000 --- a/pkg/ui/animations/config_test.go +++ /dev/null @@ -1,238 +0,0 @@ -package animations - -import ( - "os" - "path/filepath" - "testing" -) - -func TestLoadConfig(t *testing.T) { - // Test loading default config - config := LoadConfig() - - if !config.Enabled { - t.Error("Default config should have animations enabled") - } - - if config.TargetFPS != 60 { - t.Errorf("Default TargetFPS = %d, want 60", config.TargetFPS) - } - - if config.Spring.Damping != 1.0 { - t.Errorf("Default Damping = %f, want 1.0", config.Spring.Damping) - } - - if config.Spring.Stiffness != 10.0 { - t.Errorf("Default Stiffness = %f, want 10.0", config.Spring.Stiffness) - } - - if config.Duration.Max != 300 { - t.Errorf("Default Duration.Max = %d, want 300", config.Duration.Max) - } - - if config.Duration.Min != 200 { - t.Errorf("Default Duration.Min = %d, want 200", config.Duration.Min) - } - - if !config.AdaptiveFramerate { - t.Error("Default config should have adaptive framerate enabled") - } -} - -func TestDefaultConfig(t *testing.T) { - config := defaultConfig() - - if config.TargetFPS <= 0 || config.TargetFPS > 120 { - t.Errorf("Invalid default TargetFPS: %d", config.TargetFPS) - } - - if config.Spring.Damping < 0.1 || config.Spring.Damping > 2.0 { - t.Errorf("Invalid default Damping: %f", config.Spring.Damping) - } - - if config.Spring.Stiffness < 1.0 || config.Spring.Stiffness > 30.0 { - t.Errorf("Invalid default Stiffness: %f", config.Spring.Stiffness) - } -} - -func TestValidateConfig(t *testing.T) { - tests := []struct { - name string - input AnimationConfig - validate func(AnimationConfig) bool - errMsg string - }{ - { - name: "FPS too low", - input: AnimationConfig{ - TargetFPS: -10, - Spring: SpringConfig{Damping: 1.0, Stiffness: 10.0}, - Duration: DurationConfig{Max: 300, Min: 200}, - }, - validate: func(c AnimationConfig) bool { return c.TargetFPS >= 1 }, - errMsg: "FPS should be clamped to minimum 1", - }, - { - name: "FPS too high", - input: AnimationConfig{ - TargetFPS: 200, - Spring: SpringConfig{Damping: 1.0, Stiffness: 10.0}, - Duration: DurationConfig{Max: 300, Min: 200}, - }, - validate: func(c AnimationConfig) bool { return c.TargetFPS <= 120 }, - errMsg: "FPS should be clamped to maximum 120", - }, - { - name: "Damping too low", - input: AnimationConfig{ - TargetFPS: 60, - Spring: SpringConfig{Damping: 0.01, Stiffness: 10.0}, - Duration: DurationConfig{Max: 300, Min: 200}, - }, - validate: func(c AnimationConfig) bool { return c.Spring.Damping >= 0.1 }, - errMsg: "Damping should be clamped to minimum 0.1", - }, - { - name: "Damping too high", - input: AnimationConfig{ - TargetFPS: 60, - Spring: SpringConfig{Damping: 5.0, Stiffness: 10.0}, - Duration: DurationConfig{Max: 300, Min: 200}, - }, - validate: func(c AnimationConfig) bool { return c.Spring.Damping <= 2.0 }, - errMsg: "Damping should be clamped to maximum 2.0", - }, - { - name: "Stiffness too low", - input: AnimationConfig{ - TargetFPS: 60, - Spring: SpringConfig{Damping: 1.0, Stiffness: 0.5}, - Duration: DurationConfig{Max: 300, Min: 200}, - }, - validate: func(c AnimationConfig) bool { return c.Spring.Stiffness >= 1.0 }, - errMsg: "Stiffness should be clamped to minimum 1.0", - }, - { - name: "Stiffness too high", - input: AnimationConfig{ - TargetFPS: 60, - Spring: SpringConfig{Damping: 1.0, Stiffness: 50.0}, - Duration: DurationConfig{Max: 300, Min: 200}, - }, - validate: func(c AnimationConfig) bool { return c.Spring.Stiffness <= 30.0 }, - errMsg: "Stiffness should be clamped to maximum 30.0", - }, - { - name: "Duration min > max", - input: AnimationConfig{ - TargetFPS: 60, - Spring: SpringConfig{Damping: 1.0, Stiffness: 10.0}, - Duration: DurationConfig{Max: 100, Min: 300}, - }, - validate: func(c AnimationConfig) bool { return c.Duration.Min <= c.Duration.Max }, - errMsg: "Duration min should not exceed max", - }, - { - name: "Valid config unchanged", - input: AnimationConfig{ - TargetFPS: 60, - Spring: SpringConfig{Damping: 1.0, Stiffness: 10.0}, - Duration: DurationConfig{Max: 300, Min: 200}, - }, - validate: func(c AnimationConfig) bool { - return c.TargetFPS == 60 && c.Spring.Damping == 1.0 && - c.Spring.Stiffness == 10.0 && c.Duration.Max == 300 && - c.Duration.Min == 200 - }, - errMsg: "Valid config should remain unchanged", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := validateConfig(tt.input) - if !tt.validate(result) { - t.Error(tt.errMsg) - } - }) - } -} - -func TestLoadConfigFile(t *testing.T) { - // Create a temporary config file - tmpDir := t.TempDir() - configPath := filepath.Join(tmpDir, "test-animation.yaml") - - configContent := `enabled: true -target_fps: 30 -spring: - damping: 0.8 - stiffness: 15.0 -duration: - max: 250 - min: 150 -adaptive_framerate: false -` - - if err := os.WriteFile(configPath, []byte(configContent), 0o644); err != nil { - t.Fatalf("Failed to create test config file: %v", err) - } - - config, err := loadConfigFile(configPath) - if err != nil { - t.Fatalf("Failed to load config file: %v", err) - } - - if config.TargetFPS != 30 { - t.Errorf("TargetFPS = %d, want 30", config.TargetFPS) - } - - if config.Spring.Damping != 0.8 { - t.Errorf("Damping = %f, want 0.8", config.Spring.Damping) - } - - if config.Spring.Stiffness != 15.0 { - t.Errorf("Stiffness = %f, want 15.0", config.Spring.Stiffness) - } - - if config.Duration.Max != 250 { - t.Errorf("Duration.Max = %d, want 250", config.Duration.Max) - } - - if config.Duration.Min != 150 { - t.Errorf("Duration.Min = %d, want 150", config.Duration.Min) - } - - if config.AdaptiveFramerate { - t.Error("AdaptiveFramerate should be false") - } -} - -func TestLoadConfigFile_Invalid(t *testing.T) { - // Test with non-existent file - _, err := loadConfigFile("/nonexistent/path/config.yaml") - if err == nil { - t.Error("Expected error for non-existent file") - } - - // Test with invalid YAML - tmpDir := t.TempDir() - invalidPath := filepath.Join(tmpDir, "invalid.yaml") - - if writeErr := os.WriteFile(invalidPath, []byte("invalid: yaml: content: ["), 0o644); writeErr != nil { - t.Fatalf("Failed to create invalid test file: %v", writeErr) - } - - _, err = loadConfigFile(invalidPath) - if err == nil { - t.Error("Expected error for invalid YAML") - } -} - -// BenchmarkLoadConfig benchmarks config loading -func BenchmarkLoadConfig(b *testing.B) { - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = LoadConfig() - } -} diff --git a/pkg/ui/animations/control_test.go b/pkg/ui/animations/control_test.go deleted file mode 100644 index 2cc2e44..0000000 --- a/pkg/ui/animations/control_test.go +++ /dev/null @@ -1,150 +0,0 @@ -package animations - -import ( - "os" - "testing" -) - -func TestShouldAnimate(t *testing.T) { - // Save original env vars and restore after tests - origNoColor := os.Getenv("NO_COLOR") - origArcNoAnim := os.Getenv("ARC_NO_ANIMATION") - origNoAnimation := NoAnimation - defer func() { - os.Setenv("NO_COLOR", origNoColor) - os.Setenv("ARC_NO_ANIMATION", origArcNoAnim) - NoAnimation = origNoAnimation - }() - - tests := []struct { - name string - setup func() - expectedResult bool - }{ - { - name: "NoAnimation flag set", - setup: func() { - os.Clearenv() - NoAnimation = true - }, - expectedResult: false, - }, - { - name: "ARC_NO_ANIMATION env var set", - setup: func() { - NoAnimation = false - os.Setenv("ARC_NO_ANIMATION", "1") - }, - expectedResult: false, - }, - { - name: "NO_COLOR env var set", - setup: func() { - NoAnimation = false - os.Unsetenv("ARC_NO_ANIMATION") - os.Setenv("NO_COLOR", "1") - }, - expectedResult: false, - }, - { - name: "All checks pass in TTY", - setup: func() { - NoAnimation = false - os.Unsetenv("ARC_NO_ANIMATION") - os.Unsetenv("NO_COLOR") - }, - expectedResult: true, // Will be true if running in TTY with sufficient width - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tt.setup() - result := ShouldAnimate() - - // For the "All checks pass" test, we can't guarantee the result - // since it depends on the actual terminal environment - if tt.name != "All checks pass in TTY" { - if result != tt.expectedResult { - t.Errorf("ShouldAnimate() = %v, want %v", result, tt.expectedResult) - } - } - }) - } -} - -func TestShouldAnimate_PriorityOrder(t *testing.T) { - origNoAnimation := NoAnimation - defer func() { - NoAnimation = origNoAnimation - os.Unsetenv("ARC_NO_ANIMATION") - os.Unsetenv("NO_COLOR") - }() - - // Test that NoAnimation flag takes precedence over everything - NoAnimation = true - os.Setenv("ARC_NO_ANIMATION", "") - os.Setenv("NO_COLOR", "") - - if ShouldAnimate() { - t.Error("NoAnimation flag should take highest precedence") - } - - // Test that ARC_NO_ANIMATION takes precedence over NO_COLOR - NoAnimation = false - os.Setenv("ARC_NO_ANIMATION", "1") - os.Setenv("NO_COLOR", "") - - if ShouldAnimate() { - t.Error("ARC_NO_ANIMATION should take precedence over NO_COLOR") - } -} - -func TestShouldAnimate_EnvironmentVariables(t *testing.T) { - origNoAnimation := NoAnimation - defer func() { - NoAnimation = origNoAnimation - os.Unsetenv("ARC_NO_ANIMATION") - }() - - NoAnimation = false - - tests := []struct { - name string - envValue string - want bool - }{ - {"Empty string disables", "", false}, - {"Any value disables", "1", false}, - {"Yes value disables", "yes", false}, - {"True value disables", "true", false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.envValue == "" { - os.Unsetenv("ARC_NO_ANIMATION") - } else { - os.Setenv("ARC_NO_ANIMATION", tt.envValue) - } - - result := ShouldAnimate() - if tt.envValue != "" && result { - t.Errorf("ARC_NO_ANIMATION=%q should disable animations", tt.envValue) - } - }) - } -} - -// BenchmarkShouldAnimate benchmarks the animation decision logic -func BenchmarkShouldAnimate(b *testing.B) { - origNoAnimation := NoAnimation - defer func() { NoAnimation = origNoAnimation }() - - NoAnimation = false - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = ShouldAnimate() - } -} diff --git a/pkg/ui/animations/control_unix.go b/pkg/ui/animations/control_unix.go deleted file mode 100644 index 3889e34..0000000 --- a/pkg/ui/animations/control_unix.go +++ /dev/null @@ -1,178 +0,0 @@ -//go:build !windows - -// Package animations provides animation control and helpers for the A.R.C. CLI. -package animations - -import ( - "os" - "os/signal" - "sync" - "syscall" - - "github.com/arc-framework/arc-cli/internal/terminal" - "github.com/arc-framework/arc-cli/pkg/ui/styles" -) - -// NoAnimation is a global flag to disable all animations. -// This is set by the --no-animation flag in the root command. -var NoAnimation bool - -// ShouldAnimate determines if animations should be enabled based on multiple factors. -// It checks (in priority order): -// 1. Global --no-animation flag -// 2. Global --no-color flag (animations require color support) -// 3. ARC_NO_ANIMATION environment variable -// 4. NO_COLOR environment variable -// 5. TTY detection -// 6. Animation config enabled setting -// 7. Terminal width (minimum 80 columns required) -// -// Returns true if animations should be displayed, false otherwise. -func ShouldAnimate() bool { - // 1. Check global flag (set in root.go) - if NoAnimation { - return false - } - - // 2. Check global --no-color flag (animations require color support) - if styles.NoColor { - return false - } - - // 3. Check ARC_NO_ANIMATION environment variable - if os.Getenv("ARC_NO_ANIMATION") != "" { - return false - } - - // 4. Detect terminal capabilities - detector := terminal.NewDetector() - caps := detector.Detect() - - // NO_COLOR implies no animations - if caps.NoColorForced { - return false - } - - // 5. Check if we're in a TTY - if !caps.IsTTY { - return false - } - - // 6. Check animation configuration - config := LoadConfig() - if !config.Enabled { - return false - } - - // 7. Check terminal width (animations need at least 80 columns) - if caps.Width < 80 { - return false - } - - return true -} - -// ResizeHandler manages terminal resize events during animations. -type ResizeHandler struct { - width int - height int - mu sync.RWMutex - subscribers []chan struct{} - stop chan struct{} - stopOnce sync.Once -} - -// NewResizeHandler creates a new resize handler that monitors terminal size changes. -func NewResizeHandler() *ResizeHandler { - detector := terminal.NewDetector() - caps := detector.Detect() - - h := &ResizeHandler{ - width: caps.Width, - height: caps.Height, - subscribers: make([]chan struct{}, 0), - stop: make(chan struct{}), - } - - // Start monitoring for SIGWINCH (terminal resize signal) - go h.monitor() - - return h -} - -// monitor listens for terminal resize signals. -func (h *ResizeHandler) monitor() { - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGWINCH) - - for { - select { - case <-sigChan: - h.handleResize() - case <-h.stop: - signal.Stop(sigChan) - return - } - } -} - -// handleResize updates terminal dimensions and notifies subscribers. -func (h *ResizeHandler) handleResize() { - detector := terminal.NewDetector() - caps := detector.Detect() - - h.mu.Lock() - oldWidth := h.width - h.width = caps.Width - h.height = caps.Height - h.mu.Unlock() - - // If terminal became too narrow, notify subscribers to stop animation - if caps.Width < 80 && oldWidth >= 80 { - h.notifySubscribers() - } -} - -// notifySubscribers sends resize notifications to all subscribers. -func (h *ResizeHandler) notifySubscribers() { - h.mu.RLock() - defer h.mu.RUnlock() - - for _, ch := range h.subscribers { - select { - case ch <- struct{}{}: - default: - // Channel full or closed, skip - } - } -} - -// Subscribe returns a channel that receives notifications on terminal resize. -func (h *ResizeHandler) Subscribe() chan struct{} { - h.mu.Lock() - defer h.mu.Unlock() - - ch := make(chan struct{}, 1) - h.subscribers = append(h.subscribers, ch) - return ch -} - -// GetSize returns the current terminal dimensions. -func (h *ResizeHandler) GetSize() (width, height int) { - h.mu.RLock() - defer h.mu.RUnlock() - return h.width, h.height -} - -// Stop stops monitoring for resize events. -func (h *ResizeHandler) Stop() { - h.stopOnce.Do(func() { - close(h.stop) - }) -} - -// ShouldContinueAnimation checks if animation should continue based on current terminal size. -func (h *ResizeHandler) ShouldContinueAnimation() bool { - width, _ := h.GetSize() - return width >= 80 -} diff --git a/pkg/ui/animations/control_windows.go b/pkg/ui/animations/control_windows.go deleted file mode 100644 index 5cffc08..0000000 --- a/pkg/ui/animations/control_windows.go +++ /dev/null @@ -1,71 +0,0 @@ -//go:build windows - -package animations - -import ( - "os" - - "github.com/arc-framework/arc-cli/internal/terminal" - "github.com/arc-framework/arc-cli/pkg/ui/styles" -) - -// NoAnimation is a global flag to disable all animations. -var NoAnimation bool - -// ShouldAnimate determines if animations should be enabled based on multiple factors. -func ShouldAnimate() bool { - // 1. Check global flag (set in root.go) - if NoAnimation { - return false - } - - // 2. Check global --no-color flag (animations require color support) - if styles.NoColor { - return false - } - - // 3. Check ARC_NO_ANIMATION environment variable - if os.Getenv("ARC_NO_ANIMATION") != "" { - return false - } - - // 4. Detect terminal capabilities - detector := terminal.NewDetector() - caps := detector.Detect() - - // NO_COLOR implies no animations - if caps.NoColorForced { - return false - } - - // 5. Check if we're in a TTY - if !caps.IsTTY { - return false - } - - // 6. Check animation configuration - config := LoadConfig() - if !config.Enabled { - return false - } - - // 7. Check terminal width (animations need at least 80 columns) - if caps.Width < 80 { - return false - } - - return true -} - -// ResizeHandler is a stub for Windows. -type ResizeHandler struct{} - -func NewResizeHandler() *ResizeHandler { return &ResizeHandler{} } - -func (h *ResizeHandler) Subscribe() chan struct{} { return make(chan struct{}) } - -func (h *ResizeHandler) GetSize() (int, int) { return 80, 24 } - -func (h *ResizeHandler) Stop() {} - -func (h *ResizeHandler) ShouldContinueAnimation() bool { return true } diff --git a/pkg/ui/animations/framerate.go b/pkg/ui/animations/framerate.go deleted file mode 100644 index b21d14f..0000000 --- a/pkg/ui/animations/framerate.go +++ /dev/null @@ -1,177 +0,0 @@ -package animations - -import ( - "sync" - "time" -) - -// FrameRateAdapter dynamically adjusts frame rate based on performance. -type FrameRateAdapter struct { - targetFPS int - currentFPS int - minFPS int - maxFPS int - frameTimesMs []float64 - maxSamples int - mu sync.RWMutex - enabled bool - lastFrameTime time.Time - consecutiveSlow int -} - -// NewFrameRateAdapter creates a new adaptive frame rate controller. -func NewFrameRateAdapter(targetFPS int) *FrameRateAdapter { - config := LoadConfig() - - adapter := &FrameRateAdapter{ - targetFPS: targetFPS, - currentFPS: targetFPS, - minFPS: 15, // Minimum acceptable FPS - maxFPS: targetFPS, - frameTimesMs: make([]float64, 0, 30), - maxSamples: 30, // Track last 30 frames - enabled: config.AdaptiveFramerate, - lastFrameTime: time.Now(), - } - - return adapter -} - -// RecordFrame records the time taken to render a frame and adjusts FPS if needed. -func (a *FrameRateAdapter) RecordFrame() { - if !a.enabled { - return - } - - now := time.Now() - a.mu.Lock() - defer a.mu.Unlock() - - if !a.lastFrameTime.IsZero() { - frameTime := now.Sub(a.lastFrameTime).Seconds() * 1000 // Convert to ms - a.frameTimesMs = append(a.frameTimesMs, frameTime) - - // Keep only last N samples - if len(a.frameTimesMs) > a.maxSamples { - a.frameTimesMs = a.frameTimesMs[1:] - } - - // Adjust FPS if we have enough samples - if len(a.frameTimesMs) >= 10 { - a.adjustFPS() - } - } - - a.lastFrameTime = now -} - -// adjustFPS dynamically adjusts the frame rate based on recent performance. -func (a *FrameRateAdapter) adjustFPS() { - avgFrameTime := a.averageFrameTime() - targetFrameTime := 1000.0 / float64(a.currentFPS) - - // If frames are taking significantly longer than target, reduce FPS - //nolint:nestif // Complex logic needed for adaptive framerate - if avgFrameTime > targetFrameTime*1.5 { - a.consecutiveSlow++ - if a.consecutiveSlow >= 3 { - // Reduce FPS - newFPS := a.currentFPS * 3 / 4 // Reduce by 25% - if newFPS < a.minFPS { - newFPS = a.minFPS - } - if newFPS != a.currentFPS { - a.currentFPS = newFPS - a.consecutiveSlow = 0 - } - } - } else if avgFrameTime < targetFrameTime*0.8 { - // Frames are rendering faster, we can increase FPS - a.consecutiveSlow = 0 - newFPS := a.currentFPS * 5 / 4 // Increase by 25% - if newFPS > a.maxFPS { - newFPS = a.maxFPS - } - if newFPS != a.currentFPS { - a.currentFPS = newFPS - } - } else { - a.consecutiveSlow = 0 - } -} - -// averageFrameTime calculates the average frame render time in milliseconds. -func (a *FrameRateAdapter) averageFrameTime() float64 { - if len(a.frameTimesMs) == 0 { - return 0 - } - - sum := 0.0 - for _, t := range a.frameTimesMs { - sum += t - } - return sum / float64(len(a.frameTimesMs)) -} - -// GetCurrentFPS returns the current adaptive frame rate. -func (a *FrameRateAdapter) GetCurrentFPS() int { - if !a.enabled { - return a.targetFPS - } - - a.mu.RLock() - defer a.mu.RUnlock() - return a.currentFPS -} - -// GetFrameDuration returns the duration to wait between frames. -func (a *FrameRateAdapter) GetFrameDuration() time.Duration { - fps := a.GetCurrentFPS() - return time.Second / time.Duration(fps) -} - -// GetStats returns performance statistics. -func (a *FrameRateAdapter) GetStats() FrameRateStats { - a.mu.RLock() - defer a.mu.RUnlock() - - stats := FrameRateStats{ - CurrentFPS: a.currentFPS, - TargetFPS: a.targetFPS, - AverageFrameTime: a.averageFrameTime(), - SampleCount: len(a.frameTimesMs), - Enabled: a.enabled, - } - - return stats -} - -// Reset resets the adapter to initial state. -func (a *FrameRateAdapter) Reset() { - a.mu.Lock() - defer a.mu.Unlock() - - a.currentFPS = a.targetFPS - a.frameTimesMs = make([]float64, 0, a.maxSamples) - a.consecutiveSlow = 0 - a.lastFrameTime = time.Now() -} - -// SetEnabled enables or disables adaptive frame rate. -func (a *FrameRateAdapter) SetEnabled(enabled bool) { - a.mu.Lock() - defer a.mu.Unlock() - a.enabled = enabled - if !enabled { - a.currentFPS = a.targetFPS - } -} - -// FrameRateStats contains performance statistics. -type FrameRateStats struct { - CurrentFPS int - TargetFPS int - AverageFrameTime float64 - SampleCount int - Enabled bool -} diff --git a/pkg/ui/animations/framerate_test.go b/pkg/ui/animations/framerate_test.go deleted file mode 100644 index 52ff24c..0000000 --- a/pkg/ui/animations/framerate_test.go +++ /dev/null @@ -1,261 +0,0 @@ -package animations - -import ( - "testing" - "time" -) - -func TestNewFrameRateAdapter(t *testing.T) { - adapter := NewFrameRateAdapter(60) - - if adapter == nil { - t.Fatal("Expected adapter to be created") - } - - if adapter.GetCurrentFPS() != 60 { - t.Errorf("Expected initial FPS to be 60, got %d", adapter.GetCurrentFPS()) - } -} - -func TestFrameRateAdapter_GetCurrentFPS(t *testing.T) { - tests := []struct { - name string - targetFPS int - want int - }{ - {"60 FPS", 60, 60}, - {"30 FPS", 30, 30}, - {"15 FPS", 15, 15}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - adapter := NewFrameRateAdapter(tt.targetFPS) - got := adapter.GetCurrentFPS() - if got != tt.want { - t.Errorf("GetCurrentFPS() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestFrameRateAdapter_GetFrameDuration(t *testing.T) { - adapter := NewFrameRateAdapter(60) - duration := adapter.GetFrameDuration() - - expected := time.Second / 60 - if duration != expected { - t.Errorf("GetFrameDuration() = %v, want %v", duration, expected) - } -} - -func TestFrameRateAdapter_RecordFrame(t *testing.T) { - adapter := NewFrameRateAdapter(60) - adapter.SetEnabled(true) - - // Record some frames - for i := 0; i < 5; i++ { - adapter.RecordFrame() - time.Sleep(10 * time.Millisecond) - } - - stats := adapter.GetStats() - if stats.SampleCount == 0 { - t.Error("Expected samples to be recorded") - } -} - -func TestFrameRateAdapter_GetStats(t *testing.T) { - adapter := NewFrameRateAdapter(60) - adapter.SetEnabled(true) - - stats := adapter.GetStats() - - if stats.TargetFPS != 60 { - t.Errorf("Expected target FPS to be 60, got %d", stats.TargetFPS) - } - - if stats.CurrentFPS != 60 { - t.Errorf("Expected current FPS to be 60, got %d", stats.CurrentFPS) - } - - if !stats.Enabled { - t.Error("Expected adapter to be enabled") - } -} - -func TestFrameRateAdapter_Reset(t *testing.T) { - adapter := NewFrameRateAdapter(60) - adapter.SetEnabled(true) - - // Record some frames - for i := 0; i < 10; i++ { - adapter.RecordFrame() - time.Sleep(5 * time.Millisecond) - } - - stats := adapter.GetStats() - if stats.SampleCount == 0 { - t.Error("Expected samples before reset") - } - - // Reset - adapter.Reset() - - stats = adapter.GetStats() - if stats.SampleCount != 0 { - t.Errorf("Expected 0 samples after reset, got %d", stats.SampleCount) - } - - if stats.CurrentFPS != 60 { - t.Errorf("Expected FPS to be reset to 60, got %d", stats.CurrentFPS) - } -} - -func TestFrameRateAdapter_SetEnabled(t *testing.T) { - adapter := NewFrameRateAdapter(60) - - // Initially should match config - initialStats := adapter.GetStats() - - // Disable - adapter.SetEnabled(false) - if adapter.GetStats().Enabled { - t.Error("Expected adapter to be disabled") - } - - // Enable - adapter.SetEnabled(true) - if !adapter.GetStats().Enabled { - t.Error("Expected adapter to be enabled") - } - - // When disabled, should return target FPS - adapter.SetEnabled(false) - if adapter.GetCurrentFPS() != initialStats.TargetFPS { - t.Errorf("Expected FPS to be target FPS when disabled") - } -} - -func TestFrameRateAdapter_AdaptiveReduction(t *testing.T) { - adapter := NewFrameRateAdapter(60) - adapter.SetEnabled(true) - - initialFPS := adapter.GetCurrentFPS() - - // Simulate slow frames (longer than target) - for i := 0; i < 50; i++ { - adapter.RecordFrame() - time.Sleep(30 * time.Millisecond) // Much slower than 60 FPS (16.6ms) - } - - newFPS := adapter.GetCurrentFPS() - - // FPS should have been reduced (or stay at minimum) - if newFPS > initialFPS { - t.Errorf("Expected FPS to decrease or stay same, got %d -> %d", initialFPS, newFPS) - } -} - -func TestFrameRateAdapter_DisabledNoAdaptation(t *testing.T) { - adapter := NewFrameRateAdapter(60) - adapter.SetEnabled(false) - - initialFPS := adapter.GetCurrentFPS() - - // Simulate slow frames - for i := 0; i < 50; i++ { - adapter.RecordFrame() - time.Sleep(30 * time.Millisecond) - } - - newFPS := adapter.GetCurrentFPS() - - // FPS should not change when disabled - if newFPS != initialFPS { - t.Errorf("Expected FPS to remain %d when disabled, got %d", initialFPS, newFPS) - } -} - -func TestFrameRateAdapter_ConcurrentAccess(t *testing.T) { - adapter := NewFrameRateAdapter(60) - adapter.SetEnabled(true) - - done := make(chan bool) - - // Multiple goroutines recording frames - for i := 0; i < 5; i++ { - go func() { - for j := 0; j < 20; j++ { - adapter.RecordFrame() - adapter.GetCurrentFPS() - adapter.GetStats() - time.Sleep(time.Millisecond) - } - done <- true - }() - } - - // Wait for all goroutines - for i := 0; i < 5; i++ { - select { - case <-done: - case <-time.After(5 * time.Second): - t.Fatal("Timeout waiting for concurrent operations") - } - } -} - -func TestFrameRateAdapter_MinMaxBounds(t *testing.T) { - adapter := NewFrameRateAdapter(60) - adapter.SetEnabled(true) - - // The adapter should never go below minFPS (15) or above maxFPS (60) - // This is hard to test directly, but we can verify the bounds are set - stats := adapter.GetStats() - - if stats.CurrentFPS < 15 { - t.Errorf("Current FPS %d is below minimum (15)", stats.CurrentFPS) - } - - if stats.CurrentFPS > 60 { - t.Errorf("Current FPS %d is above maximum (60)", stats.CurrentFPS) - } -} - -// BenchmarkFrameRateAdapter_RecordFrame benchmarks recording a frame -func BenchmarkFrameRateAdapter_RecordFrame(b *testing.B) { - adapter := NewFrameRateAdapter(60) - adapter.SetEnabled(true) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - adapter.RecordFrame() - } -} - -// BenchmarkFrameRateAdapter_GetCurrentFPS benchmarks getting current FPS -func BenchmarkFrameRateAdapter_GetCurrentFPS(b *testing.B) { - adapter := NewFrameRateAdapter(60) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - adapter.GetCurrentFPS() - } -} - -// BenchmarkFrameRateAdapter_GetStats benchmarks getting statistics -func BenchmarkFrameRateAdapter_GetStats(b *testing.B) { - adapter := NewFrameRateAdapter(60) - adapter.SetEnabled(true) - - // Record some frames first - for i := 0; i < 30; i++ { - adapter.RecordFrame() - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - adapter.GetStats() - } -} diff --git a/pkg/ui/animations/lerp.go b/pkg/ui/animations/lerp.go deleted file mode 100644 index 5483552..0000000 --- a/pkg/ui/animations/lerp.go +++ /dev/null @@ -1,43 +0,0 @@ -// Package animations provides animation utilities for the A.R.C. CLI. -package animations - -import "math" - -// Lerp performs linear interpolation between start and end values. -// Parameter t should be in the range [0, 1] where: -// - t = 0 returns start -// - t = 1 returns end -// - 0 < t < 1 returns a value between start and end -func Lerp(start, end, t float64) float64 { - // Clamp t to [0, 1] - t = clamp(t, 0, 1) - return start + (end-start)*t -} - -// EaseInOut applies a smooth ease-in-out curve to t using a cubic function. -// This produces smooth acceleration at the start and deceleration at the end. -func EaseInOut(t float64) float64 { - t = clamp(t, 0, 1) - if t < 0.5 { - return 4 * t * t * t - } - return 1 - math.Pow(-2*t+2, 3)/2 -} - -// EaseOut applies a smooth ease-out curve to t using a cubic function. -// This produces natural deceleration, like a spring settling. -func EaseOut(t float64) float64 { - t = clamp(t, 0, 1) - return 1 - math.Pow(1-t, 3) -} - -// clamp restricts a value to the range [minVal, maxVal] -func clamp(val, minVal, maxVal float64) float64 { - if val < minVal { - return minVal - } - if val > maxVal { - return maxVal - } - return val -} diff --git a/pkg/ui/animations/lerp_test.go b/pkg/ui/animations/lerp_test.go deleted file mode 100644 index e23ab13..0000000 --- a/pkg/ui/animations/lerp_test.go +++ /dev/null @@ -1,285 +0,0 @@ -package animations - -import ( - "math" - "testing" -) - -func TestLerp(t *testing.T) { - // Note: Not using t.Parallel() - - tests := []struct { - name string - start float64 - end float64 - t float64 - expected float64 - }{ - { - name: "t=0 returns start", - start: 0.0, - end: 100.0, - t: 0.0, - expected: 0.0, - }, - { - name: "t=1 returns end", - start: 0.0, - end: 100.0, - t: 1.0, - expected: 100.0, - }, - { - name: "t=0.5 returns midpoint", - start: 0.0, - end: 100.0, - t: 0.5, - expected: 50.0, - }, - { - name: "t=0.25 returns quarter", - start: 0.0, - end: 100.0, - t: 0.25, - expected: 25.0, - }, - { - name: "negative start and end", - start: -50.0, - end: 50.0, - t: 0.5, - expected: 0.0, - }, - { - name: "reverse interpolation", - start: 100.0, - end: 0.0, - t: 0.5, - expected: 50.0, - }, - { - name: "clamps t below 0", - start: 0.0, - end: 100.0, - t: -0.5, - expected: 0.0, - }, - { - name: "clamps t above 1", - start: 0.0, - end: 100.0, - t: 1.5, - expected: 100.0, - }, - { - name: "small values", - start: 0.0, - end: 1.0, - t: 0.333, - expected: 0.333, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Note: Not using t.Parallel() - result := Lerp(tt.start, tt.end, tt.t) - if math.Abs(result-tt.expected) > 0.0001 { - t.Errorf("Lerp(%v, %v, %v) = %v, want %v", - tt.start, tt.end, tt.t, result, tt.expected) - } - }) - } -} - -func TestEaseInOut(t *testing.T) { - // Note: Not using t.Parallel() - - tests := []struct { - name string - t float64 - expected float64 - epsilon float64 // tolerance for floating-point comparison - }{ - { - name: "t=0 returns 0", - t: 0.0, - expected: 0.0, - epsilon: 0.0001, - }, - { - name: "t=1 returns 1", - t: 1.0, - expected: 1.0, - epsilon: 0.0001, - }, - { - name: "t=0.5 returns 0.5", - t: 0.5, - expected: 0.5, - epsilon: 0.0001, - }, - { - name: "t=0.25 eases in (slower than linear)", - t: 0.25, - expected: 0.0625, // 4 * 0.25^3 - epsilon: 0.0001, - }, - { - name: "t=0.75 eases out (slower than linear)", - t: 0.75, - expected: 0.9375, // 1 - ((-2*0.75+2)^3)/2 - epsilon: 0.0001, - }, - { - name: "clamps t below 0", - t: -0.5, - expected: 0.0, - epsilon: 0.0001, - }, - { - name: "clamps t above 1", - t: 1.5, - expected: 1.0, - epsilon: 0.0001, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Note: Not using t.Parallel() - result := EaseInOut(tt.t) - if math.Abs(result-tt.expected) > tt.epsilon { - t.Errorf("EaseInOut(%v) = %v, want %v (±%v)", - tt.t, result, tt.expected, tt.epsilon) - } - }) - } -} - -func TestEaseOut(t *testing.T) { - // Note: Not using t.Parallel() - - tests := []struct { - name string - t float64 - expected float64 - epsilon float64 - }{ - { - name: "t=0 returns 0", - t: 0.0, - expected: 0.0, - epsilon: 0.0001, - }, - { - name: "t=1 returns 1", - t: 1.0, - expected: 1.0, - epsilon: 0.0001, - }, - { - name: "t=0.5 returns cubic ease", - t: 0.5, - expected: 0.875, // 1 - (1-0.5)^3 - epsilon: 0.0001, - }, - { - name: "t=0.25 starts fast", - t: 0.25, - expected: 0.578125, // 1 - (1-0.25)^3 - epsilon: 0.0001, - }, - { - name: "t=0.75 slows down", - t: 0.75, - expected: 0.984375, // 1 - (1-0.75)^3 - epsilon: 0.0001, - }, - { - name: "clamps t below 0", - t: -0.5, - expected: 0.0, - epsilon: 0.0001, - }, - { - name: "clamps t above 1", - t: 1.5, - expected: 1.0, - epsilon: 0.0001, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Note: Not using t.Parallel() - result := EaseOut(tt.t) - if math.Abs(result-tt.expected) > tt.epsilon { - t.Errorf("EaseOut(%v) = %v, want %v (±%v)", - tt.t, result, tt.expected, tt.epsilon) - } - }) - } -} - -// TestLerpColorTransition demonstrates using Lerp with RGB values -func TestLerpColorTransition(t *testing.T) { - // Note: Not using t.Parallel() - - // RGB color from gray (127,127,127) to blue (0,0,255) - startR, startG, startB := 127.0, 127.0, 127.0 - endR, endG, endB := 0.0, 0.0, 255.0 - - // At t=0.5, should be halfway between - t_val := 0.5 - resultR := Lerp(startR, endR, t_val) - resultG := Lerp(startG, endG, t_val) - resultB := Lerp(startB, endB, t_val) - - expectedR := 63.5 - expectedG := 63.5 - expectedB := 191.0 - - if math.Abs(resultR-expectedR) > 0.1 || - math.Abs(resultG-expectedG) > 0.1 || - math.Abs(resultB-expectedB) > 0.1 { - t.Errorf("Color interpolation failed: got RGB(%v,%v,%v), want RGB(%v,%v,%v)", - resultR, resultG, resultB, expectedR, expectedG, expectedB) - } -} - -// BenchmarkLerp benchmarks the Lerp function -func BenchmarkLerp(b *testing.B) { - for i := 0; i < b.N; i++ { - Lerp(0.0, 100.0, 0.5) - } -} - -// BenchmarkEaseInOut benchmarks the EaseInOut function -func BenchmarkEaseInOut(b *testing.B) { - for i := 0; i < b.N; i++ { - EaseInOut(0.5) - } -} - -// BenchmarkEaseOut benchmarks the EaseOut function -func BenchmarkEaseOut(b *testing.B) { - for i := 0; i < b.N; i++ { - EaseOut(0.5) - } -} - -// BenchmarkColorTransition benchmarks a complete RGB color transition -func BenchmarkColorTransition(b *testing.B) { - startR, startG, startB := 127.0, 127.0, 127.0 - endR, endG, endB := 0.0, 0.0, 255.0 - - b.ResetTimer() - for i := 0; i < b.N; i++ { - t := float64(i%100) / 100.0 - eased := EaseInOut(t) - _ = Lerp(startR, endR, eased) - _ = Lerp(startG, endG, eased) - _ = Lerp(startB, endB, eased) - } -} diff --git a/pkg/ui/animations/metrics.go b/pkg/ui/animations/metrics.go deleted file mode 100644 index c34e5d1..0000000 --- a/pkg/ui/animations/metrics.go +++ /dev/null @@ -1,261 +0,0 @@ -package animations - -import ( - "fmt" - "sync" - "time" -) - -const ( - // msgPerfMonDisabled is the message returned when performance monitoring is disabled - msgPerfMonDisabled = "Performance monitoring disabled" -) - -// PerformanceMonitor tracks animation performance metrics. -type PerformanceMonitor struct { - enabled bool - frameTimes []time.Duration - droppedFrames int - totalFrames int - startTime time.Time - mu sync.RWMutex - logger Logger -} - -// Logger interface for performance monitoring. -type Logger interface { - Debug(msg string, args ...interface{}) - Warn(msg string, args ...interface{}) -} - -// NewPerformanceMonitor creates a new performance monitor. -// Only enabled when debug logging is active. -func NewPerformanceMonitor(enabled bool, logger Logger) *PerformanceMonitor { - return &PerformanceMonitor{ - enabled: enabled, - frameTimes: make([]time.Duration, 0, 1000), - startTime: time.Now(), - logger: logger, - } -} - -// RecordFrame records the time taken to render a frame. -func (m *PerformanceMonitor) RecordFrame(duration time.Duration) { - if !m.enabled { - return - } - - m.mu.Lock() - defer m.mu.Unlock() - - m.totalFrames++ - m.frameTimes = append(m.frameTimes, duration) - - // Check if frame time exceeds 60 FPS target (16.6ms) - if duration > 16*time.Millisecond { - m.droppedFrames++ - if m.logger != nil { - m.logger.Warn("Slow frame detected", - "duration_ms", duration.Milliseconds(), - "target_ms", 16, - "dropped_frames", m.droppedFrames, - ) - } - } - - // Keep only last 1000 frames to avoid memory growth - if len(m.frameTimes) > 1000 { - m.frameTimes = m.frameTimes[len(m.frameTimes)-1000:] - } -} - -// GetStats returns performance statistics. -func (m *PerformanceMonitor) GetStats() PerformanceStats { - if !m.enabled { - return PerformanceStats{Enabled: false} - } - - m.mu.RLock() - defer m.mu.RUnlock() - - stats := PerformanceStats{ - Enabled: true, - TotalFrames: m.totalFrames, - DroppedFrames: m.droppedFrames, - Uptime: time.Since(m.startTime), - } - - if len(m.frameTimes) > 0 { - stats.AverageFrameTime = m.calculateAverage() - stats.P95FrameTime = m.calculatePercentile(0.95) - stats.P99FrameTime = m.calculatePercentile(0.99) - stats.MaxFrameTime = m.calculateMax() - stats.MinFrameTime = m.calculateMin() - } - - if stats.Uptime > 0 { - stats.AverageFPS = float64(m.totalFrames) / stats.Uptime.Seconds() - } - - return stats -} - -// calculateAverage calculates the average frame time. -func (m *PerformanceMonitor) calculateAverage() time.Duration { - if len(m.frameTimes) == 0 { - return 0 - } - - var sum time.Duration - for _, t := range m.frameTimes { - sum += t - } - return sum / time.Duration(len(m.frameTimes)) -} - -// calculatePercentile calculates the Nth percentile frame time. -func (m *PerformanceMonitor) calculatePercentile(percentile float64) time.Duration { - if len(m.frameTimes) == 0 { - return 0 - } - - // Copy and sort times - times := make([]time.Duration, len(m.frameTimes)) - copy(times, m.frameTimes) - - // Simple bubble sort (good enough for small samples) - for i := 0; i < len(times); i++ { - for j := i + 1; j < len(times); j++ { - if times[i] > times[j] { - times[i], times[j] = times[j], times[i] - } - } - } - - index := int(float64(len(times)) * percentile) - if index >= len(times) { - index = len(times) - 1 - } - - return times[index] -} - -// calculateMax returns the maximum frame time. -func (m *PerformanceMonitor) calculateMax() time.Duration { - if len(m.frameTimes) == 0 { - return 0 - } - - maxTime := m.frameTimes[0] - for _, t := range m.frameTimes { - if t > maxTime { - maxTime = t - } - } - return maxTime -} - -// calculateMin returns the minimum frame time. -func (m *PerformanceMonitor) calculateMin() time.Duration { - if len(m.frameTimes) == 0 { - return 0 - } - - minTime := m.frameTimes[0] - for _, t := range m.frameTimes { - if t < minTime { - minTime = t - } - } - return minTime -} - -// LogStats logs current performance statistics. -func (m *PerformanceMonitor) LogStats() { - if !m.enabled || m.logger == nil { - return - } - - stats := m.GetStats() - - m.logger.Debug("Animation performance stats", - "total_frames", stats.TotalFrames, - "dropped_frames", stats.DroppedFrames, - "avg_fps", fmt.Sprintf("%.2f", stats.AverageFPS), - "avg_frame_ms", stats.AverageFrameTime.Milliseconds(), - "p95_frame_ms", stats.P95FrameTime.Milliseconds(), - "p99_frame_ms", stats.P99FrameTime.Milliseconds(), - "max_frame_ms", stats.MaxFrameTime.Milliseconds(), - "uptime_s", stats.Uptime.Seconds(), - ) -} - -// Reset resets all performance metrics. -func (m *PerformanceMonitor) Reset() { - if !m.enabled { - return - } - - m.mu.Lock() - defer m.mu.Unlock() - - m.frameTimes = make([]time.Duration, 0, 1000) - m.droppedFrames = 0 - m.totalFrames = 0 - m.startTime = time.Now() -} - -// SetEnabled enables or disables performance monitoring. -func (m *PerformanceMonitor) SetEnabled(enabled bool) { - m.mu.Lock() - defer m.mu.Unlock() - m.enabled = enabled -} - -// IsEnabled returns whether monitoring is enabled. -func (m *PerformanceMonitor) IsEnabled() bool { - m.mu.RLock() - defer m.mu.RUnlock() - return m.enabled -} - -// PerformanceStats contains animation performance statistics. -type PerformanceStats struct { - Enabled bool - TotalFrames int - DroppedFrames int - AverageFPS float64 - AverageFrameTime time.Duration - P95FrameTime time.Duration - P99FrameTime time.Duration - MaxFrameTime time.Duration - MinFrameTime time.Duration - Uptime time.Duration -} - -// String returns a human-readable representation of the stats. -func (s PerformanceStats) String() string { - if !s.Enabled { - return msgPerfMonDisabled - } - - return fmt.Sprintf( - "Frames: %d (dropped: %d), Avg FPS: %.2f, "+ - "Frame time: avg=%.2fms p95=%.2fms p99=%.2fms max=%.2fms, "+ - "Uptime: %.2fs", - s.TotalFrames, - s.DroppedFrames, - s.AverageFPS, - float64(s.AverageFrameTime.Microseconds())/1000.0, - float64(s.P95FrameTime.Microseconds())/1000.0, - float64(s.P99FrameTime.Microseconds())/1000.0, - float64(s.MaxFrameTime.Microseconds())/1000.0, - s.Uptime.Seconds(), - ) -} - -// NoopLogger is a logger that does nothing. -type NoopLogger struct{} - -func (NoopLogger) Debug(_ string, _ ...interface{}) {} -func (NoopLogger) Warn(_ string, _ ...interface{}) {} diff --git a/pkg/ui/animations/metrics_test.go b/pkg/ui/animations/metrics_test.go deleted file mode 100644 index a2dc0e9..0000000 --- a/pkg/ui/animations/metrics_test.go +++ /dev/null @@ -1,362 +0,0 @@ -package animations - -import ( - "sync" - "testing" - "time" -) - -// mockLogger is a test logger that captures log messages -type mockLogger struct { - mu sync.Mutex - debugMessages []string - warnMessages []string -} - -func (m *mockLogger) Debug(msg string, args ...interface{}) { - m.mu.Lock() - defer m.mu.Unlock() - m.debugMessages = append(m.debugMessages, msg) -} - -func (m *mockLogger) Warn(msg string, args ...interface{}) { - m.mu.Lock() - defer m.mu.Unlock() - m.warnMessages = append(m.warnMessages, msg) -} - -func TestNewPerformanceMonitor(t *testing.T) { - logger := &mockLogger{} - monitor := NewPerformanceMonitor(true, logger) - - if monitor == nil { - t.Fatal("Expected monitor to be created") - } - - if !monitor.IsEnabled() { - t.Error("Expected monitor to be enabled") - } -} - -func TestPerformanceMonitor_Disabled(t *testing.T) { - logger := &mockLogger{} - monitor := NewPerformanceMonitor(false, logger) - - // Recording frames should be no-op when disabled - monitor.RecordFrame(10 * time.Millisecond) - monitor.RecordFrame(20 * time.Millisecond) - - stats := monitor.GetStats() - if stats.Enabled { - t.Error("Expected stats to show disabled") - } - - if stats.TotalFrames != 0 { - t.Errorf("Expected 0 frames when disabled, got %d", stats.TotalFrames) - } -} - -func TestPerformanceMonitor_RecordFrame(t *testing.T) { - logger := &mockLogger{} - monitor := NewPerformanceMonitor(true, logger) - - // Record some frames - monitor.RecordFrame(10 * time.Millisecond) - monitor.RecordFrame(15 * time.Millisecond) - monitor.RecordFrame(12 * time.Millisecond) - - stats := monitor.GetStats() - if stats.TotalFrames != 3 { - t.Errorf("Expected 3 frames, got %d", stats.TotalFrames) - } - - if stats.DroppedFrames != 0 { - t.Errorf("Expected 0 dropped frames, got %d", stats.DroppedFrames) - } -} - -func TestPerformanceMonitor_SlowFrameDetection(t *testing.T) { - logger := &mockLogger{} - monitor := NewPerformanceMonitor(true, logger) - - // Record a slow frame (>16ms for 60 FPS) - monitor.RecordFrame(20 * time.Millisecond) - - stats := monitor.GetStats() - if stats.DroppedFrames != 1 { - t.Errorf("Expected 1 dropped frame, got %d", stats.DroppedFrames) - } - - if len(logger.warnMessages) == 0 { - t.Error("Expected warning message for slow frame") - } -} - -func TestPerformanceMonitor_GetStats(t *testing.T) { - logger := &mockLogger{} - monitor := NewPerformanceMonitor(true, logger) - - // Record various frame times - frameTimes := []time.Duration{ - 10 * time.Millisecond, - 12 * time.Millisecond, - 15 * time.Millisecond, - 20 * time.Millisecond, - 8 * time.Millisecond, - } - - for _, ft := range frameTimes { - monitor.RecordFrame(ft) - } - - stats := monitor.GetStats() - - if !stats.Enabled { - t.Error("Expected stats to be enabled") - } - - if stats.TotalFrames != 5 { - t.Errorf("Expected 5 total frames, got %d", stats.TotalFrames) - } - - if stats.DroppedFrames != 1 { - t.Errorf("Expected 1 dropped frame (20ms), got %d", stats.DroppedFrames) - } - - if stats.AverageFrameTime == 0 { - t.Error("Expected non-zero average frame time") - } - - if stats.MaxFrameTime != 20*time.Millisecond { - t.Errorf("Expected max frame time 20ms, got %v", stats.MaxFrameTime) - } - - if stats.MinFrameTime != 8*time.Millisecond { - t.Errorf("Expected min frame time 8ms, got %v", stats.MinFrameTime) - } - - if stats.AverageFPS == 0 { - t.Error("Expected non-zero average FPS") - } -} - -func TestPerformanceMonitor_Percentiles(t *testing.T) { - logger := &mockLogger{} - monitor := NewPerformanceMonitor(true, logger) - - // Record frames with known distribution - for i := 1; i <= 100; i++ { - monitor.RecordFrame(time.Duration(i) * time.Millisecond) - } - - stats := monitor.GetStats() - - // P95 should be around 95ms - if stats.P95FrameTime < 90*time.Millisecond || stats.P95FrameTime > 100*time.Millisecond { - t.Errorf("Expected P95 around 95ms, got %v", stats.P95FrameTime) - } - - // P99 should be around 99ms - if stats.P99FrameTime < 95*time.Millisecond || stats.P99FrameTime > 105*time.Millisecond { - t.Errorf("Expected P99 around 99ms, got %v", stats.P99FrameTime) - } -} - -func TestPerformanceMonitor_Reset(t *testing.T) { - logger := &mockLogger{} - monitor := NewPerformanceMonitor(true, logger) - - // Record some frames - monitor.RecordFrame(10 * time.Millisecond) - monitor.RecordFrame(20 * time.Millisecond) - - stats := monitor.GetStats() - if stats.TotalFrames != 2 { - t.Errorf("Expected 2 frames before reset, got %d", stats.TotalFrames) - } - - // Reset - monitor.Reset() - - stats = monitor.GetStats() - if stats.TotalFrames != 0 { - t.Errorf("Expected 0 frames after reset, got %d", stats.TotalFrames) - } - - if stats.DroppedFrames != 0 { - t.Errorf("Expected 0 dropped frames after reset, got %d", stats.DroppedFrames) - } -} - -func TestPerformanceMonitor_SetEnabled(t *testing.T) { - logger := &mockLogger{} - monitor := NewPerformanceMonitor(true, logger) - - if !monitor.IsEnabled() { - t.Error("Expected monitor to be enabled initially") - } - - // Disable - monitor.SetEnabled(false) - if monitor.IsEnabled() { - t.Error("Expected monitor to be disabled") - } - - // Enable - monitor.SetEnabled(true) - if !monitor.IsEnabled() { - t.Error("Expected monitor to be enabled") - } -} - -func TestPerformanceMonitor_LogStats(t *testing.T) { - logger := &mockLogger{} - monitor := NewPerformanceMonitor(true, logger) - - // Record some frames - monitor.RecordFrame(10 * time.Millisecond) - monitor.RecordFrame(15 * time.Millisecond) - - // Log stats - monitor.LogStats() - - if len(logger.debugMessages) == 0 { - t.Error("Expected debug message when logging stats") - } -} - -func TestPerformanceMonitor_MemoryLimit(t *testing.T) { - logger := &mockLogger{} - monitor := NewPerformanceMonitor(true, logger) - - // Record more than 1000 frames - for i := 0; i < 1500; i++ { - monitor.RecordFrame(10 * time.Millisecond) - } - - stats := monitor.GetStats() - - // Should have recorded all frames - if stats.TotalFrames != 1500 { - t.Errorf("Expected 1500 total frames, got %d", stats.TotalFrames) - } - - // But should only keep last 1000 in memory for stats calculation - // This is internal, we just verify it doesn't crash or leak memory -} - -func TestPerformanceMonitor_ConcurrentAccess(t *testing.T) { - logger := &mockLogger{} - monitor := NewPerformanceMonitor(true, logger) - - done := make(chan bool) - numGoroutines := 5 - framesPerGoroutine := 20 - - // Multiple goroutines recording frames - for i := 0; i < numGoroutines; i++ { - go func() { - for j := 0; j < framesPerGoroutine; j++ { - monitor.RecordFrame(10 * time.Millisecond) - monitor.GetStats() - monitor.LogStats() - } - done <- true - }() - } - - // Wait for all goroutines - for i := 0; i < numGoroutines; i++ { - select { - case <-done: - case <-time.After(5 * time.Second): - t.Fatal("Timeout waiting for concurrent operations") - } - } - - stats := monitor.GetStats() - expectedFrames := numGoroutines * framesPerGoroutine - if stats.TotalFrames != expectedFrames { - t.Errorf("Expected %d total frames, got %d", expectedFrames, stats.TotalFrames) - } -} - -func TestPerformanceStats_String(t *testing.T) { - stats := PerformanceStats{ - Enabled: true, - TotalFrames: 100, - DroppedFrames: 5, - AverageFPS: 58.5, - AverageFrameTime: 15 * time.Millisecond, - P95FrameTime: 18 * time.Millisecond, - P99FrameTime: 20 * time.Millisecond, - MaxFrameTime: 25 * time.Millisecond, - Uptime: 2 * time.Second, - } - - str := stats.String() - if str == "" { - t.Error("Expected non-empty string representation") - } - - if len(str) < 50 { - t.Errorf("Expected detailed string representation, got: %s", str) - } -} - -func TestPerformanceStats_String_Disabled(t *testing.T) { - stats := PerformanceStats{ - Enabled: false, - } - - str := stats.String() - if str != msgPerfMonDisabled { - t.Errorf("Expected disabled message, got: %s", str) - } -} - -func TestNoopLogger(t *testing.T) { - logger := NoopLogger{} - - // Should not panic - logger.Debug("test") - logger.Warn("test") -} - -// BenchmarkPerformanceMonitor_RecordFrame benchmarks recording a frame -func BenchmarkPerformanceMonitor_RecordFrame(b *testing.B) { - logger := NoopLogger{} - monitor := NewPerformanceMonitor(true, logger) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - monitor.RecordFrame(10 * time.Millisecond) - } -} - -// BenchmarkPerformanceMonitor_GetStats benchmarks getting statistics -func BenchmarkPerformanceMonitor_GetStats(b *testing.B) { - logger := NoopLogger{} - monitor := NewPerformanceMonitor(true, logger) - - // Record some frames first - for i := 0; i < 100; i++ { - monitor.RecordFrame(time.Duration(i) * time.Millisecond) - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - monitor.GetStats() - } -} - -// BenchmarkPerformanceMonitor_Disabled benchmarks when monitoring is disabled -func BenchmarkPerformanceMonitor_Disabled(b *testing.B) { - logger := NoopLogger{} - monitor := NewPerformanceMonitor(false, logger) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - monitor.RecordFrame(10 * time.Millisecond) - } -} diff --git a/pkg/ui/animations/progress.go b/pkg/ui/animations/progress.go deleted file mode 100644 index 62e78fd..0000000 --- a/pkg/ui/animations/progress.go +++ /dev/null @@ -1,208 +0,0 @@ -package animations - -import ( - "context" - "fmt" - "os" - "os/signal" - "syscall" - "time" - - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// WithProgress wraps an operation with a progress bar UI. -// It displays a progress bar with percentage and ETA while fn executes. -// The fn function receives an update callback to report progress. -// Deprecated: Use WithThemedProgress for profile-aware theming. -// -// Example: -// -// err := WithProgress("Processing files", 100, func(update func(int64)) error { -// for i := 0; i < 100; i++ { -// processFile(i) -// update(1) // Increment by 1 -// } -// return nil -// }) -func WithProgress(label string, total int64, fn func(update func(int64)) error) error { - return WithThemedProgress(label, total, nil, fn) -} - -// WithThemedProgress wraps an operation with a progress bar UI using theme colors. -// It displays a progress bar with percentage and ETA while fn executes. -// The fn function receives an update callback to report progress. -// If theme is nil, falls back to default theme. -// -// Example: -// -// err := WithThemedProgress("Processing files", 100, profileCtx.Theme(), func(update func(int64)) error { -// for i := 0; i < 100; i++ { -// processFile(i) -// update(1) // Increment by 1 -// } -// return nil -// }) -func WithThemedProgress(label string, total int64, theme *themes.Theme, fn func(update func(int64)) error) error { - if !ShouldAnimate() { - // Just run the function without progress bar - return fn(func(delta int64) { - // No-op update function - }) - } - - // Fallback to default theme if not provided - if theme == nil { - theme, _ = themes.GetDefault() - if theme == nil { - loader := themes.NewLoader() - theme, _ = loader.Load("enterprise") - } - } - - // Create progress state - ps := components.NewProgressState(total, label) - - // Create context for cancellation - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Set up signal handler for Ctrl+C - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - defer signal.Stop(sigChan) - - // Channel to signal operation completion - done := make(chan error, 1) - - // Update channel for progress updates - updateChan := make(chan int64, 100) - - // Start the operation in a goroutine - go func() { - err := fn(func(delta int64) { - select { - case updateChan <- delta: - case <-ctx.Done(): - return - } - }) - close(updateChan) - done <- err - }() - - // Start progress rendering - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - - // Hide cursor - fmt.Print("\033[?25l") - defer fmt.Print("\033[?25h") - - for { - select { - case <-sigChan: - // User interrupted - cancel() - clearProgressLine() - return fmt.Errorf("operation interrupted") - - case delta, ok := <-updateChan: - if ok { - ps.Update(delta) - } - - case err := <-done: - // Operation completed - clearProgressLine() - if err == nil { - // Show completion message with theme success color - var successColor lipgloss.Color - if theme != nil { - successColor = theme.Colors.SuccessColor() - } else { - successColor = lipgloss.Color("#00E091") - } - style := lipgloss.NewStyle().Foreground(successColor) - fmt.Printf("%s %s\n", style.Render("✓"), label) - } - return err - - case <-ticker.C: - // Render progress bar - renderProgressBar(ps, theme) - - case <-ctx.Done(): - clearProgressLine() - return ctx.Err() - } - } -} - -// renderProgressBar renders the progress bar with percentage and ETA using theme colors. -func renderProgressBar(ps *components.ProgressState, theme *themes.Theme) { - clearProgressLine() - - progress := ps.Progress() - width := 40 // Progress bar width - - // Calculate filled portion - filled := int(progress * float64(width)) - if filled > width { - filled = width - } - - // Build progress bar - bar := "" - for i := 0; i < width; i++ { - if i < filled { - bar += "█" - } else { - bar += "░" - } - } - - // Color based on progress using theme colors - color := getProgressColor(progress, theme) - - style := lipgloss.NewStyle().Foreground(color) - - // Format output - output := fmt.Sprintf("%s %s %s", - ps.Label, - style.Render(bar), - fmt.Sprintf("%d%% (ETA: %s)", int(progress*100), ps.ETAString())) - - fmt.Print("\r" + output) -} - -// clearProgressLine clears the current progress line. -func clearProgressLine() { - fmt.Print("\r\033[K") -} - -// getProgressColor returns the appropriate color for the given progress percentage. -// Uses theme colors if available, otherwise falls back to hardcoded colors. -func getProgressColor(progress float64, theme *themes.Theme) lipgloss.Color { - // Select color based on progress - if progress < 0.5 { - if theme != nil { - return theme.Colors.WarningColor() - } - return lipgloss.Color("#FFB86C") - } - if progress < 1.0 { - if theme != nil { - return theme.Colors.PrimaryColor() - } - return lipgloss.Color("#00ADD8") - } - // progress >= 1.0 - if theme != nil { - return theme.Colors.SuccessColor() - } - return lipgloss.Color("#00E091") -} diff --git a/pkg/ui/animations/progress_test.go b/pkg/ui/animations/progress_test.go deleted file mode 100644 index ef81a77..0000000 --- a/pkg/ui/animations/progress_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package animations - -import ( - "errors" - "testing" - "time" -) - -func TestWithProgress_Success(t *testing.T) { - completed := false - err := WithProgress("Test operation", 10, func(update func(int64)) error { - for i := 0; i < 10; i++ { - update(1) - time.Sleep(5 * time.Millisecond) - } - completed = true - return nil - }) - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - if !completed { - t.Error("Operation did not complete") - } -} - -func TestWithProgress_Error(t *testing.T) { - expectedErr := errors.New("test error") - err := WithProgress("Test operation", 10, func(update func(int64)) error { - update(5) - return expectedErr - }) - if err == nil { - t.Error("Expected error, got nil") - } - if !errors.Is(err, expectedErr) { - t.Errorf("Expected %v, got %v", expectedErr, err) - } -} - -func TestWithProgress_NoAnimation(t *testing.T) { - origNoAnimation := NoAnimation - defer func() { - NoAnimation = origNoAnimation - }() - NoAnimation = true - updateCount := 0 - err := WithProgress("Test operation", 10, func(update func(int64)) error { - for i := 0; i < 10; i++ { - update(1) - updateCount++ - } - return nil - }) - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - if updateCount != 10 { - t.Errorf("Expected 10 updates, got %d", updateCount) - } -} - -func TestClearProgressLine(t *testing.T) { - clearProgressLine() -} - -func BenchmarkWithProgress(b *testing.B) { - origNoAnimation := NoAnimation - NoAnimation = true - defer func() { - NoAnimation = origNoAnimation - }() - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = WithProgress("Benchmark", 100, func(update func(int64)) error { - for j := 0; j < 100; j++ { - update(1) - } - return nil - }) - } -} diff --git a/pkg/ui/animations/resize_test.go b/pkg/ui/animations/resize_test.go deleted file mode 100644 index 31a4e4e..0000000 --- a/pkg/ui/animations/resize_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package animations - -import ( - "testing" - "time" -) - -func TestNewResizeHandler(t *testing.T) { - handler := NewResizeHandler() - defer handler.Stop() - - if handler == nil { - t.Fatal("Expected resize handler to be created") - } - - width, height := handler.GetSize() - if width <= 0 || height <= 0 { - t.Errorf("Expected positive dimensions, got width=%d, height=%d", width, height) - } -} - -func TestResizeHandler_GetSize(t *testing.T) { - handler := NewResizeHandler() - defer handler.Stop() - - width, height := handler.GetSize() - - // Dimensions should be positive - if width <= 0 { - t.Errorf("Expected positive width, got %d", width) - } - if height <= 0 { - t.Errorf("Expected positive height, got %d", height) - } -} - -func TestResizeHandler_Subscribe(t *testing.T) { - handler := NewResizeHandler() - defer handler.Stop() - - ch := handler.Subscribe() - if ch == nil { - t.Fatal("Expected subscription channel to be created") - } - - // Channel should be buffered - select { - case ch <- struct{}{}: - // Successfully sent, channel is buffered - default: - t.Error("Expected buffered channel") - } -} - -func TestResizeHandler_ShouldContinueAnimation(t *testing.T) { - handler := NewResizeHandler() - defer handler.Stop() - - // Should return true or false based on terminal width - result := handler.ShouldContinueAnimation() - - width, _ := handler.GetSize() - expected := width >= 80 - - if result != expected { - t.Errorf("ShouldContinueAnimation() = %v, want %v (width=%d)", result, expected, width) - } -} - -func TestResizeHandler_Stop(t *testing.T) { - handler := NewResizeHandler() - - // Stop should not panic - handler.Stop() - - // Calling Stop again should not panic - handler.Stop() -} - -func TestResizeHandler_MultipleSubscribers(t *testing.T) { - handler := NewResizeHandler() - defer handler.Stop() - - ch1 := handler.Subscribe() - ch2 := handler.Subscribe() - ch3 := handler.Subscribe() - - if ch1 == nil || ch2 == nil || ch3 == nil { - t.Fatal("Expected all subscription channels to be created") - } - - // All channels should be independent - if ch1 == ch2 || ch1 == ch3 || ch2 == ch3 { - t.Error("Expected independent subscription channels") - } -} - -func TestResizeHandler_ConcurrentAccess(t *testing.T) { - handler := NewResizeHandler() - defer handler.Stop() - - // Test concurrent reads - done := make(chan bool) - for i := 0; i < 10; i++ { - go func() { - for j := 0; j < 100; j++ { - handler.GetSize() - handler.ShouldContinueAnimation() - } - done <- true - }() - } - - // Wait for all goroutines - for i := 0; i < 10; i++ { - select { - case <-done: - case <-time.After(5 * time.Second): - t.Fatal("Timeout waiting for concurrent operations") - } - } -} - -func TestResizeHandler_SubscribeAfterStop(t *testing.T) { - handler := NewResizeHandler() - handler.Stop() - - // Should not panic when subscribing after stop - ch := handler.Subscribe() - if ch == nil { - t.Error("Expected subscription channel even after stop") - } -} - -// BenchmarkResizeHandler_GetSize benchmarks getting terminal size -func BenchmarkResizeHandler_GetSize(b *testing.B) { - handler := NewResizeHandler() - defer handler.Stop() - - b.ResetTimer() - for i := 0; i < b.N; i++ { - handler.GetSize() - } -} - -// BenchmarkResizeHandler_ShouldContinue benchmarks animation continuation check -func BenchmarkResizeHandler_ShouldContinue(b *testing.B) { - handler := NewResizeHandler() - defer handler.Stop() - - b.ResetTimer() - for i := 0; i < b.N; i++ { - handler.ShouldContinueAnimation() - } -} diff --git a/pkg/ui/animations/spinner.go b/pkg/ui/animations/spinner.go deleted file mode 100644 index 333b210..0000000 --- a/pkg/ui/animations/spinner.go +++ /dev/null @@ -1,105 +0,0 @@ -package animations - -import ( - "context" - "fmt" - "os" - "os/signal" - "syscall" - "time" - - "github.com/charmbracelet/bubbles/spinner" - - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// WithSpinner wraps an operation with a spinner UI. -// It displays an animated spinner with the given label while fn executes. -// If animations are disabled, fn runs without visual feedback. -// -// Example: -// -// err := WithSpinner("Loading data...", func() error { -// return loadData() -// }) -func WithSpinner(label string, fn func() error) error { - if !ShouldAnimate() { - // Just run the function without spinner - return fn() - } - - // Get current theme color for spinner - color := getCurrentThemeColor() - - // Create spinner - s := components.NewSpinnerWithColor(color) - - // Create context for cancellation - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Set up signal handler for Ctrl+C - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - defer signal.Stop(sigChan) - - // Channel to signal operation completion - done := make(chan error, 1) - - // Start the operation in a goroutine - go func() { - done <- fn() - }() - - // Start spinner rendering - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - - // Hide cursor - fmt.Print("\033[?25l") - defer fmt.Print("\033[?25h") // Show cursor on exit - - for { - select { - case <-sigChan: - // User interrupted, cancel and clean up - cancel() - clearSpinnerLine() - return fmt.Errorf("operation interrupted") - - case err := <-done: - // Operation completed - clearSpinnerLine() - return err - - case <-ticker.C: - // Update spinner - clearSpinnerLine() - fmt.Printf("%s %s", s.View(), label) - s, _ = s.Update(spinner.TickMsg{ - Time: time.Now(), - }) - - case <-ctx.Done(): - // Context canceled - clearSpinnerLine() - return ctx.Err() - } - } -} - -// clearSpinnerLine clears the current line -func clearSpinnerLine() { - fmt.Print("\r\033[K") -} - -// getCurrentThemeColor returns the primary color from the current theme -func getCurrentThemeColor() string { - // Use YAML theme loader to get default theme color - loader := themes.NewLoader() - if theme, err := loader.Load("cyan-purple"); err == nil { - return theme.Colors.Primary - } - return "#00ADD8" // Fallback to Go blue -} diff --git a/pkg/ui/animations/spinner_test.go b/pkg/ui/animations/spinner_test.go deleted file mode 100644 index 95b0497..0000000 --- a/pkg/ui/animations/spinner_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package animations - -import ( - "errors" - "os" - "testing" - "time" -) - -func TestWithSpinner_Success(t *testing.T) { - // Test successful operation - called := false - err := WithSpinner("Test operation", func() error { - called = true - time.Sleep(50 * time.Millisecond) - return nil - }) - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - - if !called { - t.Error("Function was not called") - } -} - -func TestWithSpinner_Error(t *testing.T) { - // Test operation that returns an error - expectedErr := errors.New("test error") - err := WithSpinner("Test operation", func() error { - return expectedErr - }) - - if err == nil { - t.Error("Expected error, got nil") - } - - if !errors.Is(err, expectedErr) { - t.Errorf("Expected %v, got %v", expectedErr, err) - } -} - -func TestWithSpinner_NoAnimation(t *testing.T) { - // Save original state - origNoAnimation := NoAnimation - defer func() { - NoAnimation = origNoAnimation - }() - - // Disable animations - NoAnimation = true - - called := false - err := WithSpinner("Test operation", func() error { - called = true - return nil - }) - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - - if !called { - t.Error("Function was not called even with animations disabled") - } -} - -func TestWithSpinner_QuickOperation(t *testing.T) { - // Test a very quick operation - counter := 0 - err := WithSpinner("Quick operation", func() error { - counter++ - return nil - }) - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - - if counter != 1 { - t.Errorf("Function called %d times, expected 1", counter) - } -} - -func TestWithSpinner_LongOperation(t *testing.T) { - // Test an operation that takes a bit longer - start := time.Now() - err := WithSpinner("Long operation", func() error { - time.Sleep(200 * time.Millisecond) - return nil - }) - - duration := time.Since(start) - - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - - if duration < 200*time.Millisecond { - t.Errorf("Operation finished too quickly: %v", duration) - } -} - -func TestWithSpinner_EnvironmentVariables(t *testing.T) { - // Save original state - origNoAnimation := NoAnimation - origEnv := os.Getenv("ARC_NO_ANIMATION") - defer func() { - NoAnimation = origNoAnimation - if origEnv != "" { - os.Setenv("ARC_NO_ANIMATION", origEnv) - } else { - os.Unsetenv("ARC_NO_ANIMATION") - } - }() - - // Test with ARC_NO_ANIMATION set - NoAnimation = false - os.Setenv("ARC_NO_ANIMATION", "1") - - called := false - err := WithSpinner("Test with env var", func() error { - called = true - return nil - }) - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - - if !called { - t.Error("Function should be called even when animations are disabled via env var") - } -} - -func TestClearSpinnerLine(t *testing.T) { - // Test that clearSpinnerLine doesn't panic - // (actual output is not testable without a real terminal) - clearSpinnerLine() -} - -func TestGetCurrentThemeColor(t *testing.T) { - color := getCurrentThemeColor() - - if color == "" { - t.Error("getCurrentThemeColor returned empty string") - } - - // Check it's a valid hex color (starts with #) - if color[0] != '#' { - t.Errorf("Expected color to start with #, got %s", color) - } - - if len(color) != 7 { - t.Errorf("Expected 7-character hex color, got %s (length %d)", color, len(color)) - } -} - -// BenchmarkWithSpinner benchmarks spinner wrapper -func BenchmarkWithSpinner(b *testing.B) { - origNoAnimation := NoAnimation - defer func() { NoAnimation = origNoAnimation }() - - NoAnimation = true // Prevent actual rendering - - b.ResetTimer() - for i := 0; i < b.N; i++ { - err := WithSpinner("Test", func() error { - return nil - }) - if err != nil { - b.Fatal(err) - } - } -} diff --git a/pkg/ui/animations/transition.go b/pkg/ui/animations/transition.go deleted file mode 100644 index e3cfc47..0000000 --- a/pkg/ui/animations/transition.go +++ /dev/null @@ -1,225 +0,0 @@ -package animations - -import ( - "context" - "fmt" - "math" - "os" - "os/signal" - "strings" - "syscall" - "time" - - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/styles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// AnimateThemeTransition performs a smooth animated transition between two themes. -// It interpolates colors using spring physics for a natural feel. -// -// Example: -// -// currentTheme := themes.Available()["cyan-purple"] -// newTheme := themes.Available()["ocean"] -// err := AnimateThemeTransition(¤tTheme, &newTheme) -func AnimateThemeTransition(from, to *themes.Scheme) error { - if !ShouldAnimate() { - // Skip animation if disabled - return nil - } - - config := LoadConfig() - duration := 200 * time.Millisecond // Fixed duration for smooth feel - - // Create context for cancellation - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Set up signal handler for Ctrl+C - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - defer signal.Stop(sigChan) - - // Create color transitions for primary colors - transitions := []struct { - name string - from lipgloss.Color - to lipgloss.Color - }{ - {"Primary", from.Primary, to.Primary}, - {"Secondary", from.Secondary, to.Secondary}, - {"Success", from.Success, to.Success}, - {"Error", from.Error, to.Error}, - {"Warning", from.Warning, to.Warning}, - {"Info", from.Info, to.Info}, - } - - // Animation loop - startTime := time.Now() - frameTime := time.Second / time.Duration(config.TargetFPS) - - // Hide cursor - fmt.Print("\033[?25l") - defer fmt.Print("\033[?25h") - - for { - select { - case <-sigChan: - // User interrupted - clearLine() - return fmt.Errorf("transition interrupted") - - case <-ctx.Done(): - clearLine() - return ctx.Err() - - default: - // Check if animation is complete - elapsed := time.Since(startTime) - if elapsed >= duration { - clearLine() - return nil - } - - // Calculate smooth progress using ease-out for natural deceleration - progress := float64(elapsed) / float64(duration) - smoothProgress := EaseOut(progress) - - // Render transition frame - renderTransitionFrame(transitions, smoothProgress) - - time.Sleep(frameTime) - } - } -} - -// renderTransitionFrame renders a single frame of the theme transition. -func renderTransitionFrame(transitions []struct { - name string - from lipgloss.Color - to lipgloss.Color -}, progress float64, -) { - clearLine() - - // Show a sample transition visualization - var output string - for _, t := range transitions { - fromColor := HexToColor(string(t.from)) - toColor := HexToColor(string(t.to)) - currentColor := Interpolate(fromColor, toColor, progress) - - // Render a color block - style := lipgloss.NewStyle(). - Foreground(lipgloss.Color(currentColor.ToHex())). - Bold(true) - - output += style.Render("█") - } - - // Show progress percentage - percentage := int(progress * 100) - fmt.Printf("\rTransitioning theme... %s %d%%", output, percentage) -} - -// clearLine clears the current terminal line. -func clearLine() { - fmt.Print("\r\033[K") -} - -// AnimateThemePreview shows an animated preview of a theme by cycling through its colors. -// This is useful for the `arc theme preview ` command. -// The animation loops subtly through the theme's color palette with smooth transitions. -// -// Example: -// -// theme := themes.Available()["rainbow"] -// AnimateThemePreview(&theme, 3*time.Second) -func AnimateThemePreview(theme *themes.Scheme, duration time.Duration) error { - // Validate theme has colors first (before animation check) - numColors := len(theme.BannerColors) - if numColors == 0 { - return fmt.Errorf("theme has no colors") - } - - if !ShouldAnimate() { - // Just print the theme name and colors - fmt.Printf("Theme: %s\n", theme.Name) - fmt.Printf("Description: %s\n", theme.Description) - return nil - } - - config := LoadConfig() - frameTime := time.Second / time.Duration(config.TargetFPS) - - // Create context for cancellation - ctx, cancel := context.WithTimeout(context.Background(), duration) - defer cancel() - - // Set up signal handler - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - defer signal.Stop(sigChan) - - // Hide cursor - fmt.Print("\033[?25l") - defer fmt.Print("\033[?25h") - - startTime := time.Now() - frame := 0 - - // Calculate cycle duration (3-5 seconds per full cycle) - cycleDuration := 4 * time.Second - framesPerCycle := int(cycleDuration / frameTime) - - for { - select { - case <-sigChan: - clearLine() - return fmt.Errorf("preview interrupted") - - case <-ctx.Done(): - clearLine() - return nil - - default: - // Calculate smooth progress through color palette - progress := float64(frame%framesPerCycle) / float64(framesPerCycle) - colorPos := progress * float64(numColors-1) - colorIndex1 := int(colorPos) % numColors - colorIndex2 := (colorIndex1 + 1) % numColors - colorBlend := colorPos - float64(int(colorPos)) - - // Interpolate between adjacent colors for smooth transitions - color1 := HexToColor(string(theme.BannerColors[colorIndex1])) - color2 := HexToColor(string(theme.BannerColors[colorIndex2])) - blendedColor := Interpolate(color1, color2, colorBlend) - - style := lipgloss.NewStyle(). - Foreground(lipgloss.Color(blendedColor.ToHex())). - Bold(true) - - // Create animated color bar that grows and shrinks - barSize := 5 + int(3*math.Sin(progress*2*math.Pi)) - colorBar := strings.Repeat("█", barSize) - - clearLine() - fmt.Printf("\r%s %s - %s", - style.Render(colorBar), - styles.PrimaryStyle.Render(theme.Name), - theme.Description) - - frame++ - - // Check if duration elapsed - if time.Since(startTime) >= duration { - clearLine() - return nil - } - - time.Sleep(frameTime) - } - } -} diff --git a/pkg/ui/animations/transition_test.go b/pkg/ui/animations/transition_test.go deleted file mode 100644 index 37526b0..0000000 --- a/pkg/ui/animations/transition_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package animations - -import ( - "os" - "testing" - "time" - - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -func TestAnimateThemeTransition_NoAnimation(t *testing.T) { - origNoAnimation := NoAnimation - defer func() { - NoAnimation = origNoAnimation - }() - NoAnimation = true - loader := themes.NewLoader() - fromYAML, _ := loader.Load("cyan-purple") - toYAML, _ := loader.Load("ocean") - fromTheme := fromYAML.ToScheme() - toTheme := toYAML.ToScheme() - err := AnimateThemeTransition(&fromTheme, &toTheme) - if err != nil { - t.Errorf("Expected no error when animations disabled, got %v", err) - } -} - -func TestAnimateThemePreview_NoAnimation(t *testing.T) { - origNoAnimation := NoAnimation - defer func() { - NoAnimation = origNoAnimation - }() - NoAnimation = true - loader := themes.NewLoader() - yamlTheme, _ := loader.Load("rainbow") - theme := yamlTheme.ToScheme() - err := AnimateThemePreview(&theme, 100*time.Millisecond) - if err != nil { - t.Errorf("Expected no error when animations disabled, got %v", err) - } -} - -func TestAnimateThemePreview_WithAnimation(t *testing.T) { - // Skip if not in a TTY - if os.Getenv("CI") != "" { - t.Skip("Skipping animation test in CI environment") - } - - origNoAnimation := NoAnimation - defer func() { - NoAnimation = origNoAnimation - }() - - NoAnimation = false - - loader := themes.NewLoader() - yamlTheme, _ := loader.Load("rainbow") - theme := yamlTheme.ToScheme() - - // Run animation for very short duration - err := AnimateThemePreview(&theme, 100*time.Millisecond) - if err != nil { - t.Logf("Animation completed with: %v", err) - } -} - -func TestAnimateThemePreview_EmptyColors(t *testing.T) { - origNoAnimation := NoAnimation - defer func() { - NoAnimation = origNoAnimation - }() - - NoAnimation = false - - // Create theme with no colors - emptyTheme := themes.Scheme{ - Name: "empty", - Description: "Empty theme", - BannerColors: []lipgloss.Color{}, - } - - err := AnimateThemePreview(&emptyTheme, 50*time.Millisecond) - if err == nil { - t.Error("Expected error for theme with no colors") - } -} - -func TestAnimateThemePreview_Cancellation(t *testing.T) { - // Skip if not in a TTY - if os.Getenv("CI") != "" { - t.Skip("Skipping animation test in CI environment") - } - - origNoAnimation := NoAnimation - defer func() { - NoAnimation = origNoAnimation - }() - - NoAnimation = false - - loader := themes.NewLoader() - yamlTheme, _ := loader.Load("rainbow") - theme := yamlTheme.ToScheme() - - // This should complete naturally after timeout - err := AnimateThemePreview(&theme, 50*time.Millisecond) - if err != nil && err.Error() != "preview interrupted" { - t.Logf("Animation ended: %v", err) - } -} - -func TestRenderTransitionFrame(t *testing.T) { - transitions := []struct { - name string - from lipgloss.Color - to lipgloss.Color - }{ - {"Primary", lipgloss.Color("#FF0000"), lipgloss.Color("#00FF00")}, - {"Secondary", lipgloss.Color("#0000FF"), lipgloss.Color("#FFFF00")}, - } - for _, progress := range []float64{0.0, 0.25, 0.5, 0.75, 1.0} { - renderTransitionFrame(transitions, progress) - } -} - -func TestClearLine(t *testing.T) { - clearLine() -} - -func BenchmarkAnimateThemeTransition(b *testing.B) { - origNoAnimation := NoAnimation - NoAnimation = true - defer func() { - NoAnimation = origNoAnimation - }() - loader := themes.NewLoader() - fromYAML, _ := loader.Load("cyan-purple") - toYAML, _ := loader.Load("ocean") - fromTheme := fromYAML.ToScheme() - toTheme := toYAML.ToScheme() - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = AnimateThemeTransition(&fromTheme, &toTheme) - } -} - -// BenchmarkThemePreviewFrame benchmarks single preview frame render -func BenchmarkThemePreviewFrame(b *testing.B) { - loader := themes.NewLoader() - yamlTheme, _ := loader.Load("rainbow") - theme := yamlTheme.ToScheme() - - b.ResetTimer() - for i := 0; i < b.N; i++ { - // Simulate frame calculation - progress := float64(i%100) / 100.0 - numColors := len(theme.BannerColors) - colorPos := progress * float64(numColors-1) - colorIndex1 := int(colorPos) % numColors - colorIndex2 := (colorIndex1 + 1) % numColors - colorBlend := colorPos - float64(int(colorPos)) - - color1 := HexToColor(string(theme.BannerColors[colorIndex1])) - color2 := HexToColor(string(theme.BannerColors[colorIndex2])) - _ = Interpolate(color1, color2, colorBlend) - } -} diff --git a/pkg/ui/component/badge.go b/pkg/ui/component/badge.go new file mode 100644 index 0000000..d5ae936 --- /dev/null +++ b/pkg/ui/component/badge.go @@ -0,0 +1,60 @@ +package component + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/arc-framework/arc-cli/pkg/ui/theme" +) + +// BadgeType defines the visual variant for a badge. +type BadgeType string + +const ( + BadgeTier BadgeType = "tier" + BadgeSuccess BadgeType = "success" + BadgeWarning BadgeType = "warning" + BadgeError BadgeType = "error" + BadgeInfo BadgeType = "info" +) + +// Badge renders a small labeled tag with a background color derived from its type. +func Badge(ctx *theme.Context, text string, kind BadgeType) string { + if ctx == nil || text == "" { + return text + } + + colorMap := map[BadgeType]string{ + BadgeTier: ctx.Theme().Colors.Primary, + BadgeSuccess: ctx.Theme().Colors.Success, + BadgeWarning: ctx.Theme().Colors.Warning, + BadgeError: ctx.Theme().Colors.Error, + BadgeInfo: ctx.Theme().Colors.Accent, + } + + bg, ok := colorMap[kind] + if !ok { + bg = ctx.Theme().Colors.Foreground + } + + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(ctx.Theme().Colors.Background)). + Background(lipgloss.Color(bg)). + Padding(0, 1). + Bold(true). + Render(strings.ToUpper(text)) +} + +// TierBadge renders a badge for a given tier level (0-based). +// Looks up the tier name from the active profile. +func TierBadge(ctx *theme.Context, tierLevel int) string { + if ctx == nil { + return "" + } + name := ctx.Profile().GetTier(tierLevel) + if name == "" { + name = "Unknown" + } + return Badge(ctx, name, BadgeTier) +} diff --git a/pkg/ui/component/card.go b/pkg/ui/component/card.go new file mode 100644 index 0000000..8825902 --- /dev/null +++ b/pkg/ui/component/card.go @@ -0,0 +1,50 @@ +package component + +import ( + "github.com/charmbracelet/lipgloss" + + "github.com/arc-framework/arc-cli/pkg/ui/theme" +) + +// Card renders a bordered container with an optional title and body content. +// Border style and padding adapt to the active skin settings. +func Card(ctx *theme.Context, title, content string) string { + if ctx == nil { + return content + } + + borderStyle := lipgloss.RoundedBorder() + switch ctx.Skin().Borders.Style { + case "square": + borderStyle = lipgloss.NormalBorder() + case "thick": + borderStyle = lipgloss.ThickBorder() + case "double": + borderStyle = lipgloss.DoubleBorder() + } + + pad := [2]int{1, 2} + switch ctx.Skin().Density { + case theme.DensityCompact: + pad = [2]int{0, 1} + case theme.DensitySpacious: + pad = [2]int{2, 3} + } + + cardStyle := lipgloss.NewStyle(). + Border(borderStyle). + BorderForeground(lipgloss.Color(ctx.Theme().Colors.Border)). + Padding(pad[0], pad[1]) + + if title == "" { + return cardStyle.Render(content) + } + + titleStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(ctx.Theme().Colors.Primary)). + Bold(true). + MarginBottom(1) + + body := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(title), content) + return cardStyle.Render(body) +} diff --git a/pkg/ui/component/const.go b/pkg/ui/component/const.go new file mode 100644 index 0000000..9ced8be --- /dev/null +++ b/pkg/ui/component/const.go @@ -0,0 +1,13 @@ +package component + +// versionUnknown is the placeholder string for an unset build version. +const versionUnknown = "unknown" + +// versionDev is the fallback version string displayed when the version is empty or unknown. +const versionDev = "dev" + +// iconError is the Unicode error icon rendered in error display components. +const iconError = "✗" + +// treeConnLast is the box-drawing connector used for the last child in a tree. +const treeConnLast = "└── " diff --git a/pkg/ui/component/controlbar.go b/pkg/ui/component/controlbar.go new file mode 100644 index 0000000..9fd290f --- /dev/null +++ b/pkg/ui/component/controlbar.go @@ -0,0 +1,48 @@ +package component + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/arc-framework/arc-cli/pkg/ui/theme" +) + +// Keybinding represents a single keyboard shortcut and its description. +type Keybinding struct { + Key string + Desc string +} + +// ControlBar renders a full-width bottom bar showing active keybindings. +// The top border spans the full terminal width (width). +func ControlBar(ctx *theme.Context, bindings []Keybinding, width int) string { + if ctx == nil || len(bindings) == 0 { + return "" + } + + keyStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(ctx.Theme().Colors.Accent)). + Bold(true) + + descStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(ctx.Theme().Colors.Foreground)) + + sepStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(ctx.Theme().Colors.Muted)) + + var parts []string + for _, b := range bindings { + parts = append(parts, keyStyle.Render(b.Key)+" "+descStyle.Render(b.Desc)) + } + + content := " " + strings.Join(parts, sepStyle.Render(" • ")) + + return lipgloss.NewStyle(). + Width(width). + BorderStyle(lipgloss.NormalBorder()). + BorderTop(true). + BorderForeground(lipgloss.Color(ctx.Theme().Colors.Border)). + Foreground(lipgloss.Color(ctx.Theme().Colors.Muted)). + Render(content) +} diff --git a/pkg/ui/component/error.go b/pkg/ui/component/error.go new file mode 100644 index 0000000..a098abd --- /dev/null +++ b/pkg/ui/component/error.go @@ -0,0 +1,89 @@ +package component + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" + + "github.com/arc-framework/arc-cli/pkg/ui/theme" +) + +// ErrorSeverity indicates how critical an error is. +type ErrorSeverity string + +const ( + SeverityInfo ErrorSeverity = "info" + SeverityWarning ErrorSeverity = "warning" + SeverityError ErrorSeverity = "error" + SeverityFatal ErrorSeverity = "fatal" +) + +// ErrorDisplay renders a full error box with context message, details, and optional stderr. +// Used for shell command failures and critical errors. +func ErrorDisplay(ctx *theme.Context, title string, err error, details string, severity ErrorSeverity) string { + if err == nil && title == "" { + return "" + } + + var borderColor, iconColor, icon string + switch severity { + case SeverityWarning: + borderColor = ctx.Theme().Colors.Warning + iconColor = ctx.Theme().Colors.Warning + icon = "⚠" + case SeverityInfo: + borderColor = ctx.Theme().Colors.Accent + iconColor = ctx.Theme().Colors.Accent + icon = "ℹ" + case SeverityFatal: + borderColor = ctx.Theme().Colors.Error + iconColor = ctx.Theme().Colors.Error + icon = iconError + default: // SeverityError + borderColor = ctx.Theme().Colors.Error + iconColor = ctx.Theme().Colors.Error + icon = iconError + } + + iconStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(iconColor)). + Bold(true) + + titleStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(ctx.Theme().Colors.Foreground)). + Bold(true) + + detailStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(ctx.Theme().Colors.Muted)) + + heading := iconStyle.Render(icon) + " " + titleStyle.Render(title) + + var body string + switch { + case err != nil && details != "": + body = fmt.Sprintf("%s\n\n%s", err.Error(), details) + case err != nil: + body = err.Error() + default: + body = details + } + + content := lipgloss.JoinVertical(lipgloss.Left, heading, detailStyle.Render(body)) + + return lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(borderColor)). + Padding(1, 2). + Render(content) +} + +// InlineError renders a compact single-line error message. +// Used for form validation and inline feedback. +func InlineError(ctx *theme.Context, msg string) string { + if ctx == nil || msg == "" { + return "" + } + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(ctx.Theme().Colors.Error)). + Render("✗ " + msg) +} diff --git a/pkg/ui/component/form.go b/pkg/ui/component/form.go new file mode 100644 index 0000000..785d40b --- /dev/null +++ b/pkg/ui/component/form.go @@ -0,0 +1,48 @@ +package component + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + + "github.com/arc-framework/arc-cli/pkg/ui/theme" +) + +// Form wraps charmbracelet/huh with theme-aware configuration. +// Use the underlying huh.NewForm() to build group/field structure, then +// wrap with NewForm() to apply theme and sizing. +type Form struct { + model *huh.Form + ctx *theme.Context +} + +// NewForm creates a themed form wrapper around a huh.Form. +func NewForm(ctx *theme.Context, form *huh.Form) Form { + if ctx != nil { + form = form.WithTheme(huh.ThemeCharm()) + } + return Form{model: form, ctx: ctx} +} + +// Run executes the form synchronously (blocking — only use outside TUI mode). +func (f Form) Run() error { + return f.model.Run() +} + +// Update handles bubbles messages. +func (f Form) Update(msg tea.Msg) (Form, tea.Cmd) { + m, cmd := f.model.Update(msg) + if updated, ok := m.(*huh.Form); ok { + f.model = updated + } + return f, cmd +} + +// View renders the current form state. +func (f Form) View() string { + return f.model.View() +} + +// State returns whether the form is completed, aborted, or still active. +func (f Form) State() huh.FormState { + return f.model.State +} diff --git a/pkg/ui/component/golden_test.go b/pkg/ui/component/golden_test.go new file mode 100644 index 0000000..546c970 --- /dev/null +++ b/pkg/ui/component/golden_test.go @@ -0,0 +1,115 @@ +package component_test + +// T046: Golden file tests for key components (4 components x 10 profiles = 40 tests). +// Spec: 018-ui-design, Phase 2 Theme Switching. +// +// Regenerate golden files: +// +// go test ./pkg/ui/component/... -update-golden + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "testing" + + bubblestable "github.com/charmbracelet/bubbles/table" + "github.com/stretchr/testify/require" + + "github.com/arc-framework/arc-cli/pkg/ui/component" + "github.com/arc-framework/arc-cli/pkg/ui/theme" +) + +var updateGoldenComp = flag.Bool("update-golden", false, "regenerate golden files instead of comparing") + +var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[mGKHFABCDJMs]`) + +func stripANSI(s string) string { return ansiRe.ReplaceAllString(s, "") } + +var componentProfiles = []string{ + "enterprise", "saiyan", "jedi", "pirate", "horcrux", + "pokemon", "shinobi", "triforce", "bending", "crystal", +} + +func componentGoldenPath(comp, profile string) string { + return filepath.Join("testdata", "golden", fmt.Sprintf("%s-%s.golden", comp, profile)) +} + +func compareOrUpdateComp(t *testing.T, path, actual string) { + t.Helper() + if *updateGoldenComp { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("create golden dir: %v", err) + } + if err := os.WriteFile(path, []byte(actual), 0o600); err != nil { + t.Fatalf("write golden %s: %v", path, err) + } + t.Logf("updated golden: %s", path) + return + } + expected, err := os.ReadFile(path) + if err != nil { + t.Fatalf("golden %s not found — run with -update-golden", path) + } + if string(expected) != actual { + t.Errorf("golden mismatch %s\n--- want ---\n%s\n--- got ---\n%s", path, string(expected), actual) + } +} + +func loadCtx(t *testing.T, profileID string) *theme.Context { + t.Helper() + loader, err := theme.NewLoader() + require.NoError(t, err) + ctx, err := loader.LoadContext(profileID, "arc") + require.NoError(t, err, "LoadContext(%s, arc)", profileID) + return ctx +} + +func TestHeader_Golden(t *testing.T) { + for _, p := range componentProfiles { + t.Run(p, func(t *testing.T) { + rendered := component.Header(loadCtx(t, p), 80) + compareOrUpdateComp(t, componentGoldenPath("header", p), stripANSI(rendered)) + }) + } +} + +func TestHero_Golden(t *testing.T) { + for _, p := range componentProfiles { + t.Run(p, func(t *testing.T) { + rendered := component.Hero(loadCtx(t, p)) + compareOrUpdateComp(t, componentGoldenPath("hero", p), stripANSI(rendered)) + }) + } +} + +func TestCard_Golden(t *testing.T) { + for _, p := range componentProfiles { + t.Run(p, func(t *testing.T) { + rendered := component.Card(loadCtx(t, p), "Status", "All systems operational\nServices: 4 running") + compareOrUpdateComp(t, componentGoldenPath("card", p), stripANSI(rendered)) + }) + } +} + +func TestTable_Golden(t *testing.T) { + cols := []bubblestable.Column{ + {Title: "Service", Width: 20}, + {Title: "Type", Width: 10}, + {Title: "Status", Width: 12}, + } + rows := []bubblestable.Row{ + {"postgres", "Data", "running"}, + {"redis", "Cache", "running"}, + {"mongodb", "Data", "stopped"}, + {"nginx", "Proxy", "running"}, + } + for _, p := range componentProfiles { + t.Run(p, func(t *testing.T) { + tbl := component.NewTable(loadCtx(t, p), cols, rows, 50, 6) + compareOrUpdateComp(t, componentGoldenPath("table", p), stripANSI(tbl.View())) + }) + } +} diff --git a/pkg/ui/component/header.go b/pkg/ui/component/header.go new file mode 100644 index 0000000..6098dda --- /dev/null +++ b/pkg/ui/component/header.go @@ -0,0 +1,69 @@ +package component + +import ( + "github.com/charmbracelet/lipgloss" + + "github.com/arc-framework/arc-cli/internal/branding" + "github.com/arc-framework/arc-cli/pkg/ui/theme" + "github.com/arc-framework/arc-cli/pkg/version" +) + +// brandArt is the compact 2-line ASCII logo rendered on the LEFT of the header. +const brandArt = "▀▌▛▘▛▘\n█▌▌ ▙▖" + +// Header renders a full-width top bar: +// +// (padding top) +// ▀▌▛▘▛▘ ·····spacer····· vdev-018-… +// █▌▌ ▙▖ Agentic Reasoning Core +// ───────────────────────────────────────────────────── +// +// Total height: 1 (paddingTop) + 2 (art rows) + 1 (borderBottom) = 4 rows. +func Header(ctx *theme.Context, width int) string { + if ctx == nil || width < 1 { + return "" + } + + logoStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(ctx.Theme().Colors.Primary)). + Bold(true). + PaddingLeft(1) + + verStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(ctx.Theme().Colors.Muted)). + Bold(true) + + taglineStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(ctx.Theme().Colors.Muted)) + + // LEFT: brand art + left := logoStyle.Render(brandArt) + leftW := lipgloss.Width(left) + panelH := lipgloss.Height(left) // 2 + + // RIGHT: version on line 1, tagline on line 2 + ver := version.Version + if ver == "" || ver == versionUnknown { + ver = versionDev + } + rightText := verStyle.Render(ver) + "\n" + taglineStyle.Render(branding.Tagline) + right := lipgloss.NewStyle().PaddingRight(1).Render(rightText) + rightW := lipgloss.Width(right) + + // Spacer fills the middle + gapW := width - leftW - rightW + if gapW < 0 { + gapW = 0 + } + spacer := lipgloss.NewStyle().Width(gapW).Height(panelH).Render("") + + row := lipgloss.JoinHorizontal(lipgloss.Top, left, spacer, right) + + return lipgloss.NewStyle(). + Width(width). + PaddingTop(1). + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + BorderForeground(lipgloss.Color(ctx.Theme().Colors.Border)). + Render(row) +} diff --git a/pkg/ui/component/hero.go b/pkg/ui/component/hero.go new file mode 100644 index 0000000..05a3d4b --- /dev/null +++ b/pkg/ui/component/hero.go @@ -0,0 +1,47 @@ +package component + +import ( + "github.com/charmbracelet/lipgloss" + + "github.com/arc-framework/arc-cli/internal/branding" + "github.com/arc-framework/arc-cli/pkg/ui/theme" +) + +// Hero renders the brand identity block for the Home view: +// the profile's ASCII logo art + the product tagline. +// +// Profile name, tier badge, and skin metadata are intentionally omitted — +// those belong in the Config/Profile views, not on the landing screen. +// The rounded border and primary color give it visual weight as the top-left anchor. +func Hero(ctx *theme.Context) string { + primary := lipgloss.Color("#00ADD8") // safe default + muted := lipgloss.Color("#6272A4") + + art := branding.Name // plain-text fallback + if ctx != nil { + primary = lipgloss.Color(ctx.Theme().Colors.Primary) + muted = lipgloss.Color(ctx.Theme().Colors.Muted) + if p := ctx.Profile(); p != nil && p.Logo != "" { + art = p.Logo + } + } + + logoStyle := lipgloss.NewStyle(). + Foreground(primary). + Padding(1, 2) + + taglineStyle := lipgloss.NewStyle(). + Foreground(muted). + Padding(0, 2) + + inner := lipgloss.JoinVertical(lipgloss.Left, + logoStyle.Render(art), + taglineStyle.Render(branding.Tagline), + ) + + borderColor := primary + return lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Render(inner) +} diff --git a/pkg/ui/component/list.go b/pkg/ui/component/list.go new file mode 100644 index 0000000..18f07e4 --- /dev/null +++ b/pkg/ui/component/list.go @@ -0,0 +1,66 @@ +package component + +import ( + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/arc-framework/arc-cli/pkg/ui/theme" +) + +// List wraps bubbles/list with theme-aware styling. +type List struct { + model list.Model + ctx *theme.Context +} + +// NewList creates a themed filterable list with the given items. +func NewList(ctx *theme.Context, items []list.Item, width, height int) List { + delegate := list.NewDefaultDelegate() + if ctx != nil { + t := ctx.Theme() + delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle. + Foreground(lipgloss.Color(t.Colors.Primary)). + BorderForeground(lipgloss.Color(t.Colors.Primary)) + delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc. + Foreground(lipgloss.Color(t.Colors.Secondary)). + BorderForeground(lipgloss.Color(t.Colors.Primary)) + delegate.Styles.NormalTitle = delegate.Styles.NormalTitle. + Foreground(lipgloss.Color(t.Colors.Foreground)) + delegate.Styles.NormalDesc = delegate.Styles.NormalDesc. + Foreground(lipgloss.Color(t.Colors.Muted)) + } + m := list.New(items, delegate, width, height) + if ctx != nil { + m.Styles.Title = m.Styles.Title. + Foreground(lipgloss.Color(ctx.Theme().Colors.Primary)). + Background(lipgloss.Color(ctx.Theme().Colors.Background)) + m.Styles.FilterPrompt = m.Styles.FilterPrompt. + Foreground(lipgloss.Color(ctx.Theme().Colors.Accent)) + m.Styles.FilterCursor = m.Styles.FilterCursor. + Foreground(lipgloss.Color(ctx.Theme().Colors.Accent)) + } + return List{model: m, ctx: ctx} +} + +// Update handles bubbles messages. +func (l List) Update(msg tea.Msg) (List, tea.Cmd) { + var cmd tea.Cmd + l.model, cmd = l.model.Update(msg) + return l, cmd +} + +// View renders the list. +func (l List) View() string { + return l.model.View() +} + +// SelectedItem returns the currently highlighted item. +func (l List) SelectedItem() list.Item { + return l.model.SelectedItem() +} + +// SetItems updates the list items. +func (l *List) SetItems(items []list.Item) tea.Cmd { + return l.model.SetItems(items) +} diff --git a/pkg/ui/component/logo.go b/pkg/ui/component/logo.go new file mode 100644 index 0000000..6945145 --- /dev/null +++ b/pkg/ui/component/logo.go @@ -0,0 +1,76 @@ +package component + +import ( + "github.com/charmbracelet/lipgloss" + + "github.com/arc-framework/arc-cli/internal/branding" + "github.com/arc-framework/arc-cli/pkg/ui/theme" + "github.com/arc-framework/arc-cli/pkg/version" +) + +// LogoMode controls how much description text is shown beneath the profile logo. +// +// The logo art itself always comes from the active profile (profile.Logo field). +// LogoMode only changes the one-liner description rendered below that art. +// +// - None: No logo block rendered. Use on most views (services, workspace, forms). +// - Compact: Profile logo art only, no description. Use in shell header strip. +// - Short: Profile logo art + tagline. Use on Home view, Init wizard. +// - Long: Profile logo art + tagline + version string. Use on About/Version view. +type LogoMode int + +const ( + LogoNone LogoMode = iota // No logo + LogoCompact // Logo art only — shell header + LogoShort // Logo art + tagline — Home, Init + LogoLong // Logo art + tagline + version — About, Version view +) + +// Logo renders the profile's ASCII logo art with optional description text. +// +// The art is sourced from ctx.Profile().Logo so it changes with the active +// profile. When ctx is nil or the profile has no logo, the function returns +// a plain branding.Name text fallback styled with the default primary color. +func Logo(ctx *theme.Context, mode LogoMode) string { + if mode == LogoNone { + return "" + } + + // Resolve colors: prefer theme, fall back to safe defaults. + primary := lipgloss.Color("#00ADD8") // default cyan + muted := lipgloss.Color("#6272A4") // default muted purple + if ctx != nil { + primary = lipgloss.Color(ctx.Theme().Colors.Primary) + muted = lipgloss.Color(ctx.Theme().Colors.Muted) + } + + artStyle := lipgloss.NewStyle().Foreground(primary) + descStyle := lipgloss.NewStyle().Foreground(muted) + + // Resolve the logo art from the profile; fall back to the brand name. + art := branding.Name + if ctx != nil && ctx.Profile() != nil && ctx.Profile().Logo != "" { + art = ctx.Profile().Logo + } + renderedArt := artStyle.Render(art) + + switch mode { + case LogoCompact: + return renderedArt + + case LogoShort: + desc := descStyle.Render(branding.Tagline) + return lipgloss.JoinVertical(lipgloss.Left, renderedArt, "", desc) + + case LogoLong: + ver := version.Version + if ver == "" || ver == versionUnknown { + ver = versionDev + } + desc := descStyle.Render(branding.Tagline + " • v" + ver) + return lipgloss.JoinVertical(lipgloss.Left, renderedArt, "", desc) + + default: + return "" + } +} diff --git a/pkg/ui/component/navigation.go b/pkg/ui/component/navigation.go new file mode 100644 index 0000000..0fe6de5 --- /dev/null +++ b/pkg/ui/component/navigation.go @@ -0,0 +1,122 @@ +package component + +import ( + "github.com/charmbracelet/lipgloss" + + "github.com/arc-framework/arc-cli/pkg/ui/theme" +) + +// NavTab represents a single tab entry in the navigation bar. +type NavTab struct { + Label string + Active bool + // Hidden marks this tab as non-navigable; it is excluded from the rendered bar. + Hidden bool +} + +// Navigation renders tabs or a sidebar based on the skin's navigation style. +// Tabs with Hidden=true are excluded from the rendered output. +// width is the terminal width so the navigation bar spans the full screen. +func Navigation(ctx *theme.Context, tabs []NavTab, width int) string { + if ctx == nil || len(tabs) == 0 { + return "" + } + + // Filter hidden tabs before rendering. + visible := make([]NavTab, 0, len(tabs)) + for _, t := range tabs { + if !t.Hidden { + visible = append(visible, t) + } + } + if len(visible) == 0 { + return "" + } + + skin := ctx.Skin() + if skin.Navigation.Style == "sidebar" { + return renderSidebar(ctx, visible, width) + } + return renderTabBar(ctx, visible, width) +} + +func renderTabBar(ctx *theme.Context, tabs []NavTab, width int) string { + // gh-dash inspired tab style: + // + // Home │ Services │ Workspace │ History │ Version │ Config + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // + // Active tab: filled primary background block (no border). + // Inactive tab: flat faint text (no border). + // Separator: │ between each tab. + // Container: ThickBorder bottom, Height(2), aligned Bottom. + + activeStyle := lipgloss.NewStyle(). + Background(lipgloss.Color(ctx.Theme().Colors.Primary)). + Foreground(lipgloss.Color(ctx.Theme().Colors.Background)). + Bold(true). + Padding(0, 2) + + inactiveStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(ctx.Theme().Colors.Muted)). + Faint(true). + Padding(0, 2) + + separatorStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(ctx.Theme().Colors.Border)) + + separator := separatorStyle.Render("│") + + var parts []string + for i, tab := range tabs { + if i > 0 { + parts = append(parts, separator) + } + if tab.Active { + parts = append(parts, activeStyle.Render(tab.Label)) + } else { + parts = append(parts, inactiveStyle.Render(tab.Label)) + } + } + + // JoinHorizontal(Bottom) aligns all parts to the bottom of the 2-row content area. + bar := lipgloss.JoinHorizontal(lipgloss.Bottom, parts...) + + // Container: full width + thick bottom border forms a bold tray line. + return lipgloss.NewStyle(). + Width(width). + BorderStyle(lipgloss.ThickBorder()). + BorderBottom(true). + BorderForeground(lipgloss.Color(ctx.Theme().Colors.Primary)). + Render(bar) +} + +func renderSidebar(ctx *theme.Context, tabs []NavTab, width int) string { //nolint:unparam // width is reserved for future dynamic sidebar sizing + activeStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(ctx.Theme().Colors.Background)). + Background(lipgloss.Color(ctx.Theme().Colors.Primary)). + Bold(true). + Padding(0, 2). + Width(20) + + inactiveStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(ctx.Theme().Colors.Foreground)). + Padding(0, 2). + Width(20) + + var rendered []string + for _, tab := range tabs { + if tab.Active { + rendered = append(rendered, activeStyle.Render(tab.Label)) + } else { + rendered = append(rendered, inactiveStyle.Render(tab.Label)) + } + } + + sidebar := lipgloss.JoinVertical(lipgloss.Left, rendered...) + return lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderRight(true). + BorderForeground(lipgloss.Color(ctx.Theme().Colors.Border)). + Render(sidebar) +} diff --git a/pkg/ui/component/panel.go b/pkg/ui/component/panel.go new file mode 100644 index 0000000..58d5c49 --- /dev/null +++ b/pkg/ui/component/panel.go @@ -0,0 +1,45 @@ +package component + +import ( + "github.com/charmbracelet/lipgloss" + + "github.com/arc-framework/arc-cli/pkg/ui/theme" +) + +// Panel arranges items either vertically (default) or horizontally. +// Vertical spacing adapts to the active skin density setting. +func Panel(ctx *theme.Context, items []string, horizontal bool) string { + if len(items) == 0 { + return "" + } + + if horizontal { + return lipgloss.JoinHorizontal(lipgloss.Left, items...) + } + + // Determine vertical gap from density + gap := 1 + if ctx != nil { + switch ctx.Skin().Density { + case theme.DensityCompact: + gap = 0 + case theme.DensitySpacious: + gap = 2 + } + } + + if gap == 0 { + return lipgloss.JoinVertical(lipgloss.Left, items...) + } + + gapStyle := lipgloss.NewStyle().MarginBottom(gap) + var stacked []string + for i, item := range items { + if i < len(items)-1 { + stacked = append(stacked, gapStyle.Render(item)) + } else { + stacked = append(stacked, item) + } + } + return lipgloss.JoinVertical(lipgloss.Left, stacked...) +} diff --git a/pkg/ui/component/progress.go b/pkg/ui/component/progress.go new file mode 100644 index 0000000..06ae2e9 --- /dev/null +++ b/pkg/ui/component/progress.go @@ -0,0 +1,54 @@ +package component + +import ( + "github.com/charmbracelet/bubbles/progress" + tea "github.com/charmbracelet/bubbletea" + + "github.com/arc-framework/arc-cli/pkg/ui/theme" +) + +// Progress wraps bubbles/progress with theme coloring. +type Progress struct { + model progress.Model + ctx *theme.Context +} + +// NewProgress creates a themed progress bar. +func NewProgress(ctx *theme.Context) Progress { + var opts []progress.Option + opts = append(opts, progress.WithDefaultGradient()) + if ctx != nil { + opts = append( + opts, + progress.WithGradient( + ctx.Theme().Colors.Secondary, + ctx.Theme().Colors.Primary, + ), + ) + } + return Progress{model: progress.New(opts...), ctx: ctx} +} + +// SetPercent updates the displayed progress value (0.0–1.0). +func (p *Progress) SetPercent(v float64) tea.Cmd { + return p.model.SetPercent(v) +} + +// IncrPercent increments progress by a delta (0.0–1.0). +func (p *Progress) IncrPercent(delta float64) tea.Cmd { + return p.model.IncrPercent(delta) +} + +// Update handles bubbles messages (e.g., animation frames). +func (p Progress) Update(msg tea.Msg) (Progress, tea.Cmd) { + m, cmd := p.model.Update(msg) + if pm, ok := m.(progress.Model); ok { + p.model = pm + } + return p, cmd +} + +// View renders the progress bar. +func (p Progress) View() string { + return p.model.View() +} diff --git a/pkg/ui/component/search.go b/pkg/ui/component/search.go new file mode 100644 index 0000000..ec172bc --- /dev/null +++ b/pkg/ui/component/search.go @@ -0,0 +1,60 @@ +package component + +import ( + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/arc-framework/arc-cli/pkg/ui/theme" +) + +// Search wraps bubbles/textinput as a themed search/filter input. +type Search struct { + model textinput.Model + ctx *theme.Context +} + +// NewSearch creates a themed text input for filtering. +func NewSearch(ctx *theme.Context, placeholder string) Search { + m := textinput.New() + m.Placeholder = placeholder + if ctx != nil { + t := ctx.Theme() + m.PromptStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Colors.Accent)) + m.TextStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Colors.Foreground)) + m.PlaceholderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Colors.Muted)) + m.Cursor.Style = lipgloss.NewStyle(). + Foreground(lipgloss.Color(t.Colors.Accent)) + } + return Search{model: m, ctx: ctx} +} + +// Focus activates the search input. +func (s *Search) Focus() tea.Cmd { + return s.model.Focus() +} + +// Blur deactivates the search input. +func (s *Search) Blur() { + s.model.Blur() +} + +// Value returns the current search text. +func (s Search) Value() string { + return s.model.Value() +} + +// Update handles bubbles messages. +func (s Search) Update(msg tea.Msg) (Search, tea.Cmd) { + var cmd tea.Cmd + s.model, cmd = s.model.Update(msg) + return s, cmd +} + +// View renders the search input. +func (s Search) View() string { + return s.model.View() +} diff --git a/pkg/ui/component/spinner.go b/pkg/ui/component/spinner.go new file mode 100644 index 0000000..643f933 --- /dev/null +++ b/pkg/ui/component/spinner.go @@ -0,0 +1,43 @@ +package component + +import ( + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/arc-framework/arc-cli/pkg/ui/theme" +) + +// Spinner wraps bubbles/spinner with theme coloring. +type Spinner struct { + model spinner.Model + ctx *theme.Context +} + +// NewSpinner creates a themed spinner with optional label. +func NewSpinner(ctx *theme.Context) Spinner { + m := spinner.New() + m.Spinner = spinner.Dot + if ctx != nil { + m.Style = lipgloss.NewStyle(). + Foreground(lipgloss.Color(ctx.Theme().Colors.Accent)) + } + return Spinner{model: m, ctx: ctx} +} + +// Tick returns the spinner tick command — must be returned from Init or Update. +func (s Spinner) Tick() tea.Cmd { + return s.model.Tick +} + +// Update handles the spinner tick message. +func (s Spinner) Update(msg tea.Msg) (Spinner, tea.Cmd) { + var cmd tea.Cmd + s.model, cmd = s.model.Update(msg) + return s, cmd +} + +// View renders the spinner frame. +func (s Spinner) View() string { + return s.model.View() +} diff --git a/pkg/ui/component/table.go b/pkg/ui/component/table.go new file mode 100644 index 0000000..3f98961 --- /dev/null +++ b/pkg/ui/component/table.go @@ -0,0 +1,65 @@ +package component + +import ( + bubblestable "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/arc-framework/arc-cli/pkg/ui/theme" +) + +// Table wraps bubbles/table with theme-aware styling. +type Table struct { + model bubblestable.Model + ctx *theme.Context +} + +// NewTable creates a new Table component with columns, rows, and dimensions. +func NewTable(ctx *theme.Context, cols []bubblestable.Column, rows []bubblestable.Row, width, height int) Table { + styles := bubblestable.DefaultStyles() + if ctx != nil { + styles.Header = styles.Header. + Foreground(lipgloss.Color(ctx.Theme().Colors.Primary)). + Bold(true). + BorderForeground(lipgloss.Color(ctx.Theme().Colors.Border)) + + styles.Selected = styles.Selected. + Foreground(lipgloss.Color(ctx.Theme().Colors.Background)). + Background(lipgloss.Color(ctx.Theme().Colors.Primary)) + + styles.Cell = styles.Cell. + Foreground(lipgloss.Color(ctx.Theme().Colors.Foreground)) + } + + t := bubblestable.New( + bubblestable.WithColumns(cols), + bubblestable.WithRows(rows), + bubblestable.WithWidth(width), + bubblestable.WithHeight(height), + bubblestable.WithFocused(true), + bubblestable.WithStyles(styles), + ) + return Table{model: t, ctx: ctx} +} + +// Update handles bubbles messages. +func (t Table) Update(msg tea.Msg) (Table, tea.Cmd) { + var cmd tea.Cmd + t.model, cmd = t.model.Update(msg) + return t, cmd +} + +// View renders the table. +func (t Table) View() string { + return t.model.View() +} + +// SelectedRow returns the currently highlighted row. +func (t Table) SelectedRow() bubblestable.Row { + return t.model.SelectedRow() +} + +// SetRows updates the table's data rows. +func (t *Table) SetRows(rows []bubblestable.Row) { + t.model.SetRows(rows) +} diff --git a/pkg/ui/component/testdata/golden/card-bending.golden b/pkg/ui/component/testdata/golden/card-bending.golden new file mode 100644 index 0000000..74363d6 --- /dev/null +++ b/pkg/ui/component/testdata/golden/card-bending.golden @@ -0,0 +1,8 @@ +╭───────────────────────────╮ +│ │ +│ Status │ +│ │ +│ All systems operational │ +│ Services: 4 running │ +│ │ +╰───────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/card-crystal.golden b/pkg/ui/component/testdata/golden/card-crystal.golden new file mode 100644 index 0000000..74363d6 --- /dev/null +++ b/pkg/ui/component/testdata/golden/card-crystal.golden @@ -0,0 +1,8 @@ +╭───────────────────────────╮ +│ │ +│ Status │ +│ │ +│ All systems operational │ +│ Services: 4 running │ +│ │ +╰───────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/card-enterprise.golden b/pkg/ui/component/testdata/golden/card-enterprise.golden new file mode 100644 index 0000000..74363d6 --- /dev/null +++ b/pkg/ui/component/testdata/golden/card-enterprise.golden @@ -0,0 +1,8 @@ +╭───────────────────────────╮ +│ │ +│ Status │ +│ │ +│ All systems operational │ +│ Services: 4 running │ +│ │ +╰───────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/card-horcrux.golden b/pkg/ui/component/testdata/golden/card-horcrux.golden new file mode 100644 index 0000000..74363d6 --- /dev/null +++ b/pkg/ui/component/testdata/golden/card-horcrux.golden @@ -0,0 +1,8 @@ +╭───────────────────────────╮ +│ │ +│ Status │ +│ │ +│ All systems operational │ +│ Services: 4 running │ +│ │ +╰───────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/card-jedi.golden b/pkg/ui/component/testdata/golden/card-jedi.golden new file mode 100644 index 0000000..74363d6 --- /dev/null +++ b/pkg/ui/component/testdata/golden/card-jedi.golden @@ -0,0 +1,8 @@ +╭───────────────────────────╮ +│ │ +│ Status │ +│ │ +│ All systems operational │ +│ Services: 4 running │ +│ │ +╰───────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/card-pirate.golden b/pkg/ui/component/testdata/golden/card-pirate.golden new file mode 100644 index 0000000..74363d6 --- /dev/null +++ b/pkg/ui/component/testdata/golden/card-pirate.golden @@ -0,0 +1,8 @@ +╭───────────────────────────╮ +│ │ +│ Status │ +│ │ +│ All systems operational │ +│ Services: 4 running │ +│ │ +╰───────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/card-pokemon.golden b/pkg/ui/component/testdata/golden/card-pokemon.golden new file mode 100644 index 0000000..74363d6 --- /dev/null +++ b/pkg/ui/component/testdata/golden/card-pokemon.golden @@ -0,0 +1,8 @@ +╭───────────────────────────╮ +│ │ +│ Status │ +│ │ +│ All systems operational │ +│ Services: 4 running │ +│ │ +╰───────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/card-saiyan.golden b/pkg/ui/component/testdata/golden/card-saiyan.golden new file mode 100644 index 0000000..74363d6 --- /dev/null +++ b/pkg/ui/component/testdata/golden/card-saiyan.golden @@ -0,0 +1,8 @@ +╭───────────────────────────╮ +│ │ +│ Status │ +│ │ +│ All systems operational │ +│ Services: 4 running │ +│ │ +╰───────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/card-shinobi.golden b/pkg/ui/component/testdata/golden/card-shinobi.golden new file mode 100644 index 0000000..74363d6 --- /dev/null +++ b/pkg/ui/component/testdata/golden/card-shinobi.golden @@ -0,0 +1,8 @@ +╭───────────────────────────╮ +│ │ +│ Status │ +│ │ +│ All systems operational │ +│ Services: 4 running │ +│ │ +╰───────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/card-triforce.golden b/pkg/ui/component/testdata/golden/card-triforce.golden new file mode 100644 index 0000000..74363d6 --- /dev/null +++ b/pkg/ui/component/testdata/golden/card-triforce.golden @@ -0,0 +1,8 @@ +╭───────────────────────────╮ +│ │ +│ Status │ +│ │ +│ All systems operational │ +│ Services: 4 running │ +│ │ +╰───────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/header-bending.golden b/pkg/ui/component/testdata/golden/header-bending.golden new file mode 100644 index 0000000..320379a --- /dev/null +++ b/pkg/ui/component/testdata/golden/header-bending.golden @@ -0,0 +1,4 @@ + + ▀▌▛▘▛▘ dev + █▌▌ ▙▖ Agentic Reasoning Core +──────────────────────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/header-crystal.golden b/pkg/ui/component/testdata/golden/header-crystal.golden new file mode 100644 index 0000000..320379a --- /dev/null +++ b/pkg/ui/component/testdata/golden/header-crystal.golden @@ -0,0 +1,4 @@ + + ▀▌▛▘▛▘ dev + █▌▌ ▙▖ Agentic Reasoning Core +──────────────────────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/header-enterprise.golden b/pkg/ui/component/testdata/golden/header-enterprise.golden new file mode 100644 index 0000000..320379a --- /dev/null +++ b/pkg/ui/component/testdata/golden/header-enterprise.golden @@ -0,0 +1,4 @@ + + ▀▌▛▘▛▘ dev + █▌▌ ▙▖ Agentic Reasoning Core +──────────────────────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/header-horcrux.golden b/pkg/ui/component/testdata/golden/header-horcrux.golden new file mode 100644 index 0000000..320379a --- /dev/null +++ b/pkg/ui/component/testdata/golden/header-horcrux.golden @@ -0,0 +1,4 @@ + + ▀▌▛▘▛▘ dev + █▌▌ ▙▖ Agentic Reasoning Core +──────────────────────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/header-jedi.golden b/pkg/ui/component/testdata/golden/header-jedi.golden new file mode 100644 index 0000000..320379a --- /dev/null +++ b/pkg/ui/component/testdata/golden/header-jedi.golden @@ -0,0 +1,4 @@ + + ▀▌▛▘▛▘ dev + █▌▌ ▙▖ Agentic Reasoning Core +──────────────────────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/header-pirate.golden b/pkg/ui/component/testdata/golden/header-pirate.golden new file mode 100644 index 0000000..320379a --- /dev/null +++ b/pkg/ui/component/testdata/golden/header-pirate.golden @@ -0,0 +1,4 @@ + + ▀▌▛▘▛▘ dev + █▌▌ ▙▖ Agentic Reasoning Core +──────────────────────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/header-pokemon.golden b/pkg/ui/component/testdata/golden/header-pokemon.golden new file mode 100644 index 0000000..320379a --- /dev/null +++ b/pkg/ui/component/testdata/golden/header-pokemon.golden @@ -0,0 +1,4 @@ + + ▀▌▛▘▛▘ dev + █▌▌ ▙▖ Agentic Reasoning Core +──────────────────────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/header-saiyan.golden b/pkg/ui/component/testdata/golden/header-saiyan.golden new file mode 100644 index 0000000..320379a --- /dev/null +++ b/pkg/ui/component/testdata/golden/header-saiyan.golden @@ -0,0 +1,4 @@ + + ▀▌▛▘▛▘ dev + █▌▌ ▙▖ Agentic Reasoning Core +──────────────────────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/header-shinobi.golden b/pkg/ui/component/testdata/golden/header-shinobi.golden new file mode 100644 index 0000000..320379a --- /dev/null +++ b/pkg/ui/component/testdata/golden/header-shinobi.golden @@ -0,0 +1,4 @@ + + ▀▌▛▘▛▘ dev + █▌▌ ▙▖ Agentic Reasoning Core +──────────────────────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/header-triforce.golden b/pkg/ui/component/testdata/golden/header-triforce.golden new file mode 100644 index 0000000..320379a --- /dev/null +++ b/pkg/ui/component/testdata/golden/header-triforce.golden @@ -0,0 +1,4 @@ + + ▀▌▛▘▛▘ dev + █▌▌ ▙▖ Agentic Reasoning Core +──────────────────────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/hero-bending.golden b/pkg/ui/component/testdata/golden/hero-bending.golden new file mode 100644 index 0000000..1874d08 --- /dev/null +++ b/pkg/ui/component/testdata/golden/hero-bending.golden @@ -0,0 +1,13 @@ +╭──────────────────────────╮ +│ │ +│ 🌊 🔥 🌍 💨 │ +│ │ +│ ▄▀█ █▀█ █▀▀ │ +│ █▀█ █▀▄ █▄▄ │ +│ │ +│ 🌊 🔥 🌍 💨 │ +│ Four Elements │ +│ │ +│ │ +│ Agentic Reasoning Core │ +╰──────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/hero-crystal.golden b/pkg/ui/component/testdata/golden/hero-crystal.golden new file mode 100644 index 0000000..290f13d --- /dev/null +++ b/pkg/ui/component/testdata/golden/hero-crystal.golden @@ -0,0 +1,13 @@ +╭──────────────────────────╮ +│ │ +│ ✦━━━━━━━━━━━━━━━✦ │ +│ │ +│ ▄▀█ █▀█ █▀▀ │ +│ █▀█ █▀▄ █▄▄ │ +│ │ +│ ✦━━━━━━━━━━━━━━━✦ │ +│ Crystal Core │ +│ │ +│ │ +│ Agentic Reasoning Core │ +╰──────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/hero-enterprise.golden b/pkg/ui/component/testdata/golden/hero-enterprise.golden new file mode 100644 index 0000000..8020715 --- /dev/null +++ b/pkg/ui/component/testdata/golden/hero-enterprise.golden @@ -0,0 +1,14 @@ +╭────────────────────────────╮ +│ │ +│ █████╗ ██████╗ ██████╗ │ +│ ██╔══██╗██╔══██╗██╔════╝ │ +│ ███████║██████╔╝██║ │ +│ ██╔══██║██╔══██╗██║ │ +│ ██║ ██║██║ ██║╚██████╗ │ +│ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ │ +│ │ +│ Agentic Reasoning Core │ +│ │ +│ │ +│ Agentic Reasoning Core │ +╰────────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/hero-horcrux.golden b/pkg/ui/component/testdata/golden/hero-horcrux.golden new file mode 100644 index 0000000..41aa8a2 --- /dev/null +++ b/pkg/ui/component/testdata/golden/hero-horcrux.golden @@ -0,0 +1,13 @@ +╭──────────────────────────╮ +│ │ +│ ═══════════════════ │ +│ ║ ║ │ +│ ║ ▄▀█ █▀█ █▀▀ ║ │ +│ ║ █▀█ █▀▄ █▄▄ ║ │ +│ ║ ║ │ +│ ║ ⚡ Hogwarts ⚡ ║ │ +│ ═══════════════════ │ +│ │ +│ │ +│ Agentic Reasoning Core │ +╰──────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/hero-jedi.golden b/pkg/ui/component/testdata/golden/hero-jedi.golden new file mode 100644 index 0000000..0d69a40 --- /dev/null +++ b/pkg/ui/component/testdata/golden/hero-jedi.golden @@ -0,0 +1,13 @@ +╭──────────────────────────╮ +│ │ +│ ╔════════════════════╗ │ +│ ║ ║ │ +│ ║ ▄▀█ █▀█ █▀▀ ║ │ +│ ║ █▀█ █▀▄ █▄▄ ║ │ +│ ║ ║ │ +│ ║ ═══⚔ Force ⚔═══ ║ │ +│ ╚════════════════════╝ │ +│ │ +│ │ +│ Agentic Reasoning Core │ +╰──────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/hero-pirate.golden b/pkg/ui/component/testdata/golden/hero-pirate.golden new file mode 100644 index 0000000..10c4628 --- /dev/null +++ b/pkg/ui/component/testdata/golden/hero-pirate.golden @@ -0,0 +1,13 @@ +╭──────────────────────────╮ +│ │ +│ ⚓━━━━━━━━━━━━━━━━⚓ │ +│ │ +│ ▄▀█ █▀█ █▀▀ │ +│ █▀█ █▀▄ █▄▄ │ +│ │ +│ ⚓━━━━━━━━━━━━━━━━⚓ │ +│ Grand Line │ +│ │ +│ │ +│ Agentic Reasoning Core │ +╰──────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/hero-pokemon.golden b/pkg/ui/component/testdata/golden/hero-pokemon.golden new file mode 100644 index 0000000..71a9403 --- /dev/null +++ b/pkg/ui/component/testdata/golden/hero-pokemon.golden @@ -0,0 +1,13 @@ +╭──────────────────────────╮ +│ │ +│ ╔════════════════╗ │ +│ ║ ║ │ +│ ║ ⚪ A.R.C. ⚪ ║ │ +│ ║ ║ │ +│ ║ ◉─◉─◉ ║ │ +│ ║ Evolution ║ │ +│ ╚════════════════╝ │ +│ │ +│ │ +│ Agentic Reasoning Core │ +╰──────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/hero-saiyan.golden b/pkg/ui/component/testdata/golden/hero-saiyan.golden new file mode 100644 index 0000000..257f0b6 --- /dev/null +++ b/pkg/ui/component/testdata/golden/hero-saiyan.golden @@ -0,0 +1,20 @@ +╭────────────────────────────────────╮ +│ │ +│ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ │ +│ │ +│ ______ _______ ______ │ +│ / \ / \ / \ │ +│ /$$$$$$ |$$$$$$$ |/$$$$$$ | │ +│ $$ |__$$ |$$ |__$$ |$$ | $$/ │ +│ $$ $$ |$$ $$< $$ | │ +│ $$$$$$$$ |$$$$$$$ |$$ | __ │ +│ $$ | $$ |$$ | $$ |$$ \__/ | │ +│ $$ | $$ |$$ | $$ | $$ $$/ │ +│ $$/ $$/ $$/ $$/ $$$$$$/ │ +│ │ +│ POWER ▰▰▰▰▰▰▰▰▰▰ MAX │ +│ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ │ +│ │ +│ │ +│ Agentic Reasoning Core │ +╰────────────────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/hero-shinobi.golden b/pkg/ui/component/testdata/golden/hero-shinobi.golden new file mode 100644 index 0000000..672db48 --- /dev/null +++ b/pkg/ui/component/testdata/golden/hero-shinobi.golden @@ -0,0 +1,14 @@ +╭─────────────────────────────────╮ +│ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ +│ ▓ ___ ____ ____ ▓ │ +│ ▓ /__/\ / ___\/ ___) ▓ 🥷 │ +│ ▓ \ __ \\___ \ \___ ▓ │ +│ ▓ /_/\_/\___/ \___/ ▓ │ +│ ▓ ▓ │ +│ ▓ [Hidden Leaf] ▓ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ +│ │ +│ │ +│ Agentic Reasoning Core │ +╰─────────────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/hero-triforce.golden b/pkg/ui/component/testdata/golden/hero-triforce.golden new file mode 100644 index 0000000..7e62b86 --- /dev/null +++ b/pkg/ui/component/testdata/golden/hero-triforce.golden @@ -0,0 +1,16 @@ +╭──────────────────────────╮ +│ │ +│ ▲ │ +│ ▲ ▲ │ +│ │ +│ ▄▀█ █▀█ █▀▀ │ +│ █▀█ █▀▄ █▄▄ │ +│ │ +│ ▲ ▲ ▲ │ +│ ▲ ▲ ▲ ▲ │ +│ ═══════════ │ +│ Hyrule Core │ +│ │ +│ │ +│ Agentic Reasoning Core │ +╰──────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/table-bending.golden b/pkg/ui/component/testdata/golden/table-bending.golden new file mode 100644 index 0000000..469d1ea --- /dev/null +++ b/pkg/ui/component/testdata/golden/table-bending.golden @@ -0,0 +1,6 @@ + Service Type Status + postgres Data running + redis Cache running + mongodb Data stopped + nginx Proxy running + \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/table-crystal.golden b/pkg/ui/component/testdata/golden/table-crystal.golden new file mode 100644 index 0000000..469d1ea --- /dev/null +++ b/pkg/ui/component/testdata/golden/table-crystal.golden @@ -0,0 +1,6 @@ + Service Type Status + postgres Data running + redis Cache running + mongodb Data stopped + nginx Proxy running + \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/table-enterprise.golden b/pkg/ui/component/testdata/golden/table-enterprise.golden new file mode 100644 index 0000000..469d1ea --- /dev/null +++ b/pkg/ui/component/testdata/golden/table-enterprise.golden @@ -0,0 +1,6 @@ + Service Type Status + postgres Data running + redis Cache running + mongodb Data stopped + nginx Proxy running + \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/table-horcrux.golden b/pkg/ui/component/testdata/golden/table-horcrux.golden new file mode 100644 index 0000000..469d1ea --- /dev/null +++ b/pkg/ui/component/testdata/golden/table-horcrux.golden @@ -0,0 +1,6 @@ + Service Type Status + postgres Data running + redis Cache running + mongodb Data stopped + nginx Proxy running + \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/table-jedi.golden b/pkg/ui/component/testdata/golden/table-jedi.golden new file mode 100644 index 0000000..469d1ea --- /dev/null +++ b/pkg/ui/component/testdata/golden/table-jedi.golden @@ -0,0 +1,6 @@ + Service Type Status + postgres Data running + redis Cache running + mongodb Data stopped + nginx Proxy running + \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/table-pirate.golden b/pkg/ui/component/testdata/golden/table-pirate.golden new file mode 100644 index 0000000..469d1ea --- /dev/null +++ b/pkg/ui/component/testdata/golden/table-pirate.golden @@ -0,0 +1,6 @@ + Service Type Status + postgres Data running + redis Cache running + mongodb Data stopped + nginx Proxy running + \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/table-pokemon.golden b/pkg/ui/component/testdata/golden/table-pokemon.golden new file mode 100644 index 0000000..469d1ea --- /dev/null +++ b/pkg/ui/component/testdata/golden/table-pokemon.golden @@ -0,0 +1,6 @@ + Service Type Status + postgres Data running + redis Cache running + mongodb Data stopped + nginx Proxy running + \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/table-saiyan.golden b/pkg/ui/component/testdata/golden/table-saiyan.golden new file mode 100644 index 0000000..469d1ea --- /dev/null +++ b/pkg/ui/component/testdata/golden/table-saiyan.golden @@ -0,0 +1,6 @@ + Service Type Status + postgres Data running + redis Cache running + mongodb Data stopped + nginx Proxy running + \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/table-shinobi.golden b/pkg/ui/component/testdata/golden/table-shinobi.golden new file mode 100644 index 0000000..469d1ea --- /dev/null +++ b/pkg/ui/component/testdata/golden/table-shinobi.golden @@ -0,0 +1,6 @@ + Service Type Status + postgres Data running + redis Cache running + mongodb Data stopped + nginx Proxy running + \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/table-triforce.golden b/pkg/ui/component/testdata/golden/table-triforce.golden new file mode 100644 index 0000000..469d1ea --- /dev/null +++ b/pkg/ui/component/testdata/golden/table-triforce.golden @@ -0,0 +1,6 @@ + Service Type Status + postgres Data running + redis Cache running + mongodb Data stopped + nginx Proxy running + \ No newline at end of file diff --git a/pkg/ui/component/tree.go b/pkg/ui/component/tree.go new file mode 100644 index 0000000..5c19519 --- /dev/null +++ b/pkg/ui/component/tree.go @@ -0,0 +1,89 @@ +package component + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/arc-framework/arc-cli/pkg/ui/theme" +) + +// TreeNode represents a node in a dependency tree. +type TreeNode struct { + Label string + Children []TreeNode + Status string // optional: "ok", "error", "warning" +} + +// Tree renders a dependency tree with themed connectors and status badges. +func Tree(ctx *theme.Context, root TreeNode) string { + if ctx == nil { + return renderTreeNode(nil, root, "", true) + } + return renderTreeNode(ctx, root, "", true) +} + +func renderTreeNode(ctx *theme.Context, node TreeNode, prefix string, isLast bool) string { + connector := "├── " + childPrefix := prefix + "│ " + if isLast { + connector = treeConnLast + childPrefix = prefix + " " + } + + label := node.Label + if ctx != nil && node.Status != "" { + var statusColor string + switch node.Status { + case "ok": + statusColor = ctx.Theme().Colors.Success + case "error": + statusColor = ctx.Theme().Colors.Error + case "warning": + statusColor = ctx.Theme().Colors.Warning + default: + statusColor = ctx.Theme().Colors.Muted + } + statusBadge := lipgloss.NewStyle(). + Foreground(lipgloss.Color(statusColor)). + Render(fmt.Sprintf("[%s]", node.Status)) + label = label + " " + statusBadge + } + + var connStyle, labelStyle lipgloss.Style + if ctx != nil { + connStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ctx.Theme().Colors.Muted)) + labelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ctx.Theme().Colors.Foreground)) + } else { + connStyle = lipgloss.NewStyle() + labelStyle = lipgloss.NewStyle() + } + + line := "" + if prefix == "" && connector == treeConnLast { + // Root node + line = labelStyle.Render(node.Label) + } else { + line = prefix + connStyle.Render(connector) + labelStyle.Render(label) + } + + if len(node.Children) == 0 { + return line + } + + var lines []string + if prefix == "" { + lines = []string{labelStyle.Render(node.Label)} + } else { + lines = []string{line} + } + + for i, child := range node.Children { + last := i == len(node.Children)-1 + childLine := renderTreeNode(ctx, child, childPrefix, last) + lines = append(lines, strings.Split(childLine, "\n")...) + } + + return strings.Join(lines, "\n") +} diff --git a/pkg/ui/component/viewport.go b/pkg/ui/component/viewport.go new file mode 100644 index 0000000..aaa3f84 --- /dev/null +++ b/pkg/ui/component/viewport.go @@ -0,0 +1,53 @@ +package component + +import ( + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/arc-framework/arc-cli/pkg/ui/theme" +) + +// Viewport wraps bubbles/viewport for scrollable content with theme styling. +type Viewport struct { + model viewport.Model + ctx *theme.Context +} + +// NewViewport creates a themed scrollable viewport. +func NewViewport(ctx *theme.Context, width, height int) Viewport { + m := viewport.New(width, height) + if ctx != nil { + m.Style = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color(ctx.Theme().Colors.Border)) + } + return Viewport{model: m, ctx: ctx} +} + +// SetContent sets the scrollable content. +func (v *Viewport) SetContent(s string) { + v.model.SetContent(s) +} + +// GotoTop scrolls to the top. +func (v *Viewport) GotoTop() { + v.model.GotoTop() +} + +// GotoBottom scrolls to the bottom. +func (v *Viewport) GotoBottom() { + v.model.GotoBottom() +} + +// Update handles bubbles messages. +func (v Viewport) Update(msg tea.Msg) (Viewport, tea.Cmd) { + var cmd tea.Cmd + v.model, cmd = v.model.Update(msg) + return v, cmd +} + +// View renders the viewport. +func (v Viewport) View() string { + return v.model.View() +} diff --git a/pkg/ui/components/README.md b/pkg/ui/components/README.md deleted file mode 100644 index 58b6340..0000000 --- a/pkg/ui/components/README.md +++ /dev/null @@ -1,267 +0,0 @@ -# UI Component Library - -This package contains reusable UI components for the ARC CLI's TUI views, built on top of -[Bubble Tea](https://github.com/charmbracelet/bubbletea) and -[Lip Gloss](https://github.com/charmbracelet/lipgloss). All components are profile-aware and -accept a `*themes.Theme` for consistent styling across the 10 built-in ARC profiles. - -## Component Catalog - -### Sub-packages (017-ui-engine) - -| Package | Description | -|---|---| -| `hero/` | Branding banner that renders the active profile's ASCII logo | -| `sidebar/` | Vertical navigation menu with keyboard navigation | -| `table/` | Sortable, interactive data table | -| `search/` | Real-time text input with filtering callback | -| `status/` | Footer bar that displays keybindings and context messages | -| `badge/` | Small colored status indicator (Info / Success / Warning / Error) | -| `breadcrumb/` | Navigation path renderer (e.g., Home › Services › Postgres) | -| `progress/` | Percentage-based progress bar with theme gradient | -| `splitpane/` | Horizontal or vertical two-panel layout container | -| `tree/` | Hierarchical tree view with expand/collapse | -| `wizard/` | Multi-step guided form component | - -### Top-level files (pre-017 components) - -| File | Description | -|---|---| -| `card.go` | Bordered content card | -| `card_grid.go` | Responsive grid of cards | -| `error.go` | Styled error message renderer | -| `footer.go` | Legacy footer bar | -| `header.go` | Legacy header bar | -| `logo.go` | ASCII logo renderer (legacy) | -| `panel.go` | Bordered content panel | -| `progress.go` | Legacy progress bar (see `progress/` for new version) | -| `safeborder.go` | Border renderer that uses `lipgloss.Width` instead of `len` | -| `section_header.go` | Section divider with title | -| `spinner.go` | Animated loading indicator | -| `split_pane.go` | Legacy split-pane layout (see `splitpane/` for new version) | -| `status_rail.go` | Horizontal status indicator strip | -| `tab_bar.go` | Horizontal tab selector | -| `toast.go` | Transient notification overlay | -| `animator.go` | Frame-based animation helper | - ---- - -## Usage Examples - -### Hero — `pkg/ui/components/hero/` - -The Hero component renders the active profile's ASCII logo, centered and colored using the -theme's primary color. - -```go -import ( - "github.com/arc-framework/arc-cli/pkg/ui/components/hero" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// Create from profile + theme (typically sourced from ViewContext) -h := hero.NewHero(profile, theme) - -// Render full logo centered in 120 columns -banner := h.Render(120) - -// Render compact (profile name only) for narrow layouts -compact := h.RenderCompact(80) - -// Swap profile dynamically (e.g., after user changes profile) -h.SetProfile(newProfile) -h.SetTheme(newTheme) -``` - -The `Render` method outputs the profile's multi-line ASCII logo styled with -`lipgloss.Color(profile.PrimaryColor)` and center-aligned within `width` columns. -`RenderCompact` outputs only the profile name — useful when vertical space is limited. - ---- - -### Sidebar — `pkg/ui/components/sidebar/` - -The Sidebar is a scrollable vertical navigation menu. It implements `tea.Model` so it can -receive Bubble Tea messages directly. - -```go -import "github.com/arc-framework/arc-cli/pkg/ui/components/sidebar" - -// Define menu items -items := []sidebar.MenuItem{ - {ID: "dashboard", Label: "Dashboard", Icon: "🏠"}, - {ID: "services", Label: "Services", Icon: "📡"}, - {ID: "workspace", Label: "Workspace", Icon: "📁"}, - {ID: "config", Label: "Config", Icon: "⚙️"}, -} - -// Or use the standard dashboard preset -items = sidebar.DashboardMenuItems() - -// Create and configure -nav := sidebar.NewSidebar(items, theme) -nav.SetWidth(25) - -// In a Bubble Tea Update method, delegate key messages: -var cmd tea.Cmd -nav, cmd = nav.Update(msg).(sidebar.Sidebar), cmd // j/k navigate, g/G jump - -// Read selection -selected := nav.SelectedItem() // sidebar.MenuItem{ID: "services", Label: "Services", ...} -index := nav.SelectedIndex() - -// Render -view := nav.View() -``` - -Keyboard shortcuts handled internally: `j`/`↓` (next), `k`/`↑` (previous), `g` (first), -`G` (last). - ---- - -### DataTable — `pkg/ui/components/table/` - -An interactive, sortable table backed by `charmbracelet/bubbles/table`. - -```go -import "github.com/arc-framework/arc-cli/pkg/ui/components/table" - -// Define columns -columns := []table.Column{ - {Title: "Name", Width: 20}, - {Title: "Status", Width: 10}, - {Title: "Port", Width: 8}, -} - -// Define rows (each row is []string) -rows := []table.Row{ - {"postgres", "running", "5432"}, - {"redis", "stopped", "6379"}, - {"mongodb", "running", "27017"}, -} - -// Create table -dt := table.NewDataTable(columns, rows, theme) -dt.SetWidth(80) -dt.SetHeight(15) - -// In Bubble Tea Update, delegate all messages: -var cmd tea.Cmd -dt, cmd = dt.Update(msg).(*table.DataTable), cmd - -// Sort programmatically (0-indexed column) -dt.Sort(0) // sort by Name ascending; call again to toggle direction - -// Update data -dt.SetRows(newRows) - -// Read current selection -selected := dt.SelectedRow() // table.Row or nil -cursor := dt.Cursor() // int (0-indexed) - -// Render -view := dt.View() -``` - -Built-in keyboard shortcuts: `j`/`↑` (up), `k`/`↓` (down), `s` (sort by current column), -`1`–`9` (sort by column number). - ---- - -### SearchBar — `pkg/ui/components/search/` - -A text input component with a real-time filtering callback. - -```go -import ( - "strings" - "github.com/arc-framework/arc-cli/pkg/ui/components/search" - "github.com/arc-framework/arc-cli/pkg/ui/components/table" -) - -// Create with placeholder text -sb := search.NewSearchBar(theme, "Search services...") -sb.SetWidth(40) - -// Register a callback — called on every keystroke -sb.SetOnChange(func(query string) { - filtered := make([]table.Row, 0) - for _, row := range allRows { - if strings.Contains(strings.ToLower(row[0]), strings.ToLower(query)) { - filtered = append(filtered, row) - } - } - dt.SetRows(filtered) -}) - -// For expensive filters, debounce the callback -sb.SetDebounce(300 * time.Millisecond) - -// In Bubble Tea Init, return the blink command -func (v *MyView) Init() tea.Cmd { - return sb.Init() // starts cursor blink -} - -// In Bubble Tea Update, delegate messages -var cmd tea.Cmd -sb, cmd = sb.Update(msg).(*search.SearchBar), cmd - -// Programmatic access -value := sb.Value() -matches := sb.Filter("postgres") // true if current query is a substring of "postgres" -sb.Clear() - -// Render -view := sb.View() -``` - -Built-in keyboard shortcuts: type to search, `Esc` to clear, `Ctrl+K` to focus. - ---- - -### StatusBar — `pkg/ui/components/status/` - -A footer renderer that formats keybindings (left) and an optional message (right) into a -fixed-width line using theme muted colors. It does not implement `tea.Model` — call `Render` -directly in your view's `View()` method. - -```go -import ( - "github.com/arc-framework/arc-cli/pkg/ui/components/status" - "github.com/arc-framework/arc-cli/pkg/ui/engine" -) - -// Create once per view -bar := status.NewStatusBar(theme) - -// In View(): -keybindings := []engine.KeyBinding{ - {Key: "q", Description: "quit"}, - {Key: "↑/↓", Description: "navigate"}, - {Key: "enter", Description: "select"}, - {Key: "/", Description: "search"}, -} -footer := bar.Render(width, keybindings, "3 services") -// Output (width=80): -// "q: quit • ↑/↓: navigate • enter: select • /: search 3 services" - -// Update theme dynamically -bar.SetTheme(newTheme) -``` - -`Render` automatically truncates keybindings when the terminal is too narrow, preserving as -many complete bindings as possible and appending `...`. - ---- - -## Notes - -- All components use `lipgloss.Width()` (not `len()`) for string measurement to correctly - handle ANSI escape sequences — see `safeborder.go` for the canonical helper. -- Components that implement `tea.Model` (`Sidebar`, `DataTable`, `SearchBar`) can be embedded - in a parent view and updated via `Update(msg)`. -- `StatusBar` is intentionally stateless — pass current keybindings and message on every - `View()` call. -- To create a component without a theme (e.g. in tests), pass `nil` for `*themes.Theme`; - components fall back to neutral default styles. diff --git a/pkg/ui/components/animator.go b/pkg/ui/components/animator.go deleted file mode 100644 index b6713d6..0000000 --- a/pkg/ui/components/animator.go +++ /dev/null @@ -1,181 +0,0 @@ -// Package components provides reusable UI components with animation support. -package components - -import ( - "math" - "time" -) - -// AnimationConfig configures animation behavior. -type AnimationConfig struct { - // From is the starting value (0.0-1.0). - From float64 - - // To is the ending value (0.0-1.0). - To float64 - - // Duration is the maximum animation duration. - Duration time.Duration - - // Damping is the spring damping coefficient (0.1-2.0). - Damping float64 - - // Stiffness is the spring stiffness coefficient (1.0-30.0). - Stiffness float64 - - // OnComplete is called when animation finishes. - OnComplete func() -} - -// Animator provides spring-based animation capabilities. -type Animator interface { - // Start begins the animation with the given configuration. - Start(config AnimationConfig) error - - // Update advances the animation by one frame, returns current value (0.0-1.0). - Update() float64 - - // IsFinished returns true when animation completes. - IsFinished() bool - - // Cancel stops the animation immediately. - Cancel() - - // Progress returns completion percentage (0.0-1.0). - Progress() float64 -} - -// lerpAnimator implements Animator using LERP with easing functions. -type lerpAnimator struct { - config AnimationConfig - startTime time.Time - currentVal float64 - target float64 - started bool - canceled bool -} - -// NewAnimator creates a new LERP-based animator. -func NewAnimator() Animator { - return &lerpAnimator{} -} - -// Start begins the animation. -func (a *lerpAnimator) Start(config AnimationConfig) error { - // Set defaults - if config.Duration == 0 { - config.Duration = 300 * time.Millisecond - } - if config.Damping == 0 { - config.Damping = 1.0 - } - if config.Stiffness == 0 { - config.Stiffness = 10.0 - } - - a.config = config - a.startTime = time.Now() - a.currentVal = config.From - a.target = config.To - a.started = true - a.canceled = false - - return nil -} - -// Update advances the animation by one frame. -func (a *lerpAnimator) Update() float64 { - if !a.started || a.canceled { - return a.currentVal - } - - // Calculate linear progress based on elapsed time - elapsed := time.Since(a.startTime) - if elapsed >= a.config.Duration { - a.currentVal = a.config.To - a.started = false - if a.config.OnComplete != nil { - a.config.OnComplete() - } - return a.currentVal - } - - // Linear progress [0, 1] - progress := float64(elapsed) / float64(a.config.Duration) - - // Apply ease-out for natural deceleration (like spring settling) - smoothProgress := easeOut(progress) - - // Interpolate between from and to - a.currentVal = lerp(a.config.From, a.config.To, smoothProgress) - - return a.currentVal -} - -// lerp performs linear interpolation between start and end values. -func lerp(start, end, t float64) float64 { - if t < 0 { - t = 0 - } - if t > 1 { - t = 1 - } - return start + (end-start)*t -} - -// easeOut applies a smooth ease-out curve using a cubic function. -func easeOut(t float64) float64 { - if t < 0 { - t = 0 - } - if t > 1 { - t = 1 - } - return 1 - math.Pow(1-t, 3) -} - -// IsFinished returns true when animation completes. -func (a *lerpAnimator) IsFinished() bool { - return !a.started || a.canceled -} - -// Cancel stops the animation immediately. -func (a *lerpAnimator) Cancel() { - a.canceled = true - a.started = false -} - -// Progress returns completion percentage. -func (a *lerpAnimator) Progress() float64 { - if !a.started { - return 1.0 - } - - elapsed := time.Since(a.startTime) - if elapsed >= a.config.Duration { - return 1.0 - } - - return float64(elapsed) / float64(a.config.Duration) -} - -// ShouldSkipAnimation determines if animation should be skipped for fast operations. -func ShouldSkipAnimation(operationDuration time.Duration) bool { - return operationDuration < 200*time.Millisecond -} - -// TargetFPS is the desired frame rate for animations. -const TargetFPS = 60 - -// FrameDuration is the target duration per frame at 60fps. -var FrameDuration = time.Second / TargetFPS - -// InterpolateFloat linearly interpolates between two float values. -func InterpolateFloat(from, to, progress float64) float64 { - return from + (to-from)*progress -} - -// InterpolateInt linearly interpolates between two int values. -func InterpolateInt(from, to int, progress float64) int { - return from + int(float64(to-from)*progress) -} diff --git a/pkg/ui/components/animator_test.go b/pkg/ui/components/animator_test.go deleted file mode 100644 index 4d0b29a..0000000 --- a/pkg/ui/components/animator_test.go +++ /dev/null @@ -1,310 +0,0 @@ -package components - -import ( - "testing" - "time" -) - -func TestNewAnimator(t *testing.T) { - animator := NewAnimator() - if animator == nil { - t.Fatal("NewAnimator() returned nil") - } -} - -func TestAnimator_Start(t *testing.T) { - tests := []struct { - name string - config AnimationConfig - hasErr bool - }{ - { - name: "valid config", - config: AnimationConfig{ - From: 0.0, - To: 1.0, - Duration: 300 * time.Millisecond, - Damping: 1.0, - Stiffness: 10.0, - }, - hasErr: false, - }, - { - name: "default values", - config: AnimationConfig{ - From: 0.0, - To: 1.0, - }, - hasErr: false, - }, - { - name: "custom spring parameters", - config: AnimationConfig{ - From: 0.5, - To: 0.8, - Duration: 500 * time.Millisecond, - Damping: 0.8, - Stiffness: 15.0, - }, - hasErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - animator := NewAnimator() - err := animator.Start(tt.config) - if (err != nil) != tt.hasErr { - t.Errorf("Start() error = %v, hasErr %v", err, tt.hasErr) - } - }) - } -} - -func TestAnimator_Update(t *testing.T) { - animator := NewAnimator() - config := AnimationConfig{ - From: 0.0, - To: 1.0, - Duration: 100 * time.Millisecond, - Damping: 1.0, - Stiffness: 10.0, - } - - err := animator.Start(config) - if err != nil { - t.Fatalf("Failed to start animator: %v", err) - } - - // Update should return values progressing from From to To - firstValue := animator.Update() - if firstValue < 0.0 || firstValue > 1.0 { - t.Errorf("Update() returned value out of range: %v", firstValue) - } - - // Multiple updates should show progression - for i := 0; i < 10 && !animator.IsFinished(); i++ { - val := animator.Update() - if val < 0.0 || val > 1.1 { // Allow slight overshoot for spring - t.Errorf("Update() iteration %d returned value out of range: %v", i, val) - } - time.Sleep(10 * time.Millisecond) - } -} - -func TestAnimator_IsFinished(t *testing.T) { - animator := NewAnimator() - - // Should not be finished before starting - if !animator.IsFinished() { - t.Error("IsFinished() returned false before starting (should be true for uninitialized)") - } - - config := AnimationConfig{ - From: 0.0, - To: 1.0, - Duration: 50 * time.Millisecond, - Damping: 1.0, - Stiffness: 10.0, - } - - err := animator.Start(config) - if err != nil { - t.Fatalf("Failed to start animator: %v", err) - } - - // Run animation to completion - timeout := time.After(500 * time.Millisecond) - ticker := time.NewTicker(5 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-timeout: - t.Fatal("Animation did not finish within timeout") - case <-ticker.C: - animator.Update() - if animator.IsFinished() { - return - } - } - } -} - -func TestAnimator_Cancel(t *testing.T) { - animator := NewAnimator() - config := AnimationConfig{ - From: 0.0, - To: 1.0, - Duration: 1 * time.Second, - Damping: 1.0, - Stiffness: 10.0, - } - - err := animator.Start(config) - if err != nil { - t.Fatalf("Failed to start animator: %v", err) - } - - animator.Update() - animator.Cancel() - - if !animator.IsFinished() { - t.Error("IsFinished() should return true after Cancel()") - } -} - -func TestAnimator_Progress(t *testing.T) { - animator := NewAnimator() - config := AnimationConfig{ - From: 0.0, - To: 1.0, - Duration: 100 * time.Millisecond, - Damping: 1.0, - Stiffness: 10.0, - } - - err := animator.Start(config) - if err != nil { - t.Fatalf("Failed to start animator: %v", err) - } - - // Initial progress should be 0 - progress := animator.Progress() - if progress < 0.0 || progress > 0.1 { - t.Errorf("Initial Progress() = %v, want near 0.0", progress) - } - - // Wait and check progress increased - time.Sleep(50 * time.Millisecond) - animator.Update() - progress = animator.Progress() - if progress < 0.3 || progress > 0.7 { - t.Errorf("Mid Progress() = %v, want near 0.5", progress) - } -} - -func TestAnimator_OnComplete(t *testing.T) { - completed := false - animator := NewAnimator() - config := AnimationConfig{ - From: 0.0, - To: 1.0, - Duration: 50 * time.Millisecond, - Damping: 1.0, - Stiffness: 10.0, - OnComplete: func() { - completed = true - }, - } - - err := animator.Start(config) - if err != nil { - t.Fatalf("Failed to start animator: %v", err) - } - - // Run animation to completion - timeout := time.After(200 * time.Millisecond) - ticker := time.NewTicker(10 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-timeout: - t.Fatal("Animation did not complete within timeout") - case <-ticker.C: - animator.Update() - if completed { - return - } - } - } -} - -func TestShouldSkipAnimation(t *testing.T) { - tests := []struct { - name string - duration time.Duration - want bool - }{ - {"very fast", 50 * time.Millisecond, true}, - {"fast", 100 * time.Millisecond, true}, - {"at threshold", 200 * time.Millisecond, false}, - {"slow", 300 * time.Millisecond, false}, - {"very slow", 1 * time.Second, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ShouldSkipAnimation(tt.duration) - if got != tt.want { - t.Errorf("ShouldSkipAnimation(%v) = %v, want %v", tt.duration, got, tt.want) - } - }) - } -} - -func TestInterpolateFloat(t *testing.T) { - tests := []struct { - name string - from float64 - to float64 - progress float64 - want float64 - }{ - {"start", 0.0, 1.0, 0.0, 0.0}, - {"middle", 0.0, 1.0, 0.5, 0.5}, - {"end", 0.0, 1.0, 1.0, 1.0}, - {"custom range start", 10.0, 20.0, 0.0, 10.0}, - {"custom range middle", 10.0, 20.0, 0.5, 15.0}, - {"custom range end", 10.0, 20.0, 1.0, 20.0}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := InterpolateFloat(tt.from, tt.to, tt.progress) - if got != tt.want { - t.Errorf("InterpolateFloat(%v, %v, %v) = %v, want %v", - tt.from, tt.to, tt.progress, got, tt.want) - } - }) - } -} - -func TestInterpolateInt(t *testing.T) { - tests := []struct { - name string - from int - to int - progress float64 - want int - }{ - {"start", 0, 100, 0.0, 0}, - {"middle", 0, 100, 0.5, 50}, - {"end", 0, 100, 1.0, 100}, - {"custom range", 10, 20, 0.5, 15}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := InterpolateInt(tt.from, tt.to, tt.progress) - if got != tt.want { - t.Errorf("InterpolateInt(%v, %v, %v) = %v, want %v", - tt.from, tt.to, tt.progress, got, tt.want) - } - }) - } -} - -func TestFrameDuration(t *testing.T) { - expectedDuration := time.Second / 60 - if FrameDuration != expectedDuration { - t.Errorf("FrameDuration = %v, want %v", FrameDuration, expectedDuration) - } -} - -func TestTargetFPS(t *testing.T) { - if TargetFPS != 60 { - t.Errorf("TargetFPS = %v, want 60", TargetFPS) - } -} diff --git a/pkg/ui/components/badge/.gitkeep b/pkg/ui/components/badge/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/pkg/ui/components/badge/badge.go b/pkg/ui/components/badge/badge.go deleted file mode 100644 index cf8de7f..0000000 --- a/pkg/ui/components/badge/badge.go +++ /dev/null @@ -1,114 +0,0 @@ -package badge - -import ( - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// BadgeStyle defines the visual style of a badge. -type BadgeStyle int - -const ( - // Info style uses the theme's info color (typically blue). - Info BadgeStyle = iota - // Success style uses the theme's success color (typically green). - Success - // Warning style uses the theme's warning color (typically yellow/orange). - Warning - // Error style uses the theme's error color (typically red). - Error -) - -// Badge displays small status indicators with colored backgrounds. -// Used throughout views for visual indicators like counts, statuses, and labels. -// -// Design: 017-ui-engine Phase 3 (Badge Component) -type Badge struct { - text string - style BadgeStyle - theme *themes.Theme - icon string // Optional icon to display before text -} - -// NewBadge creates a new Badge with the given text, style, and theme. -// The badge displays text with a colored background in a pill-shaped container. -func NewBadge(text string, style BadgeStyle, theme *themes.Theme) *Badge { - return &Badge{ - text: text, - style: style, - theme: theme, - icon: "", - } -} - -// Render returns the badge as a styled string. -// The badge is rendered with a colored background and foreground in a pill shape. -func (b *Badge) Render() string { - if b.theme == nil { - return b.text - } - - // Get colors based on style - bgColor, fgColor := b.getColors() - - // Build the content with optional icon - content := b.text - if b.icon != "" { - content = b.icon + " " + b.text - } - - // Create pill-shaped badge with rounded borders and padding - style := lipgloss.NewStyle(). - Background(bgColor). - Foreground(fgColor). - Padding(0, 1). - Bold(true) - - return style.Render(content) -} - -// SetText updates the badge text. -func (b *Badge) SetText(text string) { - b.text = text -} - -// SetStyle updates the badge style. -func (b *Badge) SetStyle(style BadgeStyle) { - b.style = style -} - -// SetTheme updates the badge theme. -func (b *Badge) SetTheme(theme *themes.Theme) { - b.theme = theme -} - -// SetIcon sets an optional icon to display before the badge text. -// Pass an empty string to remove the icon. -func (b *Badge) SetIcon(icon string) { - b.icon = icon -} - -// getColors returns the background and foreground colors based on the badge style. -// Uses the theme's semantic colors (Success, Error, Warning, Info). -func (b *Badge) getColors() (bg, fg lipgloss.Color) { - // Default foreground is white/light for good contrast - fg = lipgloss.Color("#FFFFFF") - - switch b.style { - case Success: - bg = b.theme.Colors.SuccessColor() - case Error: - bg = b.theme.Colors.ErrorColor() - case Warning: - bg = b.theme.Colors.WarningColor() - // Warning badges often have dark text for better contrast on yellow/orange - fg = lipgloss.Color("#000000") - case Info: - bg = b.theme.Colors.InfoColor() - default: - bg = b.theme.Colors.InfoColor() - } - - return bg, fg -} diff --git a/pkg/ui/components/badge/badge_test.go b/pkg/ui/components/badge/badge_test.go deleted file mode 100644 index a556eb1..0000000 --- a/pkg/ui/components/badge/badge_test.go +++ /dev/null @@ -1,562 +0,0 @@ -package badge - -import ( - "strings" - "testing" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// mockTheme returns a test theme with basic colors. -func mockTheme() *themes.Theme { - return &themes.Theme{ - Name: "Test Theme", - Description: "A test theme", - Version: "1.0.0", - Colors: themes.ColorSet{ - Primary: "#4A90E2", - Secondary: "#7B68EE", - Success: "#28a745", - Error: "#dc3545", - Warning: "#ffc107", - Info: "#17a2b8", - }, - } -} - -func TestNewBadge(t *testing.T) { - theme := mockTheme() - badge := NewBadge("Test", Info, theme) - - if badge == nil { - t.Fatal("NewBadge returned nil") - } - - if badge.text != "Test" { - t.Errorf("expected text 'Test', got %q", badge.text) - } - - if badge.style != Info { - t.Errorf("expected style Info, got %v", badge.style) - } - - if badge.theme != theme { - t.Errorf("expected theme %v, got %v", theme, badge.theme) - } - - if badge.icon != "" { - t.Errorf("expected empty icon, got %q", badge.icon) - } -} - -func TestBadge_Render(t *testing.T) { - tests := []struct { - name string - text string - style BadgeStyle - theme *themes.Theme - validate func(t *testing.T, output string) - }{ - { - name: "renders info badge", - text: "Info", - style: Info, - theme: mockTheme(), - validate: func(t *testing.T, output string) { - if output == "" { - t.Error("expected non-empty output") - } - if !strings.Contains(output, "Info") { - t.Error("output should contain badge text 'Info'") - } - }, - }, - { - name: "renders success badge", - text: "Success", - style: Success, - theme: mockTheme(), - validate: func(t *testing.T, output string) { - if output == "" { - t.Error("expected non-empty output") - } - if !strings.Contains(output, "Success") { - t.Error("output should contain badge text 'Success'") - } - }, - }, - { - name: "renders warning badge", - text: "Warning", - style: Warning, - theme: mockTheme(), - validate: func(t *testing.T, output string) { - if output == "" { - t.Error("expected non-empty output") - } - if !strings.Contains(output, "Warning") { - t.Error("output should contain badge text 'Warning'") - } - }, - }, - { - name: "renders error badge", - text: "Error", - style: Error, - theme: mockTheme(), - validate: func(t *testing.T, output string) { - if output == "" { - t.Error("expected non-empty output") - } - if !strings.Contains(output, "Error") { - t.Error("output should contain badge text 'Error'") - } - }, - }, - { - name: "renders count badge", - text: "42", - style: Info, - theme: mockTheme(), - validate: func(t *testing.T, output string) { - if output == "" { - t.Error("expected non-empty output") - } - if !strings.Contains(output, "42") { - t.Error("output should contain badge text '42'") - } - }, - }, - { - name: "returns plain text for nil theme", - text: "Test", - style: Info, - theme: nil, - validate: func(t *testing.T, output string) { - if output != "Test" { - t.Errorf("expected plain text 'Test' for nil theme, got %q", output) - } - }, - }, - { - name: "renders empty text", - text: "", - style: Info, - theme: mockTheme(), - validate: func(t *testing.T, output string) { - // Should render successfully even with empty text - // (may contain ANSI codes for styling) - if output == "" { - t.Error("expected some output even for empty text (ANSI codes)") - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - badge := NewBadge(tt.text, tt.style, tt.theme) - output := badge.Render() - tt.validate(t, output) - }) - } -} - -func TestBadge_SetText(t *testing.T) { - badge := NewBadge("Initial", Info, mockTheme()) - - badge.SetText("Updated") - - if badge.text != "Updated" { - t.Errorf("expected text 'Updated', got %q", badge.text) - } - - // Verify new text is used in rendering - output := badge.Render() - if !strings.Contains(output, "Updated") { - t.Error("SetText did not affect rendering") - } -} - -func TestBadge_SetStyle(t *testing.T) { - badge := NewBadge("Test", Info, mockTheme()) - - // Test changing to each style - styles := []BadgeStyle{Success, Warning, Error, Info} - for _, style := range styles { - badge.SetStyle(style) - - if badge.style != style { - t.Errorf("expected style %v, got %v", style, badge.style) - } - - // Verify rendering doesn't panic with new style - output := badge.Render() - if output == "" { - t.Errorf("expected non-empty output for style %v", style) - } - } -} - -func TestBadge_SetTheme(t *testing.T) { - badge := NewBadge("Test", Info, mockTheme()) - - newTheme := &themes.Theme{ - Name: "New Theme", - Colors: themes.ColorSet{ - Info: "#00FF00", - }, - } - - badge.SetTheme(newTheme) - - if badge.theme != newTheme { - t.Errorf("SetTheme did not update theme: got %v, want %v", badge.theme, newTheme) - } - - // Verify rendering works with new theme - output := badge.Render() - if output == "" { - t.Error("expected non-empty output after SetTheme") - } -} - -func TestBadge_SetIcon(t *testing.T) { - badge := NewBadge("Test", Info, mockTheme()) - - // Initially no icon - if badge.icon != "" { - t.Errorf("expected empty icon initially, got %q", badge.icon) - } - - // Set an icon - badge.SetIcon("✓") - - if badge.icon != "✓" { - t.Errorf("expected icon '✓', got %q", badge.icon) - } - - // Verify icon appears in rendering - output := badge.Render() - if !strings.Contains(output, "✓") { - t.Error("SetIcon did not affect rendering - icon not found in output") - } - if !strings.Contains(output, "Test") { - t.Error("SetIcon removed badge text from output") - } - - // Remove icon - badge.SetIcon("") - - if badge.icon != "" { - t.Errorf("expected empty icon after removal, got %q", badge.icon) - } - - // Verify icon is removed from rendering - output = badge.Render() - if !strings.Contains(output, "Test") { - t.Error("badge text should still be present after removing icon") - } -} - -func TestBadge_GetColors(t *testing.T) { - tests := []struct { - name string - style BadgeStyle - expectBgColor string - expectFgColor string - description string - }{ - { - name: "info style uses info color", - style: Info, - expectBgColor: "#17a2b8", - expectFgColor: "#FFFFFF", - description: "Info badges should have white text on info background", - }, - { - name: "success style uses success color", - style: Success, - expectBgColor: "#28a745", - expectFgColor: "#FFFFFF", - description: "Success badges should have white text on success background", - }, - { - name: "warning style uses warning color with dark text", - style: Warning, - expectBgColor: "#ffc107", - expectFgColor: "#000000", - description: "Warning badges should have dark text on warning background for contrast", - }, - { - name: "error style uses error color", - style: Error, - expectBgColor: "#dc3545", - expectFgColor: "#FFFFFF", - description: "Error badges should have white text on error background", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - theme := mockTheme() - badge := NewBadge("Test", tt.style, theme) - - bg, fg := badge.getColors() - - if string(bg) != tt.expectBgColor { - t.Errorf("%s: expected bg color %s, got %s", tt.description, tt.expectBgColor, bg) - } - - if string(fg) != tt.expectFgColor { - t.Errorf("%s: expected fg color %s, got %s", tt.description, tt.expectFgColor, fg) - } - }) - } -} - -func TestBadge_AllStyles(t *testing.T) { - // Comprehensive test ensuring all styles render without panic - theme := mockTheme() - styles := []BadgeStyle{Info, Success, Warning, Error} - - for _, style := range styles { - t.Run(string(rune(style)), func(t *testing.T) { - badge := NewBadge("Test", style, theme) - output := badge.Render() - - if output == "" { - t.Errorf("expected non-empty output for style %v", style) - } - - if !strings.Contains(output, "Test") { - t.Errorf("output should contain badge text for style %v", style) - } - }) - } -} - -func TestBadge_WithIcon(t *testing.T) { - tests := []struct { - name string - text string - icon string - style BadgeStyle - validate func(t *testing.T, output string) - }{ - { - name: "renders with checkmark icon", - text: "Complete", - icon: "✓", - style: Success, - validate: func(t *testing.T, output string) { - if !strings.Contains(output, "✓") { - t.Error("output should contain checkmark icon") - } - if !strings.Contains(output, "Complete") { - t.Error("output should contain badge text") - } - }, - }, - { - name: "renders with warning icon", - text: "Alert", - icon: "⚠", - style: Warning, - validate: func(t *testing.T, output string) { - if !strings.Contains(output, "⚠") { - t.Error("output should contain warning icon") - } - if !strings.Contains(output, "Alert") { - t.Error("output should contain badge text") - } - }, - }, - { - name: "renders with error icon", - text: "Failed", - icon: "✗", - style: Error, - validate: func(t *testing.T, output string) { - if !strings.Contains(output, "✗") { - t.Error("output should contain error icon") - } - if !strings.Contains(output, "Failed") { - t.Error("output should contain badge text") - } - }, - }, - { - name: "renders with info icon", - text: "Note", - icon: "ℹ", - style: Info, - validate: func(t *testing.T, output string) { - if !strings.Contains(output, "ℹ") { - t.Error("output should contain info icon") - } - if !strings.Contains(output, "Note") { - t.Error("output should contain badge text") - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - badge := NewBadge(tt.text, tt.style, mockTheme()) - badge.SetIcon(tt.icon) - output := badge.Render() - tt.validate(t, output) - }) - } -} - -func TestBadge_DynamicUpdates(t *testing.T) { - // Test that badge can be updated dynamically and still render correctly - badge := NewBadge("Initial", Info, mockTheme()) - - // Initial state - output := badge.Render() - if !strings.Contains(output, "Initial") { - t.Error("initial rendering failed") - } - - // Update text - badge.SetText("Updated") - output = badge.Render() - if !strings.Contains(output, "Updated") { - t.Error("text update failed") - } - - // Update style - badge.SetStyle(Success) - output = badge.Render() - if !strings.Contains(output, "Updated") { - t.Error("style update affected text incorrectly") - } - - // Add icon - badge.SetIcon("✓") - output = badge.Render() - if !strings.Contains(output, "✓") || !strings.Contains(output, "Updated") { - t.Error("icon addition failed") - } - - // Update theme - newTheme := &themes.Theme{ - Colors: themes.ColorSet{ - Success: "#00FF00", - }, - } - badge.SetTheme(newTheme) - output = badge.Render() - if !strings.Contains(output, "✓") || !strings.Contains(output, "Updated") { - t.Error("theme update affected content incorrectly") - } -} - -func TestBadge_EdgeCases(t *testing.T) { - tests := []struct { - name string - text string - style BadgeStyle - theme *themes.Theme - validate func(t *testing.T, badge *Badge) - }{ - { - name: "very long text", - text: "This is a very long badge text that should still render correctly", - style: Info, - theme: mockTheme(), - validate: func(t *testing.T, badge *Badge) { - output := badge.Render() - if !strings.Contains(output, "very long badge text") { - t.Error("long text not rendered correctly") - } - }, - }, - { - name: "special characters in text", - text: "Test & Special ", - style: Info, - theme: mockTheme(), - validate: func(t *testing.T, badge *Badge) { - output := badge.Render() - if !strings.Contains(output, "Test") { - t.Error("special characters affected text rendering") - } - }, - }, - { - name: "unicode emoji in text", - text: "🎉 Party", - style: Success, - theme: mockTheme(), - validate: func(t *testing.T, badge *Badge) { - output := badge.Render() - if !strings.Contains(output, "Party") { - t.Error("unicode emoji affected text rendering") - } - }, - }, - { - name: "numeric only text", - text: "123456", - style: Info, - theme: mockTheme(), - validate: func(t *testing.T, badge *Badge) { - output := badge.Render() - if !strings.Contains(output, "123456") { - t.Error("numeric text not rendered correctly") - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - badge := NewBadge(tt.text, tt.style, tt.theme) - tt.validate(t, badge) - }) - } -} - -func TestBadge_StyleCoverage(t *testing.T) { - // Ensure all BadgeStyle enum values are tested - theme := mockTheme() - - // Test all defined styles - allStyles := []struct { - style BadgeStyle - name string - }{ - {Info, "Info"}, - {Success, "Success"}, - {Warning, "Warning"}, - {Error, "Error"}, - } - - for _, s := range allStyles { - t.Run(s.name, func(t *testing.T) { - badge := NewBadge(s.name, s.style, theme) - - // Verify style is set - if badge.style != s.style { - t.Errorf("expected style %v, got %v", s.style, badge.style) - } - - // Verify it renders - output := badge.Render() - if output == "" { - t.Errorf("style %s produced empty output", s.name) - } - - // Verify colors are retrieved without panic - bg, fg := badge.getColors() - if bg == "" || fg == "" { - t.Errorf("style %s produced empty colors: bg=%s, fg=%s", s.name, bg, fg) - } - }) - } -} diff --git a/pkg/ui/components/border_rendering_test.go b/pkg/ui/components/border_rendering_test.go deleted file mode 100644 index 9b5a2e7..0000000 --- a/pkg/ui/components/border_rendering_test.go +++ /dev/null @@ -1,232 +0,0 @@ -package components - -import ( - "strings" - "testing" - - "github.com/charmbracelet/lipgloss" -) - -// TestLipglossWidthVsLenANSI verifies that lipgloss.Width correctly measures -// ANSI-styled strings. In a TTY environment, len() would overcount due to ANSI -// escape codes; lipgloss.Width() always returns the correct visual column count. -// This is the core issue documented in the project memory — using len() for -// visual width calculations causes border misalignment in TTY environments. -func TestLipglossWidthVsLenANSI(t *testing.T) { - t.Parallel() - - plainText := "Hello" - styledText := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#00ADD8")). - Bold(true). - Render(plainText) - - visualWidth := lipgloss.Width(styledText) - - // lipgloss.Width must always return the correct visual column count, - // regardless of ANSI code presence (TTY vs non-TTY). - if visualWidth != len(plainText) { - t.Errorf("lipgloss.Width(%q) = %d, want %d (visual columns)", styledText, visualWidth, len(plainText)) - } - - // In TTY environments, byte length will exceed visual width due to ANSI codes. - // In non-TTY (test) environments, ANSI codes are stripped, so len() == visualWidth. - // Either way, lipgloss.Width() gives the correct answer for layout math. - byteLen := len(styledText) - t.Logf("visual width = %d, byte length = %d (ANSI active: %v)", - visualWidth, byteLen, byteLen > len(plainText)) -} - -// TestLipglossWidthUnicode verifies that lipgloss.Width correctly counts -// visual columns for multi-byte Unicode characters. -func TestLipglossWidthUnicode(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - wantWidth int - }{ - { - name: "ASCII only", - input: "Hello", - wantWidth: 5, - }, - { - name: "single emoji", - input: "🏠", - wantWidth: 2, // Emoji are 2 columns wide - }, - { - name: "emoji with label", - input: "🏠 Dashboard", - wantWidth: 12, // 2 (emoji) + 1 (space) + 9 (Dashboard) - }, - { - name: "CJK character", - input: "中", - wantWidth: 2, // CJK characters are 2 columns wide - }, - { - name: "right-angle quotation mark (breadcrumb separator)", - input: " › ", - wantWidth: 3, // space + › + space - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - got := lipgloss.Width(tc.input) - if got != tc.wantWidth { - t.Errorf("lipgloss.Width(%q) = %d, want %d", tc.input, got, tc.wantWidth) - } - }) - } -} - -// TestUnicodeByteLengthDiffersFromVisualWidth verifies that for multi-byte -// characters, len() != lipgloss.Width(). This documents why len() must NOT -// be used for visual layout calculations. -func TestUnicodeByteLengthDiffersFromVisualWidth(t *testing.T) { - t.Parallel() - - multiByteStrings := []struct { - name string - input string - }{ - {"emoji", "🏠"}, - {"CJK", "中"}, - {"breadcrumb separator", " › "}, - } - - for _, tc := range multiByteStrings { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - byteLen := len(tc.input) - visualWidth := lipgloss.Width(tc.input) - - // For multi-byte strings, byte count MUST differ from visual column count - if byteLen == visualWidth { - t.Errorf("%q: len()=%d equals lipgloss.Width()=%d — this string should have multi-byte chars", - tc.input, byteLen, visualWidth) - } - t.Logf("%q: len()=%d, lipgloss.Width()=%d (diff=%d bytes per visual col on average)", - tc.input, byteLen, visualWidth, byteLen-visualWidth) - }) - } -} - -// TestBorderWrappedStringWidth verifies that a border-wrapped string has the -// correct visual width, which requires lipgloss.Width — not len(). -func TestBorderWrappedStringWidth(t *testing.T) { - t.Parallel() - - content := "Hello World" - desiredWidth := 20 - - bordered := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - Width(desiredWidth). - Render(content) - - // Split into lines since a bordered box is multi-line - lines := strings.Split(bordered, "\n") - if len(lines) < 3 { - t.Fatalf("expected at least 3 lines (top border, content, bottom border), got %d", len(lines)) - } - - // All non-empty lines should have the same visual width - // (desiredWidth + 2 for the border characters on each side) - wantLineWidth := desiredWidth + 2 - for i, line := range lines { - if line == "" { - continue - } - got := lipgloss.Width(line) - if got != wantLineWidth { - t.Errorf("line %d: lipgloss.Width(%q) = %d, want %d", i, line, got, wantLineWidth) - } - } -} - -// TestANSIColoredBorderMeasurement verifies that a colored border string is -// measured correctly using lipgloss.Width, matching the known visual width. -func TestANSIColoredBorderMeasurement(t *testing.T) { - t.Parallel() - - content := "Error: connection refused" - desiredWidth := 40 - - // This mimics the pattern in error.go / panel.go where a colored border is used - colored := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#FF4444")). - Width(desiredWidth). - Render(content) - - lines := strings.Split(colored, "\n") - if len(lines) < 3 { - t.Fatalf("expected at least 3 lines (top border, content, bottom border), got %d", len(lines)) - } - - // The top border line (line 0) may be ANSI-colored in TTY environments - topBorder := lines[0] - visualW := lipgloss.Width(topBorder) - - // Visual width should be desiredWidth + 2 (borders on both sides) - wantVisualWidth := desiredWidth + 2 - if visualW != wantVisualWidth { - t.Errorf("lipgloss.Width(topBorder) = %d, want %d", visualW, wantVisualWidth) - } - - // Log whether ANSI codes are active in this test environment - byteLen := len(topBorder) - t.Logf("top border: visual width=%d, byte length=%d (ANSI active: %v)", - visualW, byteLen, byteLen > visualW) -} - -// TestSafeWidthForLayoutCalculations is a regression test for the specific -// bug pattern documented in the project: using len() for width layout math -// causes misalignment when ANSI escape sequences are present. -func TestSafeWidthForLayoutCalculations(t *testing.T) { - t.Parallel() - - // Simulate building a line: [styled label][padding][styled value] - // where total should = terminalWidth - terminalWidth := 60 - label := lipgloss.NewStyle().Foreground(lipgloss.Color("#00ADD8")).Bold(true).Render("Service") - value := lipgloss.NewStyle().Foreground(lipgloss.Color("#50FA7B")).Render("running") - - labelW := lipgloss.Width(label) - valueW := lipgloss.Width(value) - paddingNeeded := terminalWidth - labelW - valueW - - if paddingNeeded < 0 { - t.Fatalf("test setup error: label + value wider than terminal (%d + %d > %d)", - labelW, valueW, terminalWidth) - } - - line := label + strings.Repeat(" ", paddingNeeded) + value - lineVisualWidth := lipgloss.Width(line) - - // The line built using lipgloss.Width for measurement must equal terminalWidth - if lineVisualWidth != terminalWidth { - t.Errorf("constructed line visual width = %d, want %d", lineVisualWidth, terminalWidth) - } - - // In a TTY environment, using len() would give wrong (negative) padding. - // Log this for diagnostic purposes without failing (since tests run without TTY). - labelByteLen := len(label) - valueByteLen := len(value) - wrongPadding := terminalWidth - labelByteLen - valueByteLen - if wrongPadding < 0 { - t.Logf("confirmed TTY env: len() overcounts by %d bytes — using len() would yield negative padding (layout bug)", - -wrongPadding) - } else { - t.Logf("non-TTY env: len() and lipgloss.Width() agree (padding=%d), ANSI codes stripped by renderer", - wrongPadding) - } -} diff --git a/pkg/ui/components/breadcrumb/.gitkeep b/pkg/ui/components/breadcrumb/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/pkg/ui/components/breadcrumb/breadcrumb.go b/pkg/ui/components/breadcrumb/breadcrumb.go deleted file mode 100644 index e59575f..0000000 --- a/pkg/ui/components/breadcrumb/breadcrumb.go +++ /dev/null @@ -1,233 +0,0 @@ -package breadcrumb - -import ( - "strings" - - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// Breadcrumb displays a navigation path (e.g., Home > Services > Postgres). -// Used in detail views to provide context about the current location. -// -// Design: 017-ui-engine Phase 3 (Navigation Components) -// Requirements: Display path items with separator, highlight current item, theme-aware colors -type Breadcrumb struct { - items []string - currentIndex int - theme *themes.Theme - separator string -} - -// NewBreadcrumb creates a new Breadcrumb component with the given items and theme. -// The last item is considered the current item by default. -// -// Example: -// -// items := []string{"Home", "Services", "Postgres"} -// breadcrumb := NewBreadcrumb(items, theme) -// rendered := breadcrumb.Render(80) -func NewBreadcrumb(items []string, theme *themes.Theme) *Breadcrumb { - currentIndex := len(items) - 1 - if currentIndex < 0 { - currentIndex = 0 - } - - return &Breadcrumb{ - items: items, - currentIndex: currentIndex, - theme: theme, - separator: " › ", // Using right-pointing angle quotation mark - } -} - -// Render returns the breadcrumb as a styled string, truncating if necessary to fit width. -// Path items are separated by the separator, with the current item highlighted. -// Muted color is used for non-current items, primary color for the current item. -func (b *Breadcrumb) Render(width int) string { - if len(b.items) == 0 { - return "" - } - - // Validate theme - if b.theme == nil { - b.theme = getDefaultTheme() - } - - // Define styles - mutedStyle := lipgloss.NewStyle(). - Foreground(b.theme.Colors.MutedColor()) - - currentStyle := lipgloss.NewStyle(). - Foreground(b.theme.Colors.PrimaryColor()). - Bold(true) - - // Build breadcrumb parts - parts := make([]string, 0, len(b.items)*2-1) - for i, item := range b.items { - var styledItem string - if i == b.currentIndex { - styledItem = currentStyle.Render(item) - } else { - styledItem = mutedStyle.Render(item) - } - - parts = append(parts, styledItem) - - // Add separator if not the last item - if i < len(b.items)-1 { - parts = append(parts, mutedStyle.Render(b.separator)) - } - } - - // Join all parts - rendered := strings.Join(parts, "") - - // Truncate if necessary - if lipgloss.Width(rendered) > width { - return b.renderTruncated(width, &mutedStyle, ¤tStyle) - } - - return rendered -} - -// truncateToVisualWidth truncates a plain-text string to fit within maxWidth visual columns. -// Uses lipgloss.Width for accurate measurement of multi-byte and emoji characters. -func truncateToVisualWidth(s string, maxWidth int) string { - if lipgloss.Width(s) <= maxWidth { - return s - } - runes := []rune(s) - for len(runes) > 0 && lipgloss.Width(string(runes)) > maxWidth { - runes = runes[:len(runes)-1] - } - return string(runes) -} - -// renderTruncated renders a truncated breadcrumb when the full path exceeds the width. -// Strategy: Show "..." + later items to preserve current context. -func (b *Breadcrumb) renderTruncated(width int, mutedStyle, currentStyle *lipgloss.Style) string { - if len(b.items) == 0 { - return "" - } - - ellipsis := mutedStyle.Render("...") - separator := mutedStyle.Render(b.separator) - - // If we only have one item, truncate it - if len(b.items) == 1 { - item := b.items[0] - maxLen := width - 3 // Reserve space for "..." - if maxLen < 1 { - return ellipsis - } - // Use visual width (lipgloss.Width) for accurate Unicode/emoji measurement - if lipgloss.Width(item) > maxLen { - return currentStyle.Render(truncateToVisualWidth(item, maxLen)) + ellipsis - } - return currentStyle.Render(item) - } - - // Try to show: "..." > penultimate > current - // Start from the end and work backwards - var partsToShow []string - remainingWidth := width - - // Always show current item (last item or currentIndex) - currentItem := b.items[b.currentIndex] - currentStyled := currentStyle.Render(currentItem) - currentWidth := lipgloss.Width(currentStyled) - - // If current item alone exceeds width, truncate it - if currentWidth > width-3 { - maxLen := width - 3 - if maxLen < 1 { - return ellipsis - } - // Use visual width (lipgloss.Width) for accurate Unicode/emoji measurement - if lipgloss.Width(currentItem) > maxLen { - return currentStyle.Render(truncateToVisualWidth(currentItem, maxLen)) + ellipsis - } - return currentStyled - } - - partsToShow = append([]string{currentStyled}, partsToShow...) - remainingWidth -= currentWidth - - // Add items from right to left (excluding current) - for i := len(b.items) - 1; i >= 0; i-- { - if i == b.currentIndex { - continue // Already added - } - - item := b.items[i] - var styledItem string - if i == b.currentIndex { - styledItem = currentStyle.Render(item) - } else { - styledItem = mutedStyle.Render(item) - } - - sepWidth := lipgloss.Width(separator) - itemWidth := lipgloss.Width(styledItem) - ellipsisWidth := lipgloss.Width(ellipsis) - - // Check if we can fit: ellipsis + separator + item + separator + existing parts - needed := ellipsisWidth + sepWidth + itemWidth + sepWidth - if remainingWidth >= needed { - partsToShow = append([]string{separator, styledItem}, partsToShow...) - remainingWidth -= (sepWidth + itemWidth + sepWidth) - } else { - // Can't fit more items, show ellipsis - partsToShow = append([]string{ellipsis, separator}, partsToShow...) - break - } - } - - // If we haven't added ellipsis and we didn't show all items, add it - if len(partsToShow) < len(b.items)*2-1 && !strings.Contains(partsToShow[0], "...") { - partsToShow = append([]string{ellipsis, separator}, partsToShow...) - } - - return strings.Join(partsToShow, "") -} - -// SetItems updates the breadcrumb items. -// The current index is reset to the last item. -func (b *Breadcrumb) SetItems(items []string) { - b.items = items - b.currentIndex = len(items) - 1 - if b.currentIndex < 0 { - b.currentIndex = 0 - } -} - -// SetTheme updates the theme used for styling. -func (b *Breadcrumb) SetTheme(theme *themes.Theme) { - b.theme = theme -} - -// SetCurrentIndex sets which item is considered "current" (highlighted). -// The index is clamped to valid range [0, len(items)-1]. -func (b *Breadcrumb) SetCurrentIndex(index int) { - if index < 0 { - b.currentIndex = 0 - } else if index >= len(b.items) { - b.currentIndex = len(b.items) - 1 - } else { - b.currentIndex = index - } -} - -// getDefaultTheme returns the default theme, with fallback. -// This helper is shared across all components for consistency. -func getDefaultTheme() *themes.Theme { - theme, _ := themes.GetDefault() - if theme == nil { - // Try loading enterprise theme explicitly - loader := themes.NewLoader() - theme, _ = loader.Load("enterprise") - } - return theme -} diff --git a/pkg/ui/components/breadcrumb/breadcrumb_test.go b/pkg/ui/components/breadcrumb/breadcrumb_test.go deleted file mode 100644 index 63656e1..0000000 --- a/pkg/ui/components/breadcrumb/breadcrumb_test.go +++ /dev/null @@ -1,616 +0,0 @@ -package breadcrumb - -import ( - "strings" - "testing" - - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// newMockTheme creates a simple theme for testing -func newMockTheme() *themes.Theme { - colorSet := &themes.ColorSet{ - Primary: "#00ADD8", - Secondary: "#9D7CD8", - Foreground: "#E0E0E0", - Background: "#1E1E2E", - Muted: "#6272A4", - Success: "#50FA7B", - Error: "#FF5555", - Warning: "#F1FA8C", - Info: "#8BE9FD", - Border: "#44475A", - } - - return &themes.Theme{ - Name: "Test Theme", - Colors: *colorSet, - } -} - -// TestNewBreadcrumb verifies the Breadcrumb constructor. -func TestNewBreadcrumb(t *testing.T) { - theme := newMockTheme() - items := []string{"Home", "Services", "Postgres"} - - breadcrumb := NewBreadcrumb(items, theme) - - if breadcrumb == nil { - t.Fatal("NewBreadcrumb returned nil") - } - if len(breadcrumb.items) != 3 { - t.Errorf("Expected 3 items, got %d", len(breadcrumb.items)) - } - if breadcrumb.currentIndex != 2 { - t.Errorf("Expected currentIndex 2, got %d", breadcrumb.currentIndex) - } - if breadcrumb.theme != theme { - t.Error("Theme not set correctly") - } - if breadcrumb.separator != " › " { - t.Errorf("Expected separator ' › ', got '%s'", breadcrumb.separator) - } -} - -// TestNewBreadcrumbEmpty verifies handling of empty items. -func TestNewBreadcrumbEmpty(t *testing.T) { - theme := newMockTheme() - items := []string{} - - breadcrumb := NewBreadcrumb(items, theme) - - if breadcrumb == nil { - t.Fatal("NewBreadcrumb returned nil") - } - if len(breadcrumb.items) != 0 { - t.Errorf("Expected 0 items, got %d", len(breadcrumb.items)) - } - if breadcrumb.currentIndex != 0 { - t.Errorf("Expected currentIndex 0, got %d", breadcrumb.currentIndex) - } -} - -// TestNewBreadcrumbSingleItem verifies handling of single item. -func TestNewBreadcrumbSingleItem(t *testing.T) { - theme := newMockTheme() - items := []string{"Home"} - - breadcrumb := NewBreadcrumb(items, theme) - - if breadcrumb == nil { - t.Fatal("NewBreadcrumb returned nil") - } - if len(breadcrumb.items) != 1 { - t.Errorf("Expected 1 item, got %d", len(breadcrumb.items)) - } - if breadcrumb.currentIndex != 0 { - t.Errorf("Expected currentIndex 0, got %d", breadcrumb.currentIndex) - } -} - -// TestBreadcrumbRender verifies basic rendering. -func TestBreadcrumbRender(t *testing.T) { - theme := newMockTheme() - items := []string{"Home", "Services", "Postgres"} - - breadcrumb := NewBreadcrumb(items, theme) - rendered := breadcrumb.Render(80) - - if rendered == "" { - t.Error("Expected non-empty render output") - } - - // Check that all items appear in the rendered output (sans ANSI) - plainText := lipgloss.NewStyle().Render(rendered) - for _, item := range items { - if !strings.Contains(plainText, item) { - t.Errorf("Expected rendered output to contain '%s'", item) - } - } - - // Check separator appears - if !strings.Contains(plainText, "›") { - t.Error("Expected rendered output to contain separator '›'") - } -} - -// TestBreadcrumbRenderEmpty verifies rendering of empty breadcrumb. -func TestBreadcrumbRenderEmpty(t *testing.T) { - theme := newMockTheme() - items := []string{} - - breadcrumb := NewBreadcrumb(items, theme) - rendered := breadcrumb.Render(80) - - if rendered != "" { - t.Error("Expected empty render output for empty breadcrumb") - } -} - -// TestBreadcrumbRenderSingleItem verifies rendering with single item. -func TestBreadcrumbRenderSingleItem(t *testing.T) { - theme := newMockTheme() - items := []string{"Home"} - - breadcrumb := NewBreadcrumb(items, theme) - rendered := breadcrumb.Render(80) - - if rendered == "" { - t.Error("Expected non-empty render output") - } - - plainText := lipgloss.NewStyle().Render(rendered) - if !strings.Contains(plainText, "Home") { - t.Error("Expected rendered output to contain 'Home'") - } - - // Should not contain separator for single item - if strings.Contains(plainText, "›") { - t.Error("Expected no separator for single item") - } -} - -// TestBreadcrumbRenderWidth verifies rendering respects width constraint. -func TestBreadcrumbRenderWidth(t *testing.T) { - theme := newMockTheme() - items := []string{"Home", "Services", "Postgres"} - - breadcrumb := NewBreadcrumb(items, theme) - - widths := []int{10, 20, 40, 80, 120} - for _, width := range widths { - t.Run(string(rune(width))+" columns", func(t *testing.T) { - rendered := breadcrumb.Render(width) - actualWidth := lipgloss.Width(rendered) - - if actualWidth > width { - t.Errorf("Rendered width %d exceeds constraint %d", actualWidth, width) - } - }) - } -} - -// TestBreadcrumbRenderTruncation verifies truncation for long paths. -func TestBreadcrumbRenderTruncation(t *testing.T) { - theme := newMockTheme() - items := []string{"Home", "Services", "Database", "Postgres", "Instances", "Production"} - - breadcrumb := NewBreadcrumb(items, theme) - - // Render with narrow width - rendered := breadcrumb.Render(30) - actualWidth := lipgloss.Width(rendered) - - if actualWidth > 30 { - t.Errorf("Rendered width %d exceeds constraint 30", actualWidth) - } - - // Should contain ellipsis - plainText := lipgloss.NewStyle().Render(rendered) - if !strings.Contains(plainText, "...") { - t.Error("Expected truncated output to contain '...'") - } - - // Should contain current item (last item) - if !strings.Contains(plainText, "Production") { - t.Error("Expected truncated output to preserve current item 'Production'") - } -} - -// TestBreadcrumbRenderNilTheme verifies fallback to default theme. -func TestBreadcrumbRenderNilTheme(t *testing.T) { - items := []string{"Home", "Services"} - - breadcrumb := NewBreadcrumb(items, nil) - rendered := breadcrumb.Render(80) - - // Should not panic and should render something - if rendered == "" { - t.Error("Expected non-empty render with nil theme (should use fallback)") - } -} - -// TestSetItems verifies SetItems method. -func TestSetItems(t *testing.T) { - theme := newMockTheme() - items := []string{"Home", "Services"} - - breadcrumb := NewBreadcrumb(items, theme) - - // Update items - newItems := []string{"Home", "Databases", "MySQL"} - breadcrumb.SetItems(newItems) - - if len(breadcrumb.items) != 3 { - t.Errorf("Expected 3 items, got %d", len(breadcrumb.items)) - } - if breadcrumb.items[2] != "MySQL" { - t.Errorf("Expected last item 'MySQL', got '%s'", breadcrumb.items[2]) - } - if breadcrumb.currentIndex != 2 { - t.Errorf("Expected currentIndex reset to 2, got %d", breadcrumb.currentIndex) - } -} - -// TestSetItemsEmpty verifies SetItems with empty slice. -func TestSetItemsEmpty(t *testing.T) { - theme := newMockTheme() - items := []string{"Home", "Services"} - - breadcrumb := NewBreadcrumb(items, theme) - breadcrumb.SetItems([]string{}) - - if len(breadcrumb.items) != 0 { - t.Errorf("Expected 0 items, got %d", len(breadcrumb.items)) - } - if breadcrumb.currentIndex != 0 { - t.Errorf("Expected currentIndex 0, got %d", breadcrumb.currentIndex) - } -} - -// TestSetTheme verifies SetTheme method. -func TestSetTheme(t *testing.T) { - theme1 := newMockTheme() - items := []string{"Home", "Services"} - - breadcrumb := NewBreadcrumb(items, theme1) - - // Create new theme with different colors - theme2 := newMockTheme() - theme2.Colors.Primary = "#FF0000" - - breadcrumb.SetTheme(theme2) - - if breadcrumb.theme != theme2 { - t.Error("Theme not updated correctly") - } - if breadcrumb.theme.Colors.Primary != "#FF0000" { - t.Error("Expected new theme primary color") - } -} - -// TestSetCurrentIndex verifies SetCurrentIndex method. -func TestSetCurrentIndex(t *testing.T) { - theme := newMockTheme() - items := []string{"Home", "Services", "Postgres"} - - breadcrumb := NewBreadcrumb(items, theme) - - tests := []struct { - name string - index int - expected int - }{ - { - name: "Valid index 0", - index: 0, - expected: 0, - }, - { - name: "Valid index 1", - index: 1, - expected: 1, - }, - { - name: "Valid index 2", - index: 2, - expected: 2, - }, - { - name: "Negative index clamped to 0", - index: -1, - expected: 0, - }, - { - name: "Negative index clamped to 0", - index: -10, - expected: 0, - }, - { - name: "Out of bounds index clamped to last", - index: 3, - expected: 2, - }, - { - name: "Out of bounds index clamped to last", - index: 100, - expected: 2, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - breadcrumb.SetCurrentIndex(tt.index) - if breadcrumb.currentIndex != tt.expected { - t.Errorf("Expected currentIndex %d, got %d", tt.expected, breadcrumb.currentIndex) - } - }) - } -} - -// TestSetCurrentIndexRendering verifies that SetCurrentIndex affects rendering. -func TestSetCurrentIndexRendering(t *testing.T) { - theme := newMockTheme() - items := []string{"Home", "Services", "Postgres"} - - breadcrumb := NewBreadcrumb(items, theme) - - // Default: current = last item (Postgres) - rendered1 := breadcrumb.Render(80) - - // Change current to first item - breadcrumb.SetCurrentIndex(0) - rendered2 := breadcrumb.Render(80) - - // Change current to middle item - breadcrumb.SetCurrentIndex(1) - rendered3 := breadcrumb.Render(80) - - // All renderings should be different (different items highlighted) - // Note: We can't use simple string comparison due to ANSI codes - // but we can verify the breadcrumb renders without error - if rendered1 == "" || rendered2 == "" || rendered3 == "" { - t.Error("Expected non-empty renderings") - } - - // Verify all items are present in all renders - plainText1 := lipgloss.NewStyle().Render(rendered1) - plainText2 := lipgloss.NewStyle().Render(rendered2) - plainText3 := lipgloss.NewStyle().Render(rendered3) - - for _, item := range items { - if !strings.Contains(plainText1, item) { - t.Errorf("Expected rendered1 to contain '%s'", item) - } - if !strings.Contains(plainText2, item) { - t.Errorf("Expected rendered2 to contain '%s'", item) - } - if !strings.Contains(plainText3, item) { - t.Errorf("Expected rendered3 to contain '%s'", item) - } - } -} - -// TestBreadcrumbWithDifferentThemes verifies breadcrumb works with different themes. -func TestBreadcrumbWithDifferentThemes(t *testing.T) { - items := []string{"Home", "Services", "Postgres"} - - testThemes := []struct { - name string - primary string - muted string - }{ - {"Enterprise", "#00ADD8", "#6272A4"}, - {"Saiyan", "#FF6B35", "#F7931E"}, - {"Jedi", "#4A90E2", "#7ED321"}, - } - - for _, tt := range testThemes { - t.Run(tt.name, func(t *testing.T) { - colorSet := &themes.ColorSet{ - Primary: tt.primary, - Muted: tt.muted, - Background: "#1E1E2E", - Foreground: "#CDD6F4", - Border: "#313244", - } - theme := &themes.Theme{ - Name: tt.name, - Colors: *colorSet, - } - - breadcrumb := NewBreadcrumb(items, theme) - rendered := breadcrumb.Render(80) - - if rendered == "" { - t.Errorf("Expected non-empty render for %s theme", tt.name) - } - }) - } -} - -// TestBreadcrumbVeryLongItem verifies handling of very long single items. -func TestBreadcrumbVeryLongItem(t *testing.T) { - theme := newMockTheme() - items := []string{"Home", "A very long service name that exceeds normal length"} - - breadcrumb := NewBreadcrumb(items, theme) - rendered := breadcrumb.Render(30) - actualWidth := lipgloss.Width(rendered) - - if actualWidth > 30 { - t.Errorf("Rendered width %d exceeds constraint 30", actualWidth) - } -} - -// TestBreadcrumbMultipleItemsSameName verifies handling of duplicate names. -func TestBreadcrumbMultipleItemsSameName(t *testing.T) { - theme := newMockTheme() - items := []string{"Home", "Services", "Services", "Postgres"} - - breadcrumb := NewBreadcrumb(items, theme) - rendered := breadcrumb.Render(80) - - if rendered == "" { - t.Error("Expected non-empty render output") - } - - // All items should appear - plainText := lipgloss.NewStyle().Render(rendered) - // Count occurrences of "Services" - count := strings.Count(plainText, "Services") - if count < 2 { - t.Errorf("Expected at least 2 occurrences of 'Services', got %d", count) - } -} - -// TestBreadcrumbSpecialCharacters verifies handling of special characters in items. -func TestBreadcrumbSpecialCharacters(t *testing.T) { - theme := newMockTheme() - items := []string{"Home", "Services & Apps", "Postgres-DB"} - - breadcrumb := NewBreadcrumb(items, theme) - rendered := breadcrumb.Render(80) - - if rendered == "" { - t.Error("Expected non-empty render output") - } - - plainText := lipgloss.NewStyle().Render(rendered) - if !strings.Contains(plainText, "&") { - t.Error("Expected rendered output to preserve '&' character") - } - if !strings.Contains(plainText, "-") { - t.Error("Expected rendered output to preserve '-' character") - } -} - -// TestBreadcrumbUnicodeItems verifies handling of unicode characters. -func TestBreadcrumbUnicodeItems(t *testing.T) { - theme := newMockTheme() - items := []string{"🏠 Home", "⚙️ Services", "🐘 Postgres"} - - breadcrumb := NewBreadcrumb(items, theme) - rendered := breadcrumb.Render(80) - - if rendered == "" { - t.Error("Expected non-empty render output") - } - - plainText := lipgloss.NewStyle().Render(rendered) - if !strings.Contains(plainText, "🏠") { - t.Error("Expected rendered output to preserve emoji") - } -} - -// TestBreadcrumbTruncationWithVeryNarrowWidth verifies edge case handling. -func TestBreadcrumbTruncationWithVeryNarrowWidth(t *testing.T) { - theme := newMockTheme() - items := []string{"Home", "Services", "Postgres"} - - breadcrumb := NewBreadcrumb(items, theme) - - // Very narrow widths - widths := []int{5, 8, 10} - for _, width := range widths { - t.Run(string(rune(width))+" columns", func(t *testing.T) { - rendered := breadcrumb.Render(width) - actualWidth := lipgloss.Width(rendered) - - if actualWidth > width { - t.Errorf("Rendered width %d exceeds constraint %d", actualWidth, width) - } - - // Should render something (even if just ellipsis) - if rendered == "" { - t.Error("Expected non-empty render even with narrow width") - } - }) - } -} - -// TestBreadcrumbMethodChaining verifies that methods support fluent interface. -func TestBreadcrumbMethodChaining(t *testing.T) { - theme1 := newMockTheme() - theme2 := newMockTheme() - theme2.Colors.Primary = "#FF0000" - - items1 := []string{"Home", "Services"} - items2 := []string{"Home", "Databases", "MySQL"} - - breadcrumb := NewBreadcrumb(items1, theme1) - - // Chain multiple operations - breadcrumb.SetItems(items2) - breadcrumb.SetTheme(theme2) - breadcrumb.SetCurrentIndex(1) - - if len(breadcrumb.items) != 3 { - t.Errorf("Expected 3 items, got %d", len(breadcrumb.items)) - } - if breadcrumb.theme.Colors.Primary != "#FF0000" { - t.Error("Expected theme updated") - } - if breadcrumb.currentIndex != 1 { - t.Errorf("Expected currentIndex 1, got %d", breadcrumb.currentIndex) - } -} - -// TestBreadcrumbRenderConsistency verifies that multiple renders produce same output. -func TestBreadcrumbRenderConsistency(t *testing.T) { - theme := newMockTheme() - items := []string{"Home", "Services", "Postgres"} - - breadcrumb := NewBreadcrumb(items, theme) - - rendered1 := breadcrumb.Render(80) - rendered2 := breadcrumb.Render(80) - rendered3 := breadcrumb.Render(80) - - if rendered1 != rendered2 || rendered2 != rendered3 { - t.Error("Expected consistent rendering across multiple calls") - } -} - -// TestBreadcrumbNoSeparatorAfterLastItem verifies separator placement. -func TestBreadcrumbNoSeparatorAfterLastItem(t *testing.T) { - theme := newMockTheme() - items := []string{"Home", "Services", "Postgres"} - - breadcrumb := NewBreadcrumb(items, theme) - rendered := breadcrumb.Render(80) - - plainText := lipgloss.NewStyle().Render(rendered) - - // Count separators - should be (n-1) for n items - separatorCount := strings.Count(plainText, "›") - expectedCount := len(items) - 1 - - if separatorCount != expectedCount { - t.Errorf("Expected %d separators, got %d", expectedCount, separatorCount) - } - - // Should not end with separator - trimmed := strings.TrimSpace(plainText) - if strings.HasSuffix(trimmed, "›") { - t.Error("Breadcrumb should not end with separator") - } -} - -// TestBreadcrumbExtremelyLongPath verifies handling of many items. -func TestBreadcrumbExtremelyLongPath(t *testing.T) { - theme := newMockTheme() - items := []string{ - "Home", - "Services", - "Databases", - "Relational", - "Postgres", - "Clusters", - "Production", - "US-East", - "Primary", - "Instance-1", - } - - breadcrumb := NewBreadcrumb(items, theme) - rendered := breadcrumb.Render(40) - actualWidth := lipgloss.Width(rendered) - - if actualWidth > 40 { - t.Errorf("Rendered width %d exceeds constraint 40", actualWidth) - } - - // Should contain ellipsis for truncation - plainText := lipgloss.NewStyle().Render(rendered) - if !strings.Contains(plainText, "...") { - t.Error("Expected truncation ellipsis for long path") - } - - // Should preserve current (last) item - if !strings.Contains(plainText, "Instance-1") { - t.Error("Expected current item to be preserved during truncation") - } -} diff --git a/pkg/ui/components/card.go b/pkg/ui/components/card.go deleted file mode 100644 index 30ae67c..0000000 --- a/pkg/ui/components/card.go +++ /dev/null @@ -1,449 +0,0 @@ -package components - -import ( - "strings" - - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// Card represents a bordered content card with a title. -// Designed for dashboard layouts with support for focused/unfocused states. -// -// Example usage: -// -// card := NewCard("System Info", content, factory). -// SetWidth(40). -// SetFocused(true) -// fmt.Println(card.Render()) -type Card struct { - Title string - Content string - Width int - Height int - Focused bool - TitleColor lipgloss.Color - BorderColor lipgloss.Color - FocusColor lipgloss.Color - ContentColor lipgloss.Color - Padding int - Margin int - BorderStyle lipgloss.Border - FocusedBorder lipgloss.Border - TitleBold bool - ShowBorder bool -} - -// NewCard creates a new card with default styling. -// Deprecated: Use NewThemedCard for profile-aware theming. -// This version maintains backward compatibility by using default theme. -func NewCard(title, content string) *Card { - return NewThemedCard(title, content, nil) -} - -// NewThemedCard creates a new card with default styling using theme colors. -// If theme is nil, falls back to default theme. -func NewThemedCard(title, content string, theme *themes.Theme) *Card { - // Fallback to default theme if not provided - if theme == nil { - theme = getDefaultTheme() - } - - // Ultimate fallback if theme is still nil - if theme == nil { - return &Card{ - Title: title, - Content: content, - Width: 40, - Height: 0, - Focused: false, - TitleColor: lipgloss.Color("#00ADD8"), - BorderColor: lipgloss.Color("#6272A4"), - FocusColor: lipgloss.Color("#00ADD8"), - ContentColor: lipgloss.Color("#F8F8F2"), - Padding: 1, - Margin: 0, - BorderStyle: lipgloss.RoundedBorder(), - FocusedBorder: lipgloss.RoundedBorder(), - TitleBold: true, - ShowBorder: true, - } - } - - return &Card{ - Title: title, - Content: content, - Width: 40, - Height: 0, - Focused: false, - TitleColor: theme.Colors.PrimaryColor(), - BorderColor: theme.Colors.MutedColor(), - FocusColor: theme.Colors.PrimaryColor(), - ContentColor: lipgloss.Color("#F8F8F2"), - Padding: 1, - Margin: 0, - BorderStyle: lipgloss.RoundedBorder(), - FocusedBorder: lipgloss.RoundedBorder(), - TitleBold: true, - ShowBorder: true, - } -} - -// NewCardWithFactory creates a new card using ComponentFactory theming. -// This is a convenience constructor that applies factory styles automatically. -// For standalone usage, use NewCard() and customize with builder methods. -func NewCardWithFactory(title, content string, factory interface{}) *Card { - card := NewCard(title, content) - - // If factory provides theming methods, apply them - // This is a pattern for future extension when ComponentFactory - // might expose style getters - - return card -} - -// SetWidth sets the card width. -func (c *Card) SetWidth(width int) *Card { - c.Width = width - return c -} - -// SetHeight sets the card height (0 for auto). -func (c *Card) SetHeight(height int) *Card { - c.Height = height - return c -} - -// SetFocused sets the card's focused state. -// When focused, the card uses FocusColor for the border. -// When unfocused, the card uses BorderColor for the border. -func (c *Card) SetFocused(focused bool) *Card { - c.Focused = focused - return c -} - -// SetTitleColor sets the title text color. -func (c *Card) SetTitleColor(color lipgloss.Color) *Card { - c.TitleColor = color - return c -} - -// SetBorderColor sets the unfocused border color. -func (c *Card) SetBorderColor(color lipgloss.Color) *Card { - c.BorderColor = color - return c -} - -// SetFocusColor sets the focused border color. -func (c *Card) SetFocusColor(color lipgloss.Color) *Card { - c.FocusColor = color - return c -} - -// SetContentColor sets the content text color. -func (c *Card) SetContentColor(color lipgloss.Color) *Card { - c.ContentColor = color - return c -} - -// SetPadding sets the internal padding. -func (c *Card) SetPadding(padding int) *Card { - c.Padding = padding - return c -} - -// SetMargin sets the external margin. -func (c *Card) SetMargin(margin int) *Card { - c.Margin = margin - return c -} - -// SetBorderStyle sets the border style for unfocused state. -// -//nolint:gocritic // Border passed by value matches lipgloss API conventions -func (c *Card) SetBorderStyle(border lipgloss.Border) *Card { - c.BorderStyle = border - return c -} - -// SetFocusedBorder sets the border style for focused state. -// -//nolint:gocritic // Border passed by value matches lipgloss API conventions -func (c *Card) SetFocusedBorder(border lipgloss.Border) *Card { - c.FocusedBorder = border - return c -} - -// SetBorderTier sets both normal and focused borders based on BorderTier. -// Tier 1: HiddenBorder (borderless) -// Tier 2: OuterHalfBlockBorder (half-block chars) -// Tier 3: RoundedBorder / ThickBorder (classic Unicode) -func (c *Card) SetBorderTier(tier BorderTier) *Card { - switch tier { - case BorderTierNone: - c.BorderStyle = lipgloss.HiddenBorder() - c.FocusedBorder = lipgloss.HiddenBorder() - c.ShowBorder = true // Still show for layout math - case BorderTierBlock: - c.BorderStyle = lipgloss.OuterHalfBlockBorder() - c.FocusedBorder = lipgloss.OuterHalfBlockBorder() - c.ShowBorder = true - case BorderTierClassic: - c.BorderStyle = lipgloss.RoundedBorder() - c.FocusedBorder = lipgloss.ThickBorder() - c.ShowBorder = true - } - return c -} - -// WithTitle sets the card title. -func (c *Card) WithTitle(title string) *Card { - c.Title = title - return c -} - -// WithContent sets the card content. -func (c *Card) WithContent(content string) *Card { - c.Content = content - return c -} - -// WithBold sets whether the title should be bold. -func (c *Card) WithBold(bold bool) *Card { - c.TitleBold = bold - return c -} - -// Render returns the card as a styled string. -// This is the primary rendering method. -func (c *Card) Render() string { - // Create title style - titleStyle := lipgloss.NewStyle(). - Foreground(c.TitleColor). - Bold(c.TitleBold) - - // Create content style - contentStyle := lipgloss.NewStyle(). - Foreground(c.ContentColor) - - // Build content with title if present - var renderedContent string - if c.Title != "" { - renderedContent = c.renderWithTitle(titleStyle) - } else { - renderedContent = c.Content - } - - // Apply content styling with padding - styledContent := contentStyle.Padding(c.Padding).Render(renderedContent) - - // Create card style with border if enabled - if c.ShowBorder { - // Select border style and color based on focus state - border := c.BorderStyle - borderColor := c.BorderColor - - if c.Focused { - border = c.FocusedBorder - borderColor = c.FocusColor - } - - cardStyle := lipgloss.NewStyle(). - Border(border). - BorderForeground(borderColor). - Width(c.Width). - Margin(c.Margin) - - if c.Height > 0 { - cardStyle = cardStyle.Height(c.Height) - } - - return cardStyle.Render(styledContent) - } - - // No border, just return styled content - return styledContent -} - -// View is an alias for Render (for Bubble Tea compatibility). -func (c *Card) View() string { - return c.Render() -} - -// DefaultCardStyle creates a card with default A.R.C. styling. -// Deprecated: Use NewThemedCard for profile-aware theming. -func DefaultCardStyle() *Card { - return NewThemedCard("", "", nil). - SetBorderStyle(lipgloss.RoundedBorder()) -} - -// DefaultThemedCardStyle creates a card with default styling using theme colors. -// If theme is nil, falls back to default theme. -// - -func DefaultThemedCardStyle(theme *themes.Theme) *Card { - return NewThemedCard("", "", theme). - SetBorderStyle(lipgloss.RoundedBorder()) -} - -// InfoCard creates a card styled for informational content. -// Deprecated: Use ThemedInfoCard for profile-aware theming. -func InfoCard(title, content string) *Card { - return ThemedInfoCard(title, content, nil) -} - -// ThemedInfoCard creates a card styled for informational content using theme colors. -// If theme is nil, falls back to default theme. -// - -func ThemedInfoCard(title, content string, theme *themes.Theme) *Card { - if theme == nil { - theme = getDefaultTheme() - } - - if theme == nil { - return NewCard(title, content). - SetTitleColor(lipgloss.Color("#00ADD8")). - SetBorderColor(lipgloss.Color("#00ADD8")). - SetContentColor(lipgloss.Color("#F8F8F2")) - } - - return NewThemedCard(title, content, theme). - SetTitleColor(theme.Colors.InfoColor()). - SetBorderColor(theme.Colors.InfoColor()). - SetContentColor(lipgloss.Color("#F8F8F2")) -} - -// SuccessCard creates a card styled for success messages. -// Deprecated: Use ThemedSuccessCard for profile-aware theming. -func SuccessCard(title, content string) *Card { - return ThemedSuccessCard(title, content, nil) -} - -// ThemedSuccessCard creates a card styled for success messages using theme colors. -// If theme is nil, falls back to default theme. -// -//nolint:dupl // Intentional duplication for clarity - each style function is self-contained -func ThemedSuccessCard(title, content string, theme *themes.Theme) *Card { - if theme == nil { - theme = getDefaultTheme() - } - - if theme == nil { - return NewCard(title, content). - SetTitleColor(lipgloss.Color("#00E091")). - SetBorderColor(lipgloss.Color("#00E091")). - SetFocusColor(lipgloss.Color("#00E091")). - SetContentColor(lipgloss.Color("#F8F8F2")) - } - - return NewThemedCard(title, content, theme). - SetTitleColor(theme.Colors.SuccessColor()). - SetBorderColor(theme.Colors.SuccessColor()). - SetFocusColor(theme.Colors.SuccessColor()). - SetContentColor(lipgloss.Color("#F8F8F2")) -} - -// ErrorCard creates a card styled for error messages. -// Deprecated: Use ThemedErrorCard for profile-aware theming. -func ErrorCard(title, content string) *Card { - return ThemedErrorCard(title, content, nil) -} - -// ThemedErrorCard creates a card styled for error messages using theme colors. -// If theme is nil, falls back to default theme. -// -//nolint:dupl // Intentional duplication for clarity - each style function is self-contained -func ThemedErrorCard(title, content string, theme *themes.Theme) *Card { - if theme == nil { - theme = getDefaultTheme() - } - - if theme == nil { - return NewCard(title, content). - SetTitleColor(lipgloss.Color("#FF4444")). - SetBorderColor(lipgloss.Color("#FF4444")). - SetFocusColor(lipgloss.Color("#FF4444")). - SetContentColor(lipgloss.Color("#F8F8F2")) - } - - return NewThemedCard(title, content, theme). - SetTitleColor(theme.Colors.ErrorColor()). - SetBorderColor(theme.Colors.ErrorColor()). - SetFocusColor(theme.Colors.ErrorColor()). - SetContentColor(lipgloss.Color("#F8F8F2")) -} - -// WarningCard creates a card styled for warning messages. -// Deprecated: Use ThemedWarningCard for profile-aware theming. -func WarningCard(title, content string) *Card { - return ThemedWarningCard(title, content, nil) -} - -// ThemedWarningCard creates a card styled for warning messages using theme colors. -// If theme is nil, falls back to default theme. -// -//nolint:dupl // Intentional duplication for clarity - each style function is self-contained -func ThemedWarningCard(title, content string, theme *themes.Theme) *Card { - if theme == nil { - theme = getDefaultTheme() - } - - if theme == nil { - return NewCard(title, content). - SetTitleColor(lipgloss.Color("#FFB86C")). - SetBorderColor(lipgloss.Color("#FFB86C")). - SetFocusColor(lipgloss.Color("#FFB86C")). - SetContentColor(lipgloss.Color("#F8F8F2")) - } - - return NewThemedCard(title, content, theme). - SetTitleColor(theme.Colors.WarningColor()). - SetBorderColor(theme.Colors.WarningColor()). - SetFocusColor(theme.Colors.WarningColor()). - SetContentColor(lipgloss.Color("#F8F8F2")) -} - -// renderWithTitle builds card content with title and separator. -// Extracted from Render() to reduce complexity (nestif linter). -// -//nolint:gocritic // Style passed by value matches lipgloss API conventions -func (c *Card) renderWithTitle(titleStyle lipgloss.Style) string { - titleLine := titleStyle.Render(c.Title) - - // Calculate separator width using ANSI-aware width - // This is the critical fix for border alignment - titleWidth := lipgloss.Width(titleLine) - - // Calculate available width for separator - // Account for border (2 chars) and padding (2 * c.Padding) - borderWidth := 0 - if c.ShowBorder && c.BorderStyle != lipgloss.HiddenBorder() { - borderWidth = 2 // Left and right border - } - paddingWidth := c.Padding * 2 - - availableWidth := c.Width - borderWidth - paddingWidth - if availableWidth < 0 { - availableWidth = 0 - } - - // Use the larger of titleWidth or availableWidth for separator - sepWidth := availableWidth - if titleWidth > sepWidth { - sepWidth = titleWidth - } - - separator := lipgloss.NewStyle(). - Foreground(c.BorderColor). - Render(strings.Repeat("─", sepWidth)) - - return lipgloss.JoinVertical( - lipgloss.Left, - titleLine, - separator, - "", - c.Content, - ) -} diff --git a/pkg/ui/components/card_example_test.go b/pkg/ui/components/card_example_test.go deleted file mode 100644 index 73ba112..0000000 --- a/pkg/ui/components/card_example_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package components_test - -import ( - "fmt" - - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/components" -) - -// ExampleCard demonstrates basic card usage. -func ExampleCard() { - card := components.NewCard("System Info", "OS: macOS\nArch: arm64\nCPU: 8 cores") - fmt.Println(card.Render()) -} - -// ExampleCard_focused demonstrates a focused card with highlighted border. -func ExampleCard_focused() { - card := components.NewCard("Active Service", "Status: Running\nPort: 8080\nUptime: 2h 15m"). - SetFocused(true). - SetWidth(50) - fmt.Println(card.Render()) -} - -// ExampleCard_chainedBuilder demonstrates chainable builder pattern. -func ExampleCard_chainedBuilder() { - card := components.NewCard("Profile", "Name: Saiyan\nTheme: Fire"). - SetWidth(60). - SetPadding(2). - SetMargin(1). - SetFocused(false). - SetTitleColor(lipgloss.Color("#FF5733")). - SetBorderColor(lipgloss.Color("#C70039")) - - fmt.Println(card.Render()) -} - -// ExampleCard_borderTiers demonstrates different border tier styles. -func ExampleCard_borderTiers() { - content := "Tier 1: Borderless (safe fallback)" - - card := components.NewCard("Border Tier Demo", content) - - // Tier 1: Borderless (HiddenBorder) - card.SetBorderTier(components.BorderTierNone) - fmt.Println("Tier 1 (None):") - fmt.Println(card.Render()) - fmt.Println() - - // Tier 2: Block borders (half-block chars) - card.SetBorderTier(components.BorderTierBlock) - fmt.Println("Tier 2 (Block):") - fmt.Println(card.Render()) - fmt.Println() - - // Tier 3: Classic Unicode borders - card.SetBorderTier(components.BorderTierClassic) - fmt.Println("Tier 3 (Classic):") - fmt.Println(card.Render()) -} - -// ExampleSuccessCard demonstrates a pre-styled success card. -func ExampleSuccessCard() { - card := components.SuccessCard( - "Deployment Successful", - "Service: api-gateway\nVersion: v2.1.0\nStatus: Healthy", - ) - fmt.Println(card.Render()) -} - -// ExampleErrorCard demonstrates a pre-styled error card. -func ExampleErrorCard() { - card := components.ErrorCard( - "Connection Failed", - "Host: redis-cluster\nError: connection timeout\nHint: Check network connectivity", - ) - fmt.Println(card.Render()) -} - -// ExampleWarningCard demonstrates a pre-styled warning card. -func ExampleWarningCard() { - card := components.WarningCard( - "Resource Alert", - "Memory: 85% used\nDisk: 92% used\nAction: Consider scaling up", - ) - fmt.Println(card.Render()) -} - -// ExampleCard_responsive demonstrates responsive width handling. -func ExampleCard_responsive() { - card := components.NewCard("Responsive Card", "This card adapts to different widths") - - // Narrow width - card.SetWidth(40) - fmt.Println("Width: 40") - fmt.Println(card.Render()) - fmt.Println() - - // Wide width - card.SetWidth(80) - fmt.Println("Width: 80") - fmt.Println(card.Render()) -} - -// ExampleCard_multiline demonstrates multiline content handling. -func ExampleCard_multiline() { - content := `Service Overview: - - API Gateway: ✓ Running - - Database: ✓ Running - - Cache: ✓ Running - - Queue: ⚠ Degraded - - Metrics: ✓ Running` - - card := components.NewCard("Service Health", content).SetWidth(60) - fmt.Println(card.Render()) -} diff --git a/pkg/ui/components/card_grid.go b/pkg/ui/components/card_grid.go deleted file mode 100644 index 5172112..0000000 --- a/pkg/ui/components/card_grid.go +++ /dev/null @@ -1,335 +0,0 @@ -package components - -import ( - "os" - "strconv" - "strings" - - "github.com/charmbracelet/lipgloss" -) - -// CardGrid represents a responsive grid layout for cards. -// -// The grid automatically adapts to terminal width, providing responsive -// multi-column layouts (2-4 columns) with automatic height equalization within rows. -// -// Layout Algorithm (016-ui-layout-fix Phase 5): -// - 4-column layout when width >= 120 -// - 3-column layout when width >= 80 and < 120 -// - 2-column layout when width >= 60 and < 80 -// - 1-column layout when width < 60 or too narrow -// - Card widths are calculated dynamically: (width - gaps) / columns -// - Card widths are clamped between MinWidth (38) and MaxWidth (60) -// - Heights are equalized per row using lipgloss.Place -// - Supports manual column override via WithColumns() or ARC_DASHBOARD_COLUMNS env var -// -// Usage Example: -// -// cards := []string{ -// cardStyle.Render("System Info\nOS: Darwin"), -// cardStyle.Render("Runtime\nGo 1.24"), -// } -// grid := NewCardGrid(cards, 120) -// fmt.Print(grid.Render()) // Auto-detects 4 columns at 120+ width -// -// Responsive Behavior: -// - Terminal width 120+: 4 columns (dense layout for wide terminals) -// - Terminal width 80-119: 3 columns (balanced layout) -// - Terminal width 60-79: 2 columns (comfortable layout) -// - Terminal width <60: 1 column (narrow terminal fallback) -// -// Manual Column Override: -// -// grid.WithColumns(3) // Force 3 columns regardless of width -// -// Environment Variable Override: -// -// ARC_DASHBOARD_COLUMNS=2 // Force 2 columns -// -// Height Equalization: -// -// Row 1: [Card A (3 lines)] [Card B (5 lines)] [Card C (2 lines)] -// All equalized to 5 lines using lipgloss.Place -// Row 2: [Card D (2 lines)] [Card E (4 lines)] -// Both equalized to 4 lines -// -// The grid is designed for dashboard layouts, card-based UIs, and -// responsive terminal applications built with Bubble Tea. -type CardGrid struct { - Cards []string // Pre-rendered card strings - Width int // Total available width - Columns int // Number of columns (0 = auto-detect, 1-4 = manual override) - MinWidth int // Minimum card width (default: 38) - MaxWidth int // Maximum card width (default: 60) - Spacing int // Space between cards (default: 3) - deprecated, use ColumnGap - RowGap int // Vertical gap between rows (default: 1) - ColumnGap int // Horizontal gap between columns (default: 3) -} - -// NewCardGrid creates a new card grid with default settings. -// cards: Array of pre-rendered card strings (typically from Card.Render()) -// width: Total available width for the grid -// -// Phase 5 Update (016-ui-layout-fix): MinWidth reduced to 30 to better support -// multi-column layouts at standard terminal widths (120, 80, 60). -func NewCardGrid(cards []string, width int) *CardGrid { - return &CardGrid{ - Cards: cards, - Width: width, - MinWidth: 30, // Reduced from 38 to support 4 columns at 120+ width - MaxWidth: 60, - Spacing: 3, - RowGap: 1, - ColumnGap: 3, - } -} - -// WithMinWidth sets the minimum card width. -func (cg *CardGrid) WithMinWidth(width int) *CardGrid { - cg.MinWidth = width - return cg -} - -// WithMaxWidth sets the maximum card width. -func (cg *CardGrid) WithMaxWidth(width int) *CardGrid { - cg.MaxWidth = width - return cg -} - -// WithSpacing sets the spacing between cards. -// Deprecated: Use WithColumnGap instead. -func (cg *CardGrid) WithSpacing(spacing int) *CardGrid { - cg.Spacing = spacing - cg.ColumnGap = spacing // Keep in sync - return cg -} - -// WithRowGap sets the vertical gap between rows. -func (cg *CardGrid) WithRowGap(gap int) *CardGrid { - cg.RowGap = gap - return cg -} - -// WithColumnGap sets the horizontal gap between columns. -func (cg *CardGrid) WithColumnGap(gap int) *CardGrid { - cg.ColumnGap = gap - cg.Spacing = gap // Keep in sync for backward compatibility - return cg -} - -// WithColumns sets a manual column count override (1-4). -// Set to 0 to enable auto-detection based on terminal width. -// This method is part of Phase 5 (016-ui-layout-fix) for responsive multi-column layouts. -func (cg *CardGrid) WithColumns(columns int) *CardGrid { - // Clamp to valid range: 0 (auto) or 1-4 (manual) - if columns < 0 { - columns = 0 - } - if columns > 4 { - columns = 4 - } - cg.Columns = columns - return cg -} - -// calculateColumns determines the number of columns based on width. -// Implements responsive breakpoints for 016-ui-layout-fix Phase 5: -// - 120+ cols → 4 columns (dense layout for wide terminals) -// - 80-119 cols → 3 columns (balanced layout) -// - 60-79 cols → 2 columns (comfortable layout) -// - <60 cols → 1 column (narrow terminal fallback) -// -// Supports manual override via: -// 1. WithColumns() method (programmatic) -// 2. ARC_DASHBOARD_COLUMNS env var (user preference) -// 3. Auto-detection based on width (default) -func (cg *CardGrid) calculateColumns() int { - // Priority 1: Check environment variable override (T069-T070) - if envCols := os.Getenv("ARC_DASHBOARD_COLUMNS"); envCols != "" { - if cols, err := strconv.Atoi(envCols); err == nil { - // Validate range: 1-4 columns only - if cols >= 1 && cols <= 4 { - return cols - } - // Invalid value: fall through to auto-detect - } - } - - // Priority 2: Check manual column count set via WithColumns() - if cg.Columns > 0 { - return cg.Columns - } - - // Auto-detect based on width and minimum card size - // Try to fit as many columns as possible while respecting MinWidth - - // Calculate minimum width needed for each column count - // Formula: (MinWidth × cols) + (ColumnGap × (cols-1)) - - // Try 4 columns (requires ~161 width with MinWidth=38) - minRequired4 := (cg.MinWidth * 4) + (cg.ColumnGap * 3) - if cg.Width >= minRequired4 { - return 4 - } - - // Try 3 columns (requires ~120 width with MinWidth=38) - minRequired3 := (cg.MinWidth * 3) + (cg.ColumnGap * 2) - if cg.Width >= minRequired3 { - return 3 - } - - // Try 2 columns (requires ~79 width with MinWidth=38) - minRequired2 := (cg.MinWidth * 2) + cg.ColumnGap - if cg.Width >= minRequired2 { - return 2 - } - - // Fallback to 1 column (narrow terminals) - return 1 -} - -// calculateCardWidth calculates the width for each card based on column count. -func (cg *CardGrid) calculateCardWidth(columns int) int { - if columns <= 0 { - return cg.MinWidth - } - - // Calculate available width after accounting for gaps - availableWidth := cg.Width - (cg.ColumnGap * (columns - 1)) - cardWidth := availableWidth / columns - - // Clamp to min/max bounds - if cardWidth < cg.MinWidth { - cardWidth = cg.MinWidth - } - if cardWidth > cg.MaxWidth { - cardWidth = cg.MaxWidth - } - - return cardWidth -} - -// groupCardsIntoRows groups cards into rows based on the column count. -func (cg *CardGrid) groupCardsIntoRows(columns int) [][]string { - if len(cg.Cards) == 0 || columns <= 0 { - return [][]string{} - } - - rows := make([][]string, 0) - for i := 0; i < len(cg.Cards); i += columns { - end := i + columns - if end > len(cg.Cards) { - end = len(cg.Cards) - } - rows = append(rows, cg.Cards[i:end]) - } - - return rows -} - -// findMaxHeight finds the maximum height among a set of cards. -func (cg *CardGrid) findMaxHeight(cards []string) int { - maxHeight := 0 - for _, card := range cards { - height := strings.Count(card, "\n") + 1 - if height > maxHeight { - maxHeight = height - } - } - return maxHeight -} - -// equalizeHeights ensures all cards in a row have the same height using lipgloss.Place. -func (cg *CardGrid) equalizeHeights(cards []string, cardWidth int) []string { - if len(cards) == 0 { - return cards - } - - // Find maximum height in this row - maxHeight := cg.findMaxHeight(cards) - - // Equalize all cards to maxHeight - equalized := make([]string, len(cards)) - for i, card := range cards { - // Use lipgloss.Place to center the card vertically within the max height - equalized[i] = lipgloss.Place( - cardWidth, - maxHeight, - lipgloss.Left, // Horizontal alignment - lipgloss.Top, // Vertical alignment (top-align content) - card, - ) - } - - return equalized -} - -// renderRow renders a single row of cards with equal heights. -func (cg *CardGrid) renderRow(cards []string, cardWidth int) string { - if len(cards) == 0 { - return "" - } - - // Equalize heights within the row - equalized := cg.equalizeHeights(cards, cardWidth) - - // Join cards horizontally with column gap - gap := strings.Repeat(" ", cg.ColumnGap) - - // Build the row by joining cards with gaps - // Pre-allocate for cards + gaps (avoids dynamic growth) - parts := make([]string, 0, len(equalized)*2-1) - for i, card := range equalized { - parts = append(parts, card) - if i < len(equalized)-1 { - parts = append(parts, gap) - } - } - - return lipgloss.JoinHorizontal(lipgloss.Top, parts...) -} - -// Render returns the complete card grid as a styled string. -func (cg *CardGrid) Render() string { - if len(cg.Cards) == 0 { - return "" - } - - // Calculate layout - columns := cg.calculateColumns() - cardWidth := cg.calculateCardWidth(columns) - - // Group cards into rows - rows := cg.groupCardsIntoRows(columns) - - // Render each row - renderedRows := make([]string, 0, len(rows)) - for _, row := range rows { - renderedRow := cg.renderRow(row, cardWidth) - renderedRows = append(renderedRows, renderedRow) - } - - // Join rows vertically with row gap - if cg.RowGap > 0 { - gap := strings.Repeat("\n", cg.RowGap) - return lipgloss.JoinVertical(lipgloss.Left, renderedRows...) + gap - } - - return lipgloss.JoinVertical(lipgloss.Left, renderedRows...) -} - -// View is an alias for Render (for Bubble Tea compatibility). -func (cg *CardGrid) View() string { - return cg.Render() -} - -// DefaultCardGridStyle returns default configuration values. -func DefaultCardGridStyle() map[string]int { - return map[string]int{ - "MinWidth": 38, - "MaxWidth": 60, - "Spacing": 3, - "RowGap": 1, - "ColumnGap": 3, - } -} diff --git a/pkg/ui/components/card_grid_example_test.go b/pkg/ui/components/card_grid_example_test.go deleted file mode 100644 index 2234c94..0000000 --- a/pkg/ui/components/card_grid_example_test.go +++ /dev/null @@ -1,147 +0,0 @@ -package components_test - -import ( - "fmt" - - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/components" -) - -// ExampleCardGrid_basic demonstrates basic usage of the CardGrid component. -func ExampleCardGrid_basic() { - // Create simple card content - cards := []string{ - "Card 1\nFirst card content", - "Card 2\nSecond card content", - "Card 3\nThird card content", - "Card 4\nFourth card content", - } - - // Create grid with 2-column layout (width 120) - grid := components.NewCardGrid(cards, 120) - - // Render the grid - output := grid.Render() - fmt.Println(output) -} - -// ExampleCardGrid_responsive demonstrates responsive behavior. -func ExampleCardGrid_responsive() { - cards := []string{ - "Card A", - "Card B", - "Card C", - "Card D", - } - - // Wide terminal: 2 columns - wideGrid := components.NewCardGrid(cards, 120) - fmt.Println("Wide layout (120 cols):") - fmt.Println(wideGrid.Render()) - - // Narrow terminal: 1 column - narrowGrid := components.NewCardGrid(cards, 80) - fmt.Println("\nNarrow layout (80 cols):") - fmt.Println(narrowGrid.Render()) -} - -// ExampleCardGrid_customization demonstrates customizing grid parameters. -func ExampleCardGrid_customization() { - cards := []string{ - "Custom Card 1", - "Custom Card 2", - } - - grid := components.NewCardGrid(cards, 120). - WithMinWidth(40). - WithMaxWidth(70). - WithColumnGap(5). - WithRowGap(2) - - output := grid.Render() - fmt.Println(output) -} - -// ExampleCardGrid_styledCards demonstrates using styled cards with lipgloss. -func ExampleCardGrid_styledCards() { - // Create styled cards using lipgloss - cardStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#00ADD8")). - Padding(1). - Width(40) - - cards := []string{ - cardStyle.Render("System Info\nOS: Darwin\nCPU: M4"), - cardStyle.Render("Runtime\nGo: 1.24\nArch: arm64"), - cardStyle.Render("Profile\nEnterprise\nTheme: Dracula"), - } - - grid := components.NewCardGrid(cards, 120) - output := grid.Render() - - // In actual usage, this would display beautifully styled cards - fmt.Println(output) -} - -// ExampleCardGrid_heightEqualization demonstrates height equalization. -func ExampleCardGrid_heightEqualization() { - // Cards with different heights - cards := []string{ - "Short card", - "Tall card\nLine 2\nLine 3\nLine 4\nLine 5", - "Medium card\nLine 2\nLine 3", - "Another short", - } - - // Heights will be equalized within each row - grid := components.NewCardGrid(cards, 120) - output := grid.Render() - - // Row 1: "Short card" and "Tall card" will have same height - // Row 2: "Medium card" and "Another short" will have same height - fmt.Println(output) -} - -// ExampleCardGrid_dashboardView demonstrates a dashboard-style layout. -func ExampleCardGrid_dashboardView() { - // Simulate dashboard cards - systemCard := `╭─────────────────╮ -│ System Info │ -├─────────────────┤ -│ OS: Darwin │ -│ Arch: arm64 │ -│ CPU: M4 │ -╰─────────────────╯` - - runtimeCard := `╭─────────────────╮ -│ Runtime │ -├─────────────────┤ -│ Go: 1.24 │ -│ Build: 2026-01 │ -╰─────────────────╯` - - profileCard := `╭─────────────────╮ -│ Profile │ -├─────────────────┤ -│ Name: Enterprise│ -│ Theme: Dracula │ -╰─────────────────╯` - - servicesCard := `╭─────────────────╮ -│ Services │ -├─────────────────┤ -│ Running: 3/5 │ -│ Status: Healthy │ -╰─────────────────╯` - - cards := []string{systemCard, runtimeCard, profileCard, servicesCard} - - // Create dashboard layout - grid := components.NewCardGrid(cards, 120) - output := grid.Render() - - fmt.Println("Dashboard View:") - fmt.Println(output) -} diff --git a/pkg/ui/components/card_grid_test.go b/pkg/ui/components/card_grid_test.go deleted file mode 100644 index ebd2b28..0000000 --- a/pkg/ui/components/card_grid_test.go +++ /dev/null @@ -1,763 +0,0 @@ -package components - -import ( - "strings" - "testing" - - "github.com/charmbracelet/lipgloss" -) - -// TestNewCardGrid tests the basic constructor. -func TestNewCardGrid(t *testing.T) { - cards := []string{"card1", "card2", "card3"} - width := 120 - - grid := NewCardGrid(cards, width) - - if grid == nil { - t.Fatal("NewCardGrid returned nil") - } - if grid.Width != width { - t.Errorf("Expected width %d, got %d", width, grid.Width) - } - if len(grid.Cards) != len(cards) { - t.Errorf("Expected %d cards, got %d", len(cards), len(grid.Cards)) - } - if grid.MinWidth != 30 { - t.Errorf("Expected default MinWidth 30, got %d", grid.MinWidth) - } - if grid.MaxWidth != 60 { - t.Errorf("Expected default MaxWidth 60, got %d", grid.MaxWidth) - } - if grid.ColumnGap != 3 { - t.Errorf("Expected default ColumnGap 3, got %d", grid.ColumnGap) - } -} - -// TestCardGrid_WithMethods tests the builder pattern methods. -func TestCardGrid_WithMethods(t *testing.T) { - grid := NewCardGrid([]string{"card"}, 100) - - grid = grid.WithMinWidth(40). - WithMaxWidth(70). - WithColumnGap(5). - WithRowGap(2) - - if grid.MinWidth != 40 { - t.Errorf("Expected MinWidth 40, got %d", grid.MinWidth) - } - if grid.MaxWidth != 70 { - t.Errorf("Expected MaxWidth 70, got %d", grid.MaxWidth) - } - if grid.ColumnGap != 5 { - t.Errorf("Expected ColumnGap 5, got %d", grid.ColumnGap) - } - if grid.RowGap != 2 { - t.Errorf("Expected RowGap 2, got %d", grid.RowGap) - } -} - -// TestCardGrid_CalculateCardWidth tests card width calculation. -func TestCardGrid_CalculateCardWidth(t *testing.T) { - tests := []struct { - name string - width int - columns int - minWidth int - maxWidth int - expected int - }{ - { - name: "2 columns with default constraints", - width: 120, - columns: 2, - minWidth: 38, - maxWidth: 60, - expected: 58, // (120 - 3) / 2 = 58.5 → 58 - }, - { - name: "1 column respects max width", - width: 100, - columns: 1, - minWidth: 38, - maxWidth: 60, - expected: 60, // 100 > maxWidth, so clamp to 60 - }, - { - name: "1 column respects min width", - width: 30, - columns: 1, - minWidth: 38, - maxWidth: 60, - expected: 38, // 30 < minWidth, so clamp to 38 - }, - { - name: "2 columns with narrow width", - width: 90, - columns: 2, - minWidth: 38, - maxWidth: 60, - expected: 43, // (90 - 3) / 2 = 43.5 → 43 - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - grid := NewCardGrid([]string{"card"}, tt.width) - grid.MinWidth = tt.minWidth - grid.MaxWidth = tt.maxWidth - cardWidth := grid.calculateCardWidth(tt.columns) - if cardWidth != tt.expected { - t.Errorf("Expected card width %d, got %d", tt.expected, cardWidth) - } - }) - } -} - -// TestCardGrid_GroupCardsIntoRows tests row grouping logic. -func TestCardGrid_GroupCardsIntoRows(t *testing.T) { - tests := []struct { - name string - cards []string - columns int - expectedRows int - expectedLast int - }{ - { - name: "6 cards, 2 columns", - cards: []string{"1", "2", "3", "4", "5", "6"}, - columns: 2, - expectedRows: 3, - expectedLast: 2, - }, - { - name: "5 cards, 2 columns", - cards: []string{"1", "2", "3", "4", "5"}, - columns: 2, - expectedRows: 3, - expectedLast: 1, - }, - { - name: "3 cards, 1 column", - cards: []string{"1", "2", "3"}, - columns: 1, - expectedRows: 3, - expectedLast: 1, - }, - { - name: "Empty cards", - cards: []string{}, - columns: 2, - expectedRows: 0, - expectedLast: 0, - }, - { - name: "1 card, 2 columns", - cards: []string{"1"}, - columns: 2, - expectedRows: 1, - expectedLast: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - grid := NewCardGrid(tt.cards, 120) - rows := grid.groupCardsIntoRows(tt.columns) - - if len(rows) != tt.expectedRows { - t.Errorf("Expected %d rows, got %d", tt.expectedRows, len(rows)) - } - - if tt.expectedRows > 0 { - lastRowLen := len(rows[len(rows)-1]) - if lastRowLen != tt.expectedLast { - t.Errorf("Expected last row to have %d cards, got %d", tt.expectedLast, lastRowLen) - } - } - }) - } -} - -// TestCardGrid_FindMaxHeight tests height calculation. -func TestCardGrid_FindMaxHeight(t *testing.T) { - tests := []struct { - name string - cards []string - expected int - }{ - { - name: "Single line cards", - cards: []string{"card1", "card2", "card3"}, - expected: 1, - }, - { - name: "Multi-line cards", - cards: []string{"line1\nline2", "line1\nline2\nline3", "line1"}, - expected: 3, - }, - { - name: "Empty cards", - cards: []string{}, - expected: 0, - }, - { - name: "Mixed heights", - cards: []string{"a", "b\nc\nd\ne", "f\ng"}, - expected: 4, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - grid := NewCardGrid([]string{}, 100) - maxHeight := grid.findMaxHeight(tt.cards) - if maxHeight != tt.expected { - t.Errorf("Expected max height %d, got %d", tt.expected, maxHeight) - } - }) - } -} - -// TestCardGrid_EqualizeHeights tests height equalization. -func TestCardGrid_EqualizeHeights(t *testing.T) { - grid := NewCardGrid([]string{}, 100) - cards := []string{ - "Short", - "Line1\nLine2\nLine3", - "A\nB", - } - - equalized := grid.equalizeHeights(cards, 50) - - if len(equalized) != len(cards) { - t.Fatalf("Expected %d equalized cards, got %d", len(cards), len(equalized)) - } - - // All should have the same height (3 lines) - expectedHeight := 3 - for i, card := range equalized { - height := strings.Count(card, "\n") + 1 - if height != expectedHeight { - t.Errorf("Card %d: expected height %d, got %d", i, expectedHeight, height) - } - } -} - -// TestCardGrid_Render_TwoColumns tests 2-column layout rendering. -func TestCardGrid_Render_TwoColumns(t *testing.T) { - cards := []string{ - "Card 1", - "Card 2", - "Card 3", - "Card 4", - } - - grid := NewCardGrid(cards, 120) - rendered := grid.Render() - - if rendered == "" { - t.Fatal("Rendered output should not be empty") - } - - // Should have 2 rows (2 cards per row) - // Verify that we have content - if !strings.Contains(rendered, "Card 1") { - t.Error("Rendered output should contain 'Card 1'") - } - if !strings.Contains(rendered, "Card 4") { - t.Error("Rendered output should contain 'Card 4'") - } -} - -// TestCardGrid_Render_OneColumn tests 1-column layout rendering. -func TestCardGrid_Render_OneColumn(t *testing.T) { - cards := []string{ - "Card A", - "Card B", - "Card C", - } - - grid := NewCardGrid(cards, 80) - rendered := grid.Render() - - if rendered == "" { - t.Fatal("Rendered output should not be empty") - } - - // Verify all cards are present - for _, card := range cards { - if !strings.Contains(rendered, card) { - t.Errorf("Rendered output should contain '%s'", card) - } - } -} - -// TestCardGrid_Render_EmptyCards tests rendering with no cards. -func TestCardGrid_Render_EmptyCards(t *testing.T) { - grid := NewCardGrid([]string{}, 120) - rendered := grid.Render() - - if rendered != "" { - t.Errorf("Expected empty string for empty cards, got: %q", rendered) - } -} - -// TestCardGrid_Render_HeightEqualization tests that heights are equalized within rows. -func TestCardGrid_Render_HeightEqualization(t *testing.T) { - // Create cards with different heights - shortCard := "Short" - tallCard := "Line 1\nLine 2\nLine 3\nLine 4" - - cards := []string{shortCard, tallCard} - - grid := NewCardGrid(cards, 120) - rendered := grid.Render() - - if rendered == "" { - t.Fatal("Rendered output should not be empty") - } - - // Both cards should be present - if !strings.Contains(rendered, "Short") { - t.Error("Rendered output should contain short card content") - } - if !strings.Contains(rendered, "Line 4") { - t.Error("Rendered output should contain tall card content") - } - - // The rendered output should have been height-equalized - // This is hard to verify precisely without parsing ANSI, but we can - // check that lipgloss.Place was used (which adds padding) - lines := strings.Split(rendered, "\n") - if len(lines) < 4 { - t.Errorf("Expected at least 4 lines for equalized heights, got %d", len(lines)) - } -} - -// TestCardGrid_View tests the View method (alias for Render). -func TestCardGrid_View(t *testing.T) { - cards := []string{"Card 1", "Card 2"} - grid := NewCardGrid(cards, 100) - - rendered := grid.Render() - viewed := grid.View() - - if rendered != viewed { - t.Error("View() should return the same output as Render()") - } -} - -// TestCardGrid_Responsive tests responsive behavior across different widths. -// With MinWidth=30 and ColumnGap=3, breakpoints are: -// - 4 cols: 129+ (30×4 + 3×3 = 129) -// - 3 cols: 96-128 (30×3 + 3×2 = 96) -// - 2 cols: 63-95 (30×2 + 3×1 = 63) -// - 1 col: <63 -func TestCardGrid_Responsive(t *testing.T) { - cards := []string{ - "Card 1", - "Card 2", - "Card 3", - "Card 4", - } - - tests := []struct { - name string - width int - expectedColumns int - }{ - { - name: "Wide terminal (120)", - width: 120, - expectedColumns: 3, // 120 >= 96, so 3 columns - }, - { - name: "Medium terminal (80)", - width: 80, - expectedColumns: 2, // 80 >= 63, so 2 columns - }, - { - name: "Exactly at 3-col threshold (96)", - width: 96, - expectedColumns: 3, // 96 >= 96, so 3 columns - }, - { - name: "Just below 3-col threshold (95)", - width: 95, - expectedColumns: 2, // 95 < 96, so 2 columns - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - grid := NewCardGrid(cards, tt.width) - columns := grid.calculateColumns() - - if columns != tt.expectedColumns { - t.Errorf("At width %d, expected %d columns, got %d", tt.width, tt.expectedColumns, columns) - } - - // Also verify rendering works - rendered := grid.Render() - if rendered == "" { - t.Error("Rendered output should not be empty") - } - }) - } -} - -// TestCardGrid_WithSpacing tests backward compatibility with WithSpacing. -func TestCardGrid_WithSpacing(t *testing.T) { - grid := NewCardGrid([]string{"card"}, 100) - grid = grid.WithSpacing(5) - - if grid.Spacing != 5 { - t.Errorf("Expected Spacing 5, got %d", grid.Spacing) - } - if grid.ColumnGap != 5 { - t.Errorf("Expected ColumnGap to sync with Spacing, got %d", grid.ColumnGap) - } -} - -// TestDefaultCardGridStyle tests the default style function. -func TestDefaultCardGridStyle(t *testing.T) { - defaults := DefaultCardGridStyle() - - expectedDefaults := map[string]int{ - "MinWidth": 38, - "MaxWidth": 60, - "Spacing": 3, - "RowGap": 1, - "ColumnGap": 3, - } - - for key, expected := range expectedDefaults { - if val, ok := defaults[key]; !ok { - t.Errorf("Expected default key %q to exist", key) - } else if val != expected { - t.Errorf("Expected default %q to be %d, got %d", key, expected, val) - } - } -} - -// TestCardGrid_RenderRow tests individual row rendering. -func TestCardGrid_RenderRow(t *testing.T) { - grid := NewCardGrid([]string{}, 120) - cards := []string{"Card A", "Card B"} - - row := grid.renderRow(cards, 50) - - if row == "" { - t.Fatal("Rendered row should not be empty") - } - - // Both cards should be in the row - if !strings.Contains(row, "Card A") { - t.Error("Row should contain 'Card A'") - } - if !strings.Contains(row, "Card B") { - t.Error("Row should contain 'Card B'") - } -} - -// TestCardGrid_RenderRow_Empty tests rendering empty row. -func TestCardGrid_RenderRow_Empty(t *testing.T) { - grid := NewCardGrid([]string{}, 120) - row := grid.renderRow([]string{}, 50) - - if row != "" { - t.Errorf("Expected empty string for empty row, got: %q", row) - } -} - -// TestCardGrid_Integration tests full integration with lipgloss styling. -func TestCardGrid_Integration(t *testing.T) { - // Create styled cards using lipgloss - style1 := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#00ADD8")). - Padding(1). - Width(40) - - style2 := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#FF5555")). - Padding(1). - Width(40) - - cards := []string{ - style1.Render("Card 1\nWith multiple\nlines"), - style2.Render("Card 2\nAlso\nmulti-line\nwith more"), - } - - grid := NewCardGrid(cards, 120) - rendered := grid.Render() - - if rendered == "" { - t.Fatal("Rendered output should not be empty") - } - - // Verify content is present (ANSI codes may be present) - if !strings.Contains(rendered, "Card 1") { - t.Error("Should contain Card 1 content") - } - if !strings.Contains(rendered, "Card 2") { - t.Error("Should contain Card 2 content") - } -} - -// BenchmarkCardGrid_Render benchmarks the render performance. -func BenchmarkCardGrid_Render(b *testing.B) { - cards := make([]string, 6) - for i := range cards { - cards[i] = "Card content\nwith multiple\nlines of\ntext" - } - - grid := NewCardGrid(cards, 120) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = grid.Render() - } -} - -// BenchmarkCardGrid_LargeGrid benchmarks a large grid. -func BenchmarkCardGrid_LargeGrid(b *testing.B) { - cards := make([]string, 20) - for i := range cards { - cards[i] = "Card " + string(rune('A'+i)) + "\nContent\nMore\nLines" - } - - grid := NewCardGrid(cards, 120) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = grid.Render() - } -} - -// ============================================================================ -// Phase 5 Tests: Multi-Column Layout (016-ui-layout-fix T067-T068) -// ============================================================================ - -// TestCardGrid_MultiColumnBreakpoints tests the new 2-4 column responsive breakpoints. -// Implements T068 (table-driven tests for column detection at different widths). -func TestCardGrid_MultiColumnBreakpoints(t *testing.T) { - tests := []struct { - name string - width int - expectedCols int - description string - }{ - { - name: "Very wide terminal - 4 columns", - width: 160, - expectedCols: 4, - description: "160 cols triggers 4-column layout (≥129 threshold)", - }, - { - name: "Wide terminal boundary - 4 columns", - width: 129, - expectedCols: 4, - description: "129 cols is exact threshold for 4 columns (30×4 + 3×3)", - }, - { - name: "Standard wide - 3 columns", - width: 100, - expectedCols: 3, - description: "100 cols triggers 3-column layout (96-128)", - }, - { - name: "Medium terminal - 3 columns", - width: 96, - expectedCols: 3, - description: "96 cols is exact threshold for 3 columns (30×3 + 3×2)", - }, - { - name: "Comfortable terminal - 2 columns", - width: 70, - expectedCols: 2, - description: "70 cols triggers 2-column layout (63-95)", - }, - { - name: "Narrow boundary - 2 columns", - width: 63, - expectedCols: 2, - description: "63 cols is exact threshold for 2 columns (30×2 + 3×1)", - }, - { - name: "Narrow terminal - 1 column", - width: 50, - expectedCols: 1, - description: "50 cols falls back to 1 column (<63)", - }, - { - name: "Very narrow - 1 column", - width: 40, - expectedCols: 1, - description: "40 cols uses single column layout", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cards := []string{"card1", "card2", "card3", "card4"} - grid := NewCardGrid(cards, tt.width) - - actualCols := grid.calculateColumns() - - if actualCols != tt.expectedCols { - t.Errorf("%s: expected %d columns, got %d", - tt.description, tt.expectedCols, actualCols) - } - }) - } -} - -// TestCardGrid_WithColumns tests manual column override via WithColumns() method. -// Implements T061 test coverage. -func TestCardGrid_WithColumns(t *testing.T) { - tests := []struct { - name string - width int - manualCols int - expectedCols int - }{ - { - name: "Force 2 columns on wide terminal", - width: 160, - manualCols: 2, - expectedCols: 2, - }, - { - name: "Force 4 columns on narrow terminal", - width: 100, - manualCols: 4, - expectedCols: 4, - }, - { - name: "Force 3 columns", - width: 160, - manualCols: 3, - expectedCols: 3, - }, - { - name: "Force 1 column on wide terminal", - width: 160, - manualCols: 1, - expectedCols: 1, - }, - { - name: "Set to 0 for auto-detect (160 cols)", - width: 160, - manualCols: 0, - expectedCols: 4, - }, - { - name: "Negative value clamped to 0 (auto)", - width: 160, - manualCols: -1, - expectedCols: 4, - }, - { - name: "Value >4 clamped to 4", - width: 200, - manualCols: 10, - expectedCols: 4, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cards := []string{"card1", "card2"} - grid := NewCardGrid(cards, tt.width).WithColumns(tt.manualCols) - - actualCols := grid.calculateColumns() - - if actualCols != tt.expectedCols { - t.Errorf("WithColumns(%d) on width %d: expected %d columns, got %d", - tt.manualCols, tt.width, tt.expectedCols, actualCols) - } - }) - } -} - -// TestCardGrid_EnvironmentVariableOverride tests ARC_DASHBOARD_COLUMNS env var. -// Implements T071 test coverage. -func TestCardGrid_EnvironmentVariableOverride(t *testing.T) { - tests := []struct { - name string - envValue string - width int - expectedCols int - description string - }{ - { - name: "Valid env var: 2 columns", - envValue: "2", - width: 160, - expectedCols: 2, - description: "ARC_DASHBOARD_COLUMNS=2 should override auto-detect", - }, - { - name: "Valid env var: 3 columns", - envValue: "3", - width: 160, - expectedCols: 3, - description: "ARC_DASHBOARD_COLUMNS=3 should force 3 columns", - }, - { - name: "Valid env var: 4 columns", - envValue: "4", - width: 60, - expectedCols: 4, - description: "ARC_DASHBOARD_COLUMNS=4 should work even on narrow terminal", - }, - { - name: "Invalid env var: non-numeric", - envValue: "abc", - width: 160, - expectedCols: 4, - description: "Invalid value should fallback to auto-detect (4 at 120 width)", - }, - { - name: "Invalid env var: out of range (5)", - envValue: "5", - width: 160, - expectedCols: 4, - description: "ARC_DASHBOARD_COLUMNS=5 should fallback to auto-detect", - }, - { - name: "Invalid env var: out of range (0)", - envValue: "0", - width: 100, - expectedCols: 3, - description: "ARC_DASHBOARD_COLUMNS=0 should fallback to auto-detect (3 at 100 width)", - }, - { - name: "Invalid env var: negative", - envValue: "-1", - width: 100, - expectedCols: 3, - description: "Negative value should fallback to auto-detect", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Set environment variable for this test - t.Setenv("ARC_DASHBOARD_COLUMNS", tt.envValue) - - cards := []string{"card1", "card2"} - grid := NewCardGrid(cards, tt.width) - - actualCols := grid.calculateColumns() - - if actualCols != tt.expectedCols { - t.Errorf("%s: expected %d columns, got %d", - tt.description, tt.expectedCols, actualCols) - } - }) - } -} diff --git a/pkg/ui/components/card_test.go b/pkg/ui/components/card_test.go deleted file mode 100644 index 8834445..0000000 --- a/pkg/ui/components/card_test.go +++ /dev/null @@ -1,584 +0,0 @@ -package components - -import ( - "strings" - "testing" - - "github.com/charmbracelet/lipgloss" -) - -func TestNewCard(t *testing.T) { - title := "Test Card" - content := "Test content" - card := NewCard(title, content) - - if card == nil { - t.Fatal("NewCard() returned nil") - } - if card.Title != title { - t.Errorf("Title = %q, want %q", card.Title, title) - } - if card.Content != content { - t.Errorf("Content = %q, want %q", card.Content, content) - } - if card.Width != 40 { - t.Errorf("Width = %d, want 40", card.Width) - } - if !card.ShowBorder { - t.Error("ShowBorder = false, want true") - } - if card.Focused { - t.Error("Focused = true, want false") - } - if !card.TitleBold { - t.Error("TitleBold = false, want true") - } -} - -func TestCard_SetWidth(t *testing.T) { - card := NewCard("Test", "Content") - newWidth := 60 - - result := card.SetWidth(newWidth) - - if result != card { - t.Error("SetWidth() should return self for chaining") - } - if card.Width != newWidth { - t.Errorf("Width = %d, want %d", card.Width, newWidth) - } -} - -func TestCard_SetHeight(t *testing.T) { - card := NewCard("Test", "Content") - newHeight := 15 - - result := card.SetHeight(newHeight) - - if result != card { - t.Error("SetHeight() should return self for chaining") - } - if card.Height != newHeight { - t.Errorf("Height = %d, want %d", card.Height, newHeight) - } -} - -func TestCard_SetFocused(t *testing.T) { - card := NewCard("Test", "Content") - - // Initially unfocused - if card.Focused { - t.Error("Card should initially be unfocused") - } - - // Set focused - result := card.SetFocused(true) - if result != card { - t.Error("SetFocused() should return self for chaining") - } - if !card.Focused { - t.Error("Card should be focused after SetFocused(true)") - } - - // Set unfocused - card.SetFocused(false) - if card.Focused { - t.Error("Card should be unfocused after SetFocused(false)") - } -} - -func TestCard_SetColors(t *testing.T) { - card := NewCard("Test", "Content") - - titleColor := lipgloss.Color("#FF0000") - borderColor := lipgloss.Color("#00FF00") - focusColor := lipgloss.Color("#0000FF") - contentColor := lipgloss.Color("#FFFFFF") - - card.SetTitleColor(titleColor). - SetBorderColor(borderColor). - SetFocusColor(focusColor). - SetContentColor(contentColor) - - if card.TitleColor != titleColor { - t.Error("TitleColor not set correctly") - } - if card.BorderColor != borderColor { - t.Error("BorderColor not set correctly") - } - if card.FocusColor != focusColor { - t.Error("FocusColor not set correctly") - } - if card.ContentColor != contentColor { - t.Error("ContentColor not set correctly") - } -} - -func TestCard_SetPaddingAndMargin(t *testing.T) { - card := NewCard("Test", "Content") - - result := card.SetPadding(2).SetMargin(1) - - if result != card { - t.Error("Chained methods should return self") - } - if card.Padding != 2 { - t.Errorf("Padding = %d, want 2", card.Padding) - } - if card.Margin != 1 { - t.Errorf("Margin = %d, want 1", card.Margin) - } -} - -func TestCard_WithTitle(t *testing.T) { - card := NewCard("Original", "Content") - newTitle := "Updated Title" - - result := card.WithTitle(newTitle) - - if result != card { - t.Error("WithTitle() should return self for chaining") - } - if card.Title != newTitle { - t.Errorf("Title = %q, want %q", card.Title, newTitle) - } -} - -func TestCard_WithContent(t *testing.T) { - card := NewCard("Title", "Original") - newContent := "Updated Content" - - result := card.WithContent(newContent) - - if result != card { - t.Error("WithContent() should return self for chaining") - } - if card.Content != newContent { - t.Errorf("Content = %q, want %q", card.Content, newContent) - } -} - -func TestCard_WithBold(t *testing.T) { - card := NewCard("Title", "Content") - - result := card.WithBold(false) - - if result != card { - t.Error("WithBold() should return self for chaining") - } - if card.TitleBold { - t.Error("TitleBold should be false after WithBold(false)") - } - - card.WithBold(true) - if !card.TitleBold { - t.Error("TitleBold should be true after WithBold(true)") - } -} - -func TestCard_SetBorderTier(t *testing.T) { - tests := []struct { - name string - tier BorderTier - }{ - {"BorderTierNone", BorderTierNone}, - {"BorderTierBlock", BorderTierBlock}, - {"BorderTierClassic", BorderTierClassic}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - card := NewCard("Test", "Content") - result := card.SetBorderTier(tt.tier) - - if result != card { - t.Error("SetBorderTier() should return self for chaining") - } - - // Verify border styles are set appropriately - if !card.ShowBorder { - t.Error("ShowBorder should be true after SetBorderTier()") - } - - // For BorderTierNone, border should be HiddenBorder - if tt.tier == BorderTierNone { - if card.BorderStyle != lipgloss.HiddenBorder() { - t.Error("BorderTierNone should use HiddenBorder") - } - } - }) - } -} - -func TestCard_Render(t *testing.T) { - tests := []struct { - name string - card *Card - wantLen bool // Check if output has content - }{ - { - name: "with title and border", - card: NewCard("Test Title", "Test Content"), - wantLen: true, - }, - { - name: "without title", - card: NewCard("", "Just Content"), - wantLen: true, - }, - { - name: "without border", - card: &Card{ - Title: "No Border", - Content: "Content", - ShowBorder: false, - }, - wantLen: true, - }, - { - name: "empty content", - card: NewCard("Title", ""), - wantLen: true, - }, - { - name: "focused card", - card: NewCard("Focused", "Content").SetFocused(true), - wantLen: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - output := tt.card.Render() - if tt.wantLen && len(output) == 0 { - t.Error("Render() returned empty string") - } - if !tt.wantLen && len(output) > 0 { - t.Error("Render() should return empty string") - } - }) - } -} - -func TestCard_RenderWithTitle(t *testing.T) { - card := NewCard("My Card Title", "My Card Content") - output := card.Render() - - // Output should contain both title and content - if !strings.Contains(output, "My Card Title") { - t.Error("Render() output should contain title") - } - if !strings.Contains(output, "My Card Content") { - t.Error("Render() output should contain content") - } -} - -func TestCard_RenderFocusedVsUnfocused(t *testing.T) { - card := NewCard("Test", "Content") - - // Render unfocused - unfocusedOutput := card.Render() - - // Render focused - card.SetFocused(true) - focusedOutput := card.Render() - - // Both should have content - if len(unfocusedOutput) == 0 { - t.Error("Unfocused render should have content") - } - if len(focusedOutput) == 0 { - t.Error("Focused render should have content") - } - - // They should be different (different border colors) - // Note: We can't easily test the visual difference in unit tests, - // but we can ensure both renders complete without error -} - -func TestCard_View(t *testing.T) { - card := NewCard("Title", "Content") - - view := card.View() - render := card.Render() - - if view != render { - t.Error("View() should return same as Render()") - } -} - -func TestCard_ResponsiveWidth(t *testing.T) { - card := NewCard("Title", "Content") - - widths := []int{30, 40, 60, 80, 100} - for _, width := range widths { - card.SetWidth(width) - output := card.Render() - if len(output) == 0 { - t.Errorf("Render() with width %d returned empty string", width) - } - } -} - -func TestCard_EmptyContentHandling(t *testing.T) { - tests := []struct { - name string - title string - content string - }{ - {"empty title and content", "", ""}, - {"empty title", "", "Content"}, - {"empty content", "Title", ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - card := NewCard(tt.title, tt.content) - output := card.Render() - // Should not panic and should return something - if output == "" && (tt.title != "" || tt.content != "") { - t.Error("Render() should not return empty for non-empty input") - } - }) - } -} - -func TestCard_ChainedOperations(t *testing.T) { - card := NewCard("Initial", "Initial Content"). - SetWidth(80). - SetHeight(20). - SetFocused(true). - WithTitle("Updated Title"). - WithContent("Updated Content"). - SetPadding(2). - SetMargin(1). - WithBold(false) - - if card.Width != 80 { - t.Errorf("Width = %d, want 80", card.Width) - } - if card.Height != 20 { - t.Errorf("Height = %d, want 20", card.Height) - } - if !card.Focused { - t.Error("Focused should be true") - } - if card.Title != "Updated Title" { - t.Errorf("Title = %q, want %q", card.Title, "Updated Title") - } - if card.Content != "Updated Content" { - t.Errorf("Content = %q, want %q", card.Content, "Updated Content") - } - if card.Padding != 2 { - t.Errorf("Padding = %d, want 2", card.Padding) - } - if card.Margin != 1 { - t.Errorf("Margin = %d, want 1", card.Margin) - } - if card.TitleBold { - t.Error("TitleBold should be false") - } -} - -func TestDefaultCardStyle(t *testing.T) { - card := DefaultCardStyle() - - if card == nil { - t.Fatal("DefaultCardStyle() returned nil") - } - if card.TitleColor == "" { - t.Error("DefaultCardStyle() TitleColor should not be empty") - } - if card.BorderColor == "" { - t.Error("DefaultCardStyle() BorderColor should not be empty") - } - if !card.ShowBorder { - t.Error("DefaultCardStyle() ShowBorder should be true") - } -} - -func TestInfoCard(t *testing.T) { - card := InfoCard("Info Title", "Info Content") - - if card == nil { - t.Fatal("InfoCard() returned nil") - } - if card.Title != "Info Title" { - t.Error("InfoCard() should set title") - } - if card.Content != "Info Content" { - t.Error("InfoCard() should set content") - } - if card.TitleColor == "" { - t.Error("InfoCard() TitleColor should not be empty") - } -} - -func TestSuccessCard(t *testing.T) { - card := SuccessCard("Success Title", "Success Content") - - if card == nil { - t.Fatal("SuccessCard() returned nil") - } - if card.Title != "Success Title" { - t.Error("SuccessCard() should set title") - } - if card.Content != "Success Content" { - t.Error("SuccessCard() should set content") - } - if card.TitleColor == "" { - t.Error("SuccessCard() TitleColor should not be empty") - } -} - -func TestErrorCard(t *testing.T) { - card := ErrorCard("Error Title", "Error Content") - - if card == nil { - t.Fatal("ErrorCard() returned nil") - } - if card.Title != "Error Title" { - t.Error("ErrorCard() should set title") - } - if card.Content != "Error Content" { - t.Error("ErrorCard() should set content") - } - if card.TitleColor == "" { - t.Error("ErrorCard() TitleColor should not be empty") - } -} - -func TestWarningCard(t *testing.T) { - card := WarningCard("Warning Title", "Warning Content") - - if card == nil { - t.Fatal("WarningCard() returned nil") - } - if card.Title != "Warning Title" { - t.Error("WarningCard() should set title") - } - if card.Content != "Warning Content" { - t.Error("WarningCard() should set content") - } - if card.TitleColor == "" { - t.Error("WarningCard() TitleColor should not be empty") - } -} - -func TestCard_ANSIWidthHandling(t *testing.T) { - // Test that cards handle ANSI escape codes correctly - // This is critical for border alignment - card := NewCard("Test", "Content with \x1b[31mcolor\x1b[0m") - output := card.Render() - - if len(output) == 0 { - t.Error("Render() should handle ANSI codes without error") - } - - // Ensure the separator width calculation doesn't break with styled content - styledTitle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")).Render("Styled") - card2 := NewCard(styledTitle, "Content") - output2 := card2.Render() - - if len(output2) == 0 { - t.Error("Render() should handle styled title without error") - } -} - -func TestCard_BorderTierTransitions(t *testing.T) { - // Test that changing border tiers works correctly - card := NewCard("Test", "Content") - - // Start with default (RoundedBorder) - initialOutput := card.Render() - - // Switch to Block - card.SetBorderTier(BorderTierBlock) - blockOutput := card.Render() - - // Switch to None - card.SetBorderTier(BorderTierNone) - noneOutput := card.Render() - - // Switch to Classic - card.SetBorderTier(BorderTierClassic) - classicOutput := card.Render() - - // All should render successfully - if len(initialOutput) == 0 || len(blockOutput) == 0 || len(noneOutput) == 0 || len(classicOutput) == 0 { - t.Error("All border tier outputs should have content") - } -} - -func TestCard_MultilineContent(t *testing.T) { - multilineContent := `Line 1 -Line 2 -Line 3 -Line 4` - - card := NewCard("Multiline Test", multilineContent) - output := card.Render() - - if len(output) == 0 { - t.Error("Render() should handle multiline content") - } - - // All lines should be present - if !strings.Contains(output, "Line 1") { - t.Error("Output should contain Line 1") - } - if !strings.Contains(output, "Line 4") { - t.Error("Output should contain Line 4") - } -} - -func TestCard_EdgeCases(t *testing.T) { - tests := []struct { - name string - setup func() *Card - }{ - { - name: "zero width", - setup: func() *Card { - return NewCard("Test", "Content").SetWidth(0) - }, - }, - { - name: "negative width", - setup: func() *Card { - return NewCard("Test", "Content").SetWidth(-10) - }, - }, - { - name: "very large width", - setup: func() *Card { - return NewCard("Test", "Content").SetWidth(1000) - }, - }, - { - name: "zero padding", - setup: func() *Card { - return NewCard("Test", "Content").SetPadding(0) - }, - }, - { - name: "very long title", - setup: func() *Card { - longTitle := strings.Repeat("A", 200) - return NewCard(longTitle, "Content") - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - card := tt.setup() - // Should not panic - output := card.Render() - // Should produce some output - if len(output) == 0 { - t.Error("Render() should not return empty string for edge cases") - } - }) - } -} diff --git a/pkg/ui/components/error.go b/pkg/ui/components/error.go deleted file mode 100644 index d00c5d2..0000000 --- a/pkg/ui/components/error.go +++ /dev/null @@ -1,312 +0,0 @@ -package components - -import ( - "fmt" - "os" - "strings" - - "github.com/charmbracelet/lipgloss" - "golang.org/x/term" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// Severity represents the severity level of an error box. -type Severity int - -const ( - // SeverityError indicates an error condition (red, ✗) - SeverityError Severity = iota - // SeverityWarning indicates a warning condition (orange, ⚠) - SeverityWarning - // SeverityInfo indicates informational message (blue, ℹ) - SeverityInfo - // SeveritySuccess indicates a success condition (green, ✓) - SeveritySuccess -) - -// ErrorOptions configures the appearance and content of an error box. -type ErrorOptions struct { - // Severity level (Error, Warning, Info) - Severity Severity - // Context provides additional context about what failed - Context string - // Hint provides actionable suggestions to fix the issue - Hint string - // ShowStack enables stack trace display (default: false) - ShowStack bool - // Width overrides auto-detected terminal width (0 = auto-detect) - Width int - // Theme provides colors and symbols (required) - Theme *themes.Theme - // IsTTY indicates if output is to a terminal (affects rendering) - IsTTY bool -} - -const ( - // Default widths for responsive rendering - minWidth = 40 - standardWidth = 80 - wideWidth = 120 - maxWidth = 200 - - // Fallback width when detection fails - fallbackWidth = 80 - - // Severity labels - severityLabelError = "Error" - severityLabelWarning = "Warning" - severityLabelInfo = "Information" - - // ASCII symbols for non-TTY - asciiSymbolError = "[ERROR]" - asciiSymbolWarning = "[WARNING]" - asciiSymbolInfo = "[INFO]" -) - -// ErrorBox renders a themed error box with optional context and hints. -// It adapts to terminal width and provides plain-text fallback for non-TTY. -// -// Usage: -// -// box := ErrorBox(err, ErrorOptions{ -// Severity: SeverityError, -// Context: "Failed to initialize workspace", -// Hint: "Try using --force flag", -// Theme: theme, -// IsTTY: true, -// }) -func ErrorBox(err error, opts ErrorOptions) string { - // Validate required options - if opts.Theme == nil { - panic("ErrorBox: Theme is required") - } - - // Handle nil error gracefully - errorMsg := "Unknown error" - if err != nil { - errorMsg = err.Error() - } - - // Determine terminal width - width := opts.Width - if width == 0 { - width = detectTerminalWidth() - } - - // Validate width bounds - if width < minWidth { - width = minWidth - } else if width > maxWidth { - width = maxWidth - } - - // Non-TTY: render plain text without ANSI codes - if !opts.IsTTY { - return renderPlainText(errorMsg, opts) - } - - // TTY: render styled box - return renderStyledBox(errorMsg, opts, width) -} - -// detectTerminalWidth returns the current terminal width or fallback. -func detectTerminalWidth() int { - fd := int(os.Stdout.Fd()) - width, _, err := term.GetSize(fd) - if err != nil || width <= 0 { - return fallbackWidth - } - return width -} - -// renderPlainText renders a plain-text error for non-TTY environments (CI/CD). -func renderPlainText(errorMsg string, opts ErrorOptions) string { - symbol := getASCIISymbol(opts.Severity) - - var parts []string - - // Add context if present - if opts.Context != "" { - parts = append(parts, fmt.Sprintf("%s %s", symbol, opts.Context)) - } else { - parts = append(parts, fmt.Sprintf("%s Error", symbol)) - } - - // Add error message - parts = append(parts, errorMsg) - - // Add hint if present - if opts.Hint != "" { - parts = append(parts, fmt.Sprintf("Hint: %s", opts.Hint)) - } - - return strings.Join(parts, "\n") -} - -// renderStyledBox renders a styled error box with borders and colors. -func renderStyledBox(errorMsg string, opts ErrorOptions, width int) string { - theme := opts.Theme - - // Get colors and symbols based on severity - color := getSeverityColor(opts.Severity, theme) - symbol := getSeveritySymbol(opts.Severity, theme) - - // Calculate content width (accounting for borders and padding) - contentWidth := width - 4 // 2 for borders, 2 for padding - if contentWidth < 20 { - contentWidth = 20 - } - - // Build content sections - var contentParts []string - - // Add context with symbol - if opts.Context != "" { - contextStyle := lipgloss.NewStyle(). - Foreground(color). - Bold(true) - contextLine := fmt.Sprintf("%s %s", symbol, opts.Context) - contentParts = append(contentParts, - contextStyle.Render(contextLine), - "", // Blank line - ) - } else { - // No context, just show symbol + severity label - contextStyle := lipgloss.NewStyle(). - Foreground(color). - Bold(true) - severityLabel := getSeverityLabel(opts.Severity) - contextLine := fmt.Sprintf("%s %s", symbol, severityLabel) - contentParts = append(contentParts, - contextStyle.Render(contextLine), - "", // Blank line - ) - } - - // Add error message (word-wrapped) - wrappedError := wrapText(errorMsg, contentWidth) - contentParts = append(contentParts, wrappedError) - - // Add hint if present - if opts.Hint != "" { - contentParts = append(contentParts, "") // Blank line - hintStyle := lipgloss.NewStyle(). - Foreground(theme.Colors.MutedColor()). - Italic(true) - hintText := "Hint: " + opts.Hint - wrappedHint := wrapText(hintText, contentWidth) - contentParts = append(contentParts, hintStyle.Render(wrappedHint)) - } - - // Join all content - content := strings.Join(contentParts, "\n") - - // Create bordered box - boxStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(color). - Padding(0, 1). - Width(width - 4) // Account for border width - - return boxStyle.Render(content) -} - -// getSeverityColor returns the theme color for a severity level. -func getSeverityColor(severity Severity, theme *themes.Theme) lipgloss.Color { - switch severity { - case SeverityError: - return theme.Colors.ErrorColor() - case SeverityWarning: - return theme.Colors.WarningColor() - case SeverityInfo: - return theme.Colors.InfoColor() - default: - return theme.Colors.ErrorColor() - } -} - -// getSeveritySymbol returns the theme symbol for a severity level. -func getSeveritySymbol(severity Severity, theme *themes.Theme) string { - switch severity { - case SeverityError: - return theme.Symbols.Error - case SeverityWarning: - return theme.Symbols.Warning - case SeverityInfo: - return theme.Symbols.Info - default: - return theme.Symbols.Error - } -} - -// getASCIISymbol returns ASCII fallback symbols for non-TTY. -func getASCIISymbol(severity Severity) string { - switch severity { - case SeverityError: - return asciiSymbolError - case SeverityWarning: - return asciiSymbolWarning - case SeverityInfo: - return asciiSymbolInfo - default: - return asciiSymbolError - } -} - -// getSeverityLabel returns a human-readable label for a severity level. -func getSeverityLabel(severity Severity) string { - switch severity { - case SeverityError: - return severityLabelError - case SeverityWarning: - return severityLabelWarning - case SeverityInfo: - return severityLabelInfo - default: - return severityLabelError - } -} - -// wrapText wraps text to fit within the specified width. -func wrapText(text string, width int) string { - if width <= 0 { - return text - } - - var wrapped []string - words := strings.Fields(text) - if len(words) == 0 { - return text - } - - var currentLine strings.Builder - lineLength := 0 - - for i, word := range words { - wordLen := lipgloss.Width(word) - - // First word on line or word fits on current line - if lineLength == 0 { - currentLine.WriteString(word) - lineLength = wordLen - } else if lineLength+1+wordLen <= width { - currentLine.WriteString(" ") - currentLine.WriteString(word) - lineLength += 1 + wordLen - } else { - // Word doesn't fit, start new line - wrapped = append(wrapped, currentLine.String()) - currentLine.Reset() - currentLine.WriteString(word) - lineLength = wordLen - } - - // Last word: add the line - if i == len(words)-1 { - wrapped = append(wrapped, currentLine.String()) - } - } - - return strings.Join(wrapped, "\n") -} diff --git a/pkg/ui/components/error_example_test.go b/pkg/ui/components/error_example_test.go deleted file mode 100644 index d74c5a3..0000000 --- a/pkg/ui/components/error_example_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package components_test - -import ( - "errors" - "fmt" - - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// ExampleErrorBox demonstrates basic ErrorBox usage with error severity. -func ExampleErrorBox() { - // Load default theme - theme, _ := themes.GetDefault() - - // Create an error - err := errors.New("database connection failed") - - // Render error box with context and hint - opts := components.ErrorOptions{ - Severity: components.SeverityError, - Context: "Failed to connect to database", - Hint: "Check that the database service is running", - Theme: theme, - IsTTY: false, // Plain text for example output - } - - output := components.ErrorBox(err, opts) - fmt.Println(output) - - // Output will show: - // [ERROR] Failed to connect to database - // database connection failed - // Hint: Check that the database service is running -} - -// ExampleErrorBox_warning demonstrates ErrorBox usage with warning severity. -func ExampleErrorBox_warning() { - // Load default theme - theme, _ := themes.GetDefault() - - // Create a warning - warning := errors.New("configuration file missing some optional fields") - - // Render warning box - opts := components.ErrorOptions{ - Severity: components.SeverityWarning, - Context: "Incomplete configuration", - Hint: "Add missing fields or accept defaults", - Theme: theme, - IsTTY: false, - } - - output := components.ErrorBox(warning, opts) - fmt.Println(output) - - // Output will show: - // [WARNING] Incomplete configuration - // configuration file missing some optional fields - // Hint: Add missing fields or accept defaults -} - -// ExampleErrorBox_info demonstrates ErrorBox usage with info severity. -func ExampleErrorBox_info() { - // Load default theme - theme, _ := themes.GetDefault() - - // Create an informational message - info := errors.New("workspace initialized successfully") - - // Render info box - opts := components.ErrorOptions{ - Severity: components.SeverityInfo, - Hint: "Run 'arc workspace run' to start", - Theme: theme, - IsTTY: false, - } - - output := components.ErrorBox(info, opts) - fmt.Println(output) - - // Output will show: - // [INFO] Information - // workspace initialized successfully - // Hint: Run 'arc workspace run' to start -} diff --git a/pkg/ui/components/error_test.go b/pkg/ui/components/error_test.go deleted file mode 100644 index 43734f2..0000000 --- a/pkg/ui/components/error_test.go +++ /dev/null @@ -1,539 +0,0 @@ -package components - -import ( - "errors" - "strings" - "testing" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// createTestTheme creates a minimal theme for testing. -func createTestTheme() *themes.Theme { - return &themes.Theme{ - Name: "test-theme", - Description: "Test theme for error box", - Version: "1.0.0", - Author: "Test", - Colors: themes.ColorSet{ - Primary: "#00ADD8", - Secondary: "#6272A4", - Success: "#00E091", - Error: "#FF4444", - Warning: "#FFB86C", - Info: "#00ADD8", - Foreground: "#F8F8F2", - Background: "#282A36", - Muted: "#6272A4", - Border: "#6272A4", - BannerGradient: []string{"#00ADD8", "#6272A4"}, - }, - Symbols: themes.SymbolSet{ - Success: "✓", - Error: "✗", - Warning: "⚠", - Info: "ℹ", - Bullet: "•", - Arrow: "→", - Spinner: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}, - }, - } -} - -func TestErrorBox_NilError(t *testing.T) { - t.Parallel() - - theme := createTestTheme() - opts := ErrorOptions{ - Severity: SeverityError, - Theme: theme, - IsTTY: false, - } - - result := ErrorBox(nil, opts) - - if !strings.Contains(result, "Unknown error") { - t.Errorf("Expected 'Unknown error' for nil error, got: %s", result) - } -} - -func TestErrorBox_SimpleError(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - err error - severity Severity - isTTY bool - }{ - { - name: "simple error non-TTY", - err: errors.New("test error"), - severity: SeverityError, - isTTY: false, - }, - { - name: "simple error TTY", - err: errors.New("test error"), - severity: SeverityError, - isTTY: true, - }, - { - name: "simple warning non-TTY", - err: errors.New("test warning"), - severity: SeverityWarning, - isTTY: false, - }, - { - name: "simple info non-TTY", - err: errors.New("test info"), - severity: SeverityInfo, - isTTY: false, - }, - } - - theme := createTestTheme() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - opts := ErrorOptions{ - Severity: tt.severity, - Theme: theme, - IsTTY: tt.isTTY, - } - - result := ErrorBox(tt.err, opts) - - if !strings.Contains(result, tt.err.Error()) { - t.Errorf("Expected error message '%s' in result, got: %s", tt.err.Error(), result) - } - }) - } -} - -func TestErrorBox_WithContext(t *testing.T) { - t.Parallel() - - theme := createTestTheme() - err := errors.New("configuration file not found") - context := "Failed to initialize workspace" - - opts := ErrorOptions{ - Severity: SeverityError, - Context: context, - Theme: theme, - IsTTY: false, - } - - result := ErrorBox(err, opts) - - if !strings.Contains(result, context) { - t.Errorf("Expected context '%s' in result, got: %s", context, result) - } - if !strings.Contains(result, err.Error()) { - t.Errorf("Expected error message in result, got: %s", result) - } -} - -func TestErrorBox_WithHint(t *testing.T) { - t.Parallel() - - theme := createTestTheme() - err := errors.New("workspace already exists") - hint := "Use 'arc workspace init --force' to reinitialize" - - opts := ErrorOptions{ - Severity: SeverityError, - Hint: hint, - Theme: theme, - IsTTY: false, - } - - result := ErrorBox(err, opts) - - if !strings.Contains(result, hint) { - t.Errorf("Expected hint '%s' in result, got: %s", hint, result) - } - if !strings.Contains(result, err.Error()) { - t.Errorf("Expected error message in result, got: %s", result) - } -} - -func TestErrorBox_WithContextAndHint(t *testing.T) { - t.Parallel() - - theme := createTestTheme() - err := errors.New("Docker daemon not running") - context := "Failed to start services" - hint := "Start Docker Desktop or run 'systemctl start docker'" - - opts := ErrorOptions{ - Severity: SeverityError, - Context: context, - Hint: hint, - Theme: theme, - IsTTY: false, - } - - result := ErrorBox(err, opts) - - if !strings.Contains(result, context) { - t.Errorf("Expected context '%s' in result, got: %s", context, result) - } - if !strings.Contains(result, hint) { - t.Errorf("Expected hint '%s' in result, got: %s", hint, result) - } - if !strings.Contains(result, err.Error()) { - t.Errorf("Expected error message in result, got: %s", result) - } -} - -func TestErrorBox_AllSeverityLevels(t *testing.T) { - t.Parallel() - - theme := createTestTheme() - err := errors.New("test message") - - tests := []struct { - name string - severity Severity - symbol string - }{ - { - name: "error severity", - severity: SeverityError, - symbol: "[ERROR]", - }, - { - name: "warning severity", - severity: SeverityWarning, - symbol: "[WARNING]", - }, - { - name: "info severity", - severity: SeverityInfo, - symbol: "[INFO]", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - opts := ErrorOptions{ - Severity: tt.severity, - Theme: theme, - IsTTY: false, - } - - result := ErrorBox(err, opts) - - if !strings.Contains(result, tt.symbol) { - t.Errorf("Expected symbol '%s' in result, got: %s", tt.symbol, result) - } - }) - } -} - -func TestErrorBox_LongMessage(t *testing.T) { - t.Parallel() - - theme := createTestTheme() - longMsg := strings.Repeat("This is a very long error message that should be wrapped. ", 10) - err := errors.New(longMsg) - - opts := ErrorOptions{ - Severity: SeverityError, - Theme: theme, - IsTTY: true, - Width: 80, - } - - result := ErrorBox(err, opts) - - // Should contain the error message (wrapped) - if !strings.Contains(result, "This is a very long error message") { - t.Errorf("Expected wrapped error message in result") - } - - // Result should have multiple lines due to wrapping - lines := strings.Split(result, "\n") - if len(lines) < 3 { - t.Errorf("Expected multiple lines for wrapped text, got %d lines", len(lines)) - } -} - -func TestErrorBox_TerminalWidthBreakpoints(t *testing.T) { - t.Parallel() - - theme := createTestTheme() - err := errors.New("test error message") - - tests := []struct { - name string - width int - }{ - {"narrow terminal", 40}, - {"standard terminal", 80}, - {"wide terminal", 120}, - {"extra-wide terminal", 200}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - opts := ErrorOptions{ - Severity: SeverityError, - Theme: theme, - IsTTY: true, - Width: tt.width, - } - - result := ErrorBox(err, opts) - - // Should contain error message - if !strings.Contains(result, err.Error()) { - t.Errorf("Expected error message in result for width %d", tt.width) - } - }) - } -} - -func TestErrorBox_WidthBounds(t *testing.T) { - t.Parallel() - - theme := createTestTheme() - err := errors.New("test error") - - tests := []struct { - name string - width int - expectMin bool - }{ - {"below minimum", 10, true}, - {"minimum width", 40, false}, - {"above maximum", 300, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - opts := ErrorOptions{ - Severity: SeverityError, - Theme: theme, - IsTTY: true, - Width: tt.width, - } - - result := ErrorBox(err, opts) - - // Should not panic and should contain error message - if !strings.Contains(result, err.Error()) { - t.Errorf("Expected error message in result") - } - }) - } -} - -func TestErrorBox_PanicOnNilTheme(t *testing.T) { - t.Parallel() - - defer func() { - if r := recover(); r == nil { - t.Errorf("Expected panic for nil theme") - } - }() - - err := errors.New("test error") - opts := ErrorOptions{ - Severity: SeverityError, - Theme: nil, // nil theme should panic - IsTTY: false, - } - - _ = ErrorBox(err, opts) -} - -func TestErrorBox_NonTTYRendering(t *testing.T) { - t.Parallel() - - theme := createTestTheme() - err := errors.New("test error") - - opts := ErrorOptions{ - Severity: SeverityError, - Context: "Test context", - Hint: "Test hint", - Theme: theme, - IsTTY: false, - } - - result := ErrorBox(err, opts) - - // Non-TTY should not contain ANSI escape codes (lipgloss adds these for borders) - // Check for absence of common border characters - if strings.Contains(result, "╭") || strings.Contains(result, "╮") { - t.Errorf("Non-TTY output should not contain borders, got: %s", result) - } - - // Should contain plain text symbol - if !strings.Contains(result, "[ERROR]") { - t.Errorf("Non-TTY output should contain ASCII symbol [ERROR]") - } -} - -func TestWrapText(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - text string - width int - expected int // expected number of lines - }{ - { - name: "short text no wrap", - text: "Short text", - width: 80, - expected: 1, - }, - { - name: "long text wraps", - text: "This is a very long text that should definitely wrap when given a narrow width constraint", - width: 20, - expected: 5, // Should wrap to multiple lines - }, - { - name: "empty text", - text: "", - width: 80, - expected: 1, - }, - { - name: "single word", - text: "Word", - width: 80, - expected: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - result := wrapText(tt.text, tt.width) - lines := strings.Split(result, "\n") - - if len(lines) < tt.expected { - t.Errorf("Expected at least %d lines, got %d. Result: %s", tt.expected, len(lines), result) - } - }) - } -} - -func TestGetSeverityColor(t *testing.T) { - t.Parallel() - - theme := createTestTheme() - - tests := []struct { - name string - severity Severity - }{ - {"error color", SeverityError}, - {"warning color", SeverityWarning}, - {"info color", SeverityInfo}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - color := getSeverityColor(tt.severity, theme) - if color == "" { - t.Errorf("Expected non-empty color for %v", tt.severity) - } - }) - } -} - -func TestGetSeveritySymbol(t *testing.T) { - t.Parallel() - - theme := createTestTheme() - - tests := []struct { - name string - severity Severity - expected string - }{ - {"error symbol", SeverityError, "✗"}, - {"warning symbol", SeverityWarning, "⚠"}, - {"info symbol", SeverityInfo, "ℹ"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - symbol := getSeveritySymbol(tt.severity, theme) - if symbol != tt.expected { - t.Errorf("Expected symbol '%s', got '%s'", tt.expected, symbol) - } - }) - } -} - -func TestGetASCIISymbol(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - severity Severity - expected string - }{ - {"error ASCII", SeverityError, "[ERROR]"}, - {"warning ASCII", SeverityWarning, "[WARNING]"}, - {"info ASCII", SeverityInfo, "[INFO]"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - symbol := getASCIISymbol(tt.severity) - if symbol != tt.expected { - t.Errorf("Expected ASCII symbol '%s', got '%s'", tt.expected, symbol) - } - }) - } -} - -func TestGetSeverityLabel(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - severity Severity - expected string - }{ - {"error label", SeverityError, "Error"}, - {"warning label", SeverityWarning, "Warning"}, - {"info label", SeverityInfo, "Information"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - label := getSeverityLabel(tt.severity) - if label != tt.expected { - t.Errorf("Expected label '%s', got '%s'", tt.expected, label) - } - }) - } -} diff --git a/pkg/ui/components/footer.go b/pkg/ui/components/footer.go deleted file mode 100644 index 4b0a24c..0000000 --- a/pkg/ui/components/footer.go +++ /dev/null @@ -1,189 +0,0 @@ -package components - -import ( - "strings" - - "github.com/charmbracelet/lipgloss" -) - -// KeyBinding represents a single keyboard control binding. -// Used to display available shortcuts in the footer. -type KeyBinding struct { - Key string // e.g., "Tab", "q", "?" - Description string // e.g., "Next", "Quit", "Help" -} - -// Footer renders the persistent footer at the bottom of the dashboard. -// It displays keyboard controls on the left and version/commit info on the right. -// -// Design: 016-ui-layout-fix Phase 4 (US2) -// Requirements: FR-008 through FR-013 -type Footer struct { - themeProvider ThemeProvider - controls []KeyBinding - version string - commit string - width int -} - -// NewFooter creates a new Footer component with controls and version info. -// The themeProvider gives access to profile-themed colors. -// ComponentFactory from pkg/ui implements ThemeProvider interface. -// -// Example: -// -// controls := []KeyBinding{ -// {Key: "Tab", Description: "Next"}, -// {Key: "q", Description: "Quit"}, -// } -// footer := NewFooter(factory, controls, "v1.0.0", "abc1234", 80) -// rendered := footer.Render() -func NewFooter(themeProvider ThemeProvider, controls []KeyBinding, version, commit string, width int) *Footer { - return &Footer{ - themeProvider: themeProvider, - controls: controls, - version: version, - commit: commit, - width: width, - } -} - -// WithControls updates the keybinding controls. -// Returns the footer for method chaining. -func (f *Footer) WithControls(controls []KeyBinding) *Footer { - f.controls = controls - return f -} - -// WithVersion updates the version and commit hash. -// Returns the footer for method chaining. -func (f *Footer) WithVersion(version, commit string) *Footer { - f.version = version - f.commit = commit - return f -} - -// SetWidth updates the footer width. -// Returns the footer for method chaining. -func (f *Footer) SetWidth(width int) *Footer { - f.width = width - return f -} - -// Render returns the footer as a styled string. -// Layout: [controls on left] [version+commit on right] -// Example: "Tab: Next | q: Quit | ?: Help v1.2.3 [abc1234]" -func (f *Footer) Render() string { - theme := f.themeProvider.Theme() - - // Format controls (left side) - controlsText := f.formatControls() - - // Format version (right side) - versionText := f.formatVersion() - - // Calculate spacing between left and right - controlsWidth := lipgloss.Width(controlsText) - versionWidth := lipgloss.Width(versionText) - totalContentWidth := controlsWidth + versionWidth - - // If content fits, add spacing; otherwise truncate - var result string - if totalContentWidth < f.width { - spacing := f.width - totalContentWidth - result = controlsText + strings.Repeat(" ", spacing) + versionText - } else { - // Truncate controls to fit, keeping version visible - maxControlsWidth := f.width - versionWidth - 3 // Reserve 3 for "..." - if maxControlsWidth > 0 { - truncatedControls := f.truncateControls(maxControlsWidth) - result = truncatedControls + strings.Repeat(" ", f.width-lipgloss.Width(truncatedControls)-versionWidth) + versionText - } else { - // Terminal too narrow - show version only - result = versionText - } - } - - // Style footer with muted color - style := lipgloss.NewStyle(). - Foreground(theme.Colors.MutedColor()) - - return style.Render(result) -} - -// formatControls formats keyboard controls as "Key: Desc | Key: Desc" -func (f *Footer) formatControls() string { - if len(f.controls) == 0 { - return "" - } - - parts := make([]string, 0, len(f.controls)) - for _, ctrl := range f.controls { - parts = append(parts, ctrl.Key+": "+ctrl.Description) - } - - return strings.Join(parts, " | ") -} - -// formatVersion formats version and commit as "vX.Y.Z [commit]" -// If commit is empty, returns version only. -func (f *Footer) formatVersion() string { - if f.commit != "" { - return f.version + " [" + f.commit + "]" - } - return f.version -} - -// truncateControls truncates the control list to fit maxWidth. -// Prioritizes showing as many complete controls as possible. -func (f *Footer) truncateControls(maxWidth int) string { - if len(f.controls) == 0 { - return "" - } - - parts := make([]string, 0, len(f.controls)) - currentWidth := 0 - - for i, ctrl := range f.controls { - part := ctrl.Key + ": " + ctrl.Description - partWidth := lipgloss.Width(part) - - // Add separator width if not first item - if i > 0 { - partWidth += 3 // " | " - } - - if currentWidth+partWidth > maxWidth { - // Can't fit this control, stop here - break - } - - parts = append(parts, part) - currentWidth += partWidth - } - - if len(parts) == 0 { - return "..." - } - - result := strings.Join(parts, " | ") - - // Add ellipsis if we truncated - if len(parts) < len(f.controls) { - if currentWidth+4 <= maxWidth { // Room for " ..." - result += " ..." - } - } - - return result -} - -// Height returns the height of the footer (always 1 line). -func (f *Footer) Height() int { - return 1 -} - -// View is an alias for Render (for Bubble Tea compatibility). -func (f *Footer) View() string { - return f.Render() -} diff --git a/pkg/ui/components/footer_test.go b/pkg/ui/components/footer_test.go deleted file mode 100644 index a55a781..0000000 --- a/pkg/ui/components/footer_test.go +++ /dev/null @@ -1,437 +0,0 @@ -package components - -import ( - "strings" - "testing" -) - -// TestNewFooter verifies the Footer constructor. -func TestNewFooter(t *testing.T) { - provider := newMockThemeProvider() - controls := []KeyBinding{ - {Key: "Tab", Description: "Next"}, - {Key: "q", Description: "Quit"}, - } - - footer := NewFooter(provider, controls, "v1.0.0", "abc1234", 80) - - if footer == nil { - t.Fatal("NewFooter returned nil") - } - if footer.themeProvider == nil { - t.Error("Footer themeProvider is nil") - } - if len(footer.controls) != 2 { - t.Errorf("Expected 2 controls, got %d", len(footer.controls)) - } - if footer.version != "v1.0.0" { - t.Errorf("Expected version v1.0.0, got %s", footer.version) - } - if footer.commit != "abc1234" { - t.Errorf("Expected commit abc1234, got %s", footer.commit) - } - if footer.width != 80 { - t.Errorf("Expected width 80, got %d", footer.width) - } -} - -// TestFooterWithControls verifies the WithControls method. -func TestFooterWithControls(t *testing.T) { - provider := newMockThemeProvider() - initialControls := []KeyBinding{{Key: "q", Description: "Quit"}} - footer := NewFooter(provider, initialControls, "v1.0.0", "", 80) - - newControls := []KeyBinding{ - {Key: "Enter", Description: "Select"}, - {Key: "Esc", Description: "Back"}, - } - - footer.WithControls(newControls) - - if len(footer.controls) != 2 { - t.Errorf("Expected 2 controls after update, got %d", len(footer.controls)) - } - if footer.controls[0].Key != "Enter" { - t.Errorf("Expected first control key 'Enter', got '%s'", footer.controls[0].Key) - } -} - -// TestFooterWithVersion verifies the WithVersion method. -func TestFooterWithVersion(t *testing.T) { - provider := newMockThemeProvider() - footer := NewFooter(provider, nil, "v1.0.0", "abc1234", 80) - - footer.WithVersion("v2.0.0", "def5678") - - if footer.version != "v2.0.0" { - t.Errorf("Expected version v2.0.0, got %s", footer.version) - } - if footer.commit != "def5678" { - t.Errorf("Expected commit def5678, got %s", footer.commit) - } -} - -// TestFooterSetWidth verifies the SetWidth method. -func TestFooterSetWidth(t *testing.T) { - provider := newMockThemeProvider() - footer := NewFooter(provider, nil, "v1.0.0", "", 80) - - footer.SetWidth(120) - - if footer.width != 120 { - t.Errorf("Expected width 120, got %d", footer.width) - } -} - -// TestFooterHeight verifies the Height method. -func TestFooterHeight(t *testing.T) { - provider := newMockThemeProvider() - footer := NewFooter(provider, nil, "v1.0.0", "", 80) - - height := footer.Height() - - if height != 1 { - t.Errorf("Expected height 1, got %d", height) - } -} - -// TestFooterFormatControls verifies control formatting. -func TestFooterFormatControls(t *testing.T) { - provider := newMockThemeProvider() - - tests := []struct { - name string - controls []KeyBinding - contains []string // Substrings that should be in output - }{ - { - name: "single control", - controls: []KeyBinding{ - {Key: "q", Description: "Quit"}, - }, - contains: []string{"q: Quit"}, - }, - { - name: "multiple controls", - controls: []KeyBinding{ - {Key: "Tab", Description: "Next"}, - {Key: "q", Description: "Quit"}, - {Key: "?", Description: "Help"}, - }, - contains: []string{"Tab: Next", "q: Quit", "?: Help", " | "}, - }, - { - name: "empty controls", - controls: []KeyBinding{}, - contains: []string{}, // Empty output - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - footer := NewFooter(provider, tt.controls, "v1.0.0", "", 80) - output := footer.formatControls() - - for _, substr := range tt.contains { - if !strings.Contains(output, substr) { - t.Errorf("Expected output to contain %q, got: %s", substr, output) - } - } - }) - } -} - -// TestFooterFormatVersion verifies version formatting. -func TestFooterFormatVersion(t *testing.T) { - provider := newMockThemeProvider() - - tests := []struct { - name string - version string - commit string - expected string - }{ - { - name: "version with commit", - version: "v1.0.0", - commit: "abc1234", - expected: "v1.0.0 [abc1234]", - }, - { - name: "version without commit", - version: "v1.0.0", - commit: "", - expected: "v1.0.0", - }, - { - name: "dev version with commit", - version: "vdev-branch", - commit: "xyz9876", - expected: "vdev-branch [xyz9876]", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - footer := NewFooter(provider, nil, tt.version, tt.commit, 80) - output := footer.formatVersion() - - if output != tt.expected { - t.Errorf("Expected %q, got %q", tt.expected, output) - } - }) - } -} - -// TestFooterRenderWithDifferentWidths verifies footer adapts to terminal width. -func TestFooterRenderWithDifferentWidths(t *testing.T) { - provider := newMockThemeProvider() - controls := []KeyBinding{ - {Key: "Tab", Description: "Next"}, - {Key: "q", Description: "Quit"}, - {Key: "?", Description: "Help"}, - } - - widths := []int{40, 60, 80, 120} - - for _, width := range widths { - t.Run(string(rune(width))+" columns", func(t *testing.T) { - footer := NewFooter(provider, controls, "v1.0.0", "abc1234", width) - rendered := footer.Render() - - if rendered == "" { - t.Error("Expected non-empty footer") - } - - // Verify rendered content doesn't exceed terminal width - // Note: lipgloss may strip ANSI codes in non-TTY environment - plainRendered := stripANSI(rendered) - renderedWidth := len(plainRendered) - - // Allow slight overflow for ANSI codes - if renderedWidth > width+10 { - t.Errorf("Footer width %d exceeds terminal width %d", renderedWidth, width) - } - }) - } -} - -// TestFooterTruncation verifies truncation on narrow terminals. -func TestFooterTruncation(t *testing.T) { - provider := newMockThemeProvider() - controls := []KeyBinding{ - {Key: "Tab", Description: "Next"}, - {Key: "Shift+Tab", Description: "Previous"}, - {Key: "Enter", Description: "Select"}, - {Key: "Esc", Description: "Cancel"}, - {Key: "q", Description: "Quit"}, - {Key: "?", Description: "Help"}, - } - - tests := []struct { - name string - width int - expectTruncation bool - minControlsShown int // Minimum number of controls that should be visible - }{ - { - name: "wide terminal - no truncation", - width: 120, - expectTruncation: false, - minControlsShown: 6, - }, - { - name: "medium terminal - possible truncation", - width: 80, - expectTruncation: false, - minControlsShown: 4, - }, - { - name: "narrow terminal - truncation expected", - width: 50, - expectTruncation: true, - minControlsShown: 2, - }, - { - name: "very narrow terminal - aggressive truncation", - width: 40, - expectTruncation: true, - minControlsShown: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - footer := NewFooter(provider, controls, "v1.0.0", "abc1234", tt.width) - rendered := footer.Render() - - if rendered == "" { - t.Error("Expected non-empty footer") - } - - // Check for truncation indicator - plainRendered := stripANSI(rendered) - hasTruncation := strings.Contains(plainRendered, "...") - - if tt.expectTruncation && !hasTruncation { - t.Log("Note: Expected truncation indicator '...' but not found (may be OK if all controls fit)") - } - - // Verify version is always visible (unless terminal is extremely narrow) - if tt.width >= 30 && !strings.Contains(plainRendered, "v1.0.0") { - t.Error("Expected version to be visible") - } - }) - } -} - -// TestFooterRenderStructure verifies footer layout structure. -func TestFooterRenderStructure(t *testing.T) { - provider := newMockThemeProvider() - - tests := []struct { - name string - controls []KeyBinding - version string - commit string - width int - expectControlsLeft bool - expectVersionRight bool - }{ - { - name: "full footer with controls and version", - controls: []KeyBinding{ - {Key: "Tab", Description: "Next"}, - {Key: "q", Description: "Quit"}, - }, - version: "v1.0.0", - commit: "abc1234", - width: 80, - expectControlsLeft: true, - expectVersionRight: true, - }, - { - name: "footer with version only (no controls)", - controls: []KeyBinding{}, - version: "v1.0.0", - commit: "abc1234", - width: 80, - expectControlsLeft: false, - expectVersionRight: true, - }, - { - name: "footer with controls only (no commit)", - controls: []KeyBinding{ - {Key: "q", Description: "Quit"}, - }, - version: "v1.0.0", - commit: "", - width: 80, - expectControlsLeft: true, - expectVersionRight: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - footer := NewFooter(provider, tt.controls, tt.version, tt.commit, tt.width) - rendered := footer.Render() - plainRendered := stripANSI(rendered) - - if tt.expectControlsLeft && len(tt.controls) > 0 { - if !strings.Contains(plainRendered, tt.controls[0].Key) { - t.Error("Expected controls to be visible on left side") - } - } - - if tt.expectVersionRight { - if !strings.Contains(plainRendered, tt.version) { - t.Error("Expected version to be visible on right side") - } - } - }) - } -} - -// TestFooterView verifies the View alias method. -func TestFooterView(t *testing.T) { - provider := newMockThemeProvider() - footer := NewFooter(provider, nil, "v1.0.0", "", 80) - - rendered := footer.Render() - viewed := footer.View() - - if rendered != viewed { - t.Error("View() and Render() should return the same output") - } -} - -// TestFooterMethodChaining verifies fluent interface pattern. -func TestFooterMethodChaining(t *testing.T) { - provider := newMockThemeProvider() - - newControls := []KeyBinding{{Key: "Enter", Description: "Select"}} - - footer := NewFooter(provider, nil, "v1.0.0", "", 80). - WithControls(newControls). - WithVersion("v2.0.0", "xyz9876"). - SetWidth(120) - - if len(footer.controls) != 1 { - t.Errorf("Expected 1 control, got %d", len(footer.controls)) - } - if footer.version != "v2.0.0" { - t.Errorf("Expected version v2.0.0, got %s", footer.version) - } - if footer.commit != "xyz9876" { - t.Errorf("Expected commit xyz9876, got %s", footer.commit) - } - if footer.width != 120 { - t.Errorf("Expected width 120, got %d", footer.width) - } -} - -// TestFooterEdgeCases verifies edge case handling. -func TestFooterEdgeCases(t *testing.T) { - provider := newMockThemeProvider() - - t.Run("very long control descriptions", func(t *testing.T) { - controls := []KeyBinding{ - {Key: "Ctrl+Shift+Alt+Meta+X", Description: "Execute extremely long operation with detailed explanation"}, - } - footer := NewFooter(provider, controls, "v1.0.0", "", 40) - rendered := footer.Render() - - if rendered == "" { - t.Error("Expected non-empty footer even with long controls") - } - }) - - t.Run("empty version", func(t *testing.T) { - footer := NewFooter(provider, nil, "", "", 80) - rendered := footer.Render() - - // Should still render without crashing - if rendered == "" { - t.Error("Expected non-empty footer even with empty version") - } - }) - - t.Run("zero width", func(t *testing.T) { - footer := NewFooter(provider, nil, "v1.0.0", "", 0) - rendered := footer.Render() - - // Should handle gracefully - _ = rendered // Just verify it doesn't crash - }) - - t.Run("negative width", func(t *testing.T) { - footer := NewFooter(provider, nil, "v1.0.0", "", -10) - rendered := footer.Render() - - // Should handle gracefully - _ = rendered // Just verify it doesn't crash - }) -} - -// Note: stripANSI and newMockThemeProvider are defined in status_rail.go/logo_test.go and shared across component tests diff --git a/pkg/ui/components/header.go b/pkg/ui/components/header.go deleted file mode 100644 index c9fedc8..0000000 --- a/pkg/ui/components/header.go +++ /dev/null @@ -1,146 +0,0 @@ -package components - -import ( - "strings" - - "github.com/charmbracelet/lipgloss" -) - -// Header renders the persistent header at the top of the dashboard. -// It combines the A.R.C. logo, a horizontal rule separator, and tab navigation. -// -// Design: 016-ui-layout-fix Phase 3 (US1) -// Requirements: FR-001 through FR-007 -type Header struct { - themeProvider ThemeProvider - logo *Logo - tabBar *TabBar - width int - showLogo bool - showRule bool -} - -// NewHeader creates a new Header component with logo and tab bar. -// The themeProvider gives access to profile-themed colors. -// ComponentFactory from pkg/ui implements ThemeProvider interface. -// -// Example: -// -// tabs := DashboardTabs() -// header := NewHeader(factory, tabs, 0, 80) -// rendered := header.Render() -func NewHeader(themeProvider ThemeProvider, tabs []TabItem, activeTab, width int) *Header { - logo := NewLogo(themeProvider, width) - tabBar := NewThemedTabBar(tabs, activeTab, themeProvider.Theme()) - tabBar.SetWidth(width) - tabBar.Style.ShowSeparator = false // Header manages separator - - return &Header{ - themeProvider: themeProvider, - logo: logo, - tabBar: tabBar, - width: width, - showLogo: true, - showRule: true, - } -} - -// WithLogo enables or disables logo display. -// Returns the header for method chaining. -func (h *Header) WithLogo(show bool) *Header { - h.showLogo = show - return h -} - -// WithHorizontalRule enables or disables the horizontal rule separator. -// Returns the header for method chaining. -func (h *Header) WithHorizontalRule(show bool) *Header { - h.showRule = show - return h -} - -// SetActiveTab updates the active tab index. -// Returns the header for method chaining. -func (h *Header) SetActiveTab(activeTab int) *Header { - h.tabBar.SetActiveIdx(activeTab) - return h -} - -// SetWidth updates the header width and recalculates child component widths. -// Returns the header for method chaining. -func (h *Header) SetWidth(width int) *Header { - h.width = width - h.logo = NewLogo(h.themeProvider, width) - h.tabBar.SetWidth(width) - return h -} - -// Height returns the total height of the header in lines. -// This includes logo height (if shown), horizontal rule (if shown), and tab bar. -func (h *Header) Height() int { - height := 0 - if h.showLogo { - height += h.logo.Height() - } - if h.showRule { - height++ // Horizontal rule is 1 line - } - height++ // Tab bar is 1 line - return height -} - -// Render returns the complete header as a styled string. -// The header layout is: -// - Logo (centered, profile-themed colors) -// - Horizontal rule separator (faint border color) -// - Tab bar (with active tab highlighted) -func (h *Header) Render() string { - var sections []string - - // Render logo if enabled - if h.showLogo { - logoContent := h.logo.Render() - if logoContent != "" { - sections = append(sections, logoContent) - } - } - - // Render horizontal rule if enabled - if h.showRule { - rule := h.renderHorizontalRule() - if rule != "" { - sections = append(sections, rule) - } - } - - // Always render tab bar - tabBarContent := h.tabBar.Render() - if tabBarContent != "" { - sections = append(sections, tabBarContent) - } - - // Join all sections vertically - if len(sections) == 0 { - return "" - } - - return lipgloss.JoinVertical(lipgloss.Left, sections...) -} - -// renderHorizontalRule renders a horizontal line separator using theme colors. -// Uses the faint border color from the theme for subtle separation. -func (h *Header) renderHorizontalRule() string { - theme := h.themeProvider.Theme() - - // Use BorderColor() which returns the faint border color - ruleColor := theme.Colors.BorderColor() - - // Render a horizontal line spanning the full width - style := lipgloss.NewStyle().Foreground(ruleColor) - return style.Render(strings.Repeat("─", h.width)) -} - -// View is an alias for Render (for Bubble Tea compatibility). -func (h *Header) View() string { - return h.Render() -} diff --git a/pkg/ui/components/header_test.go b/pkg/ui/components/header_test.go deleted file mode 100644 index c7a26d3..0000000 --- a/pkg/ui/components/header_test.go +++ /dev/null @@ -1,404 +0,0 @@ -package components - -import ( - "strings" - "testing" - - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// TestNewHeader verifies the Header constructor. -func TestNewHeader(t *testing.T) { - provider := newMockThemeProvider() - tabs := DashboardTabs() - - header := NewHeader(provider, tabs, 0, 80) - - if header == nil { - t.Fatal("NewHeader returned nil") - } - if header.themeProvider == nil { - t.Error("Header themeProvider is nil") - } - if header.logo == nil { - t.Error("Header logo is nil") - } - if header.tabBar == nil { - t.Error("Header tabBar is nil") - } - if header.width != 80 { - t.Errorf("Expected width 80, got %d", header.width) - } - if !header.showLogo { - t.Error("Expected showLogo to be true by default") - } - if !header.showRule { - t.Error("Expected showRule to be true by default") - } - - // Verify tab bar integration - if header.tabBar.ActiveIdx != 0 { - t.Errorf("Expected initial active tab 0, got %d", header.tabBar.ActiveIdx) - } - if len(header.tabBar.Tabs) != len(tabs) { - t.Errorf("Expected %d tabs, got %d", len(tabs), len(header.tabBar.Tabs)) - } -} - -// TestHeaderWithLogo verifies the WithLogo method. -func TestHeaderWithLogo(t *testing.T) { - provider := newMockThemeProvider() - tabs := DashboardTabs() - header := NewHeader(provider, tabs, 0, 80) - - // Test disabling logo - header.WithLogo(false) - if header.showLogo { - t.Error("Expected showLogo to be false after WithLogo(false)") - } - - // Test enabling logo - header.WithLogo(true) - if !header.showLogo { - t.Error("Expected showLogo to be true after WithLogo(true)") - } -} - -// TestHeaderWithHorizontalRule verifies the WithHorizontalRule method. -func TestHeaderWithHorizontalRule(t *testing.T) { - provider := newMockThemeProvider() - tabs := DashboardTabs() - header := NewHeader(provider, tabs, 0, 80) - - // Test disabling rule - header.WithHorizontalRule(false) - if header.showRule { - t.Error("Expected showRule to be false after WithHorizontalRule(false)") - } - - // Test enabling rule - header.WithHorizontalRule(true) - if !header.showRule { - t.Error("Expected showRule to be true after WithHorizontalRule(true)") - } -} - -// TestHeaderSetActiveTab verifies the SetActiveTab method. -func TestHeaderSetActiveTab(t *testing.T) { - provider := newMockThemeProvider() - tabs := DashboardTabs() - header := NewHeader(provider, tabs, 0, 80) - - // Test setting active tab - header.SetActiveTab(2) - if header.tabBar.ActiveIdx != 2 { - t.Errorf("Expected active tab 2, got %d", header.tabBar.ActiveIdx) - } - - // Test setting to first tab - header.SetActiveTab(0) - if header.tabBar.ActiveIdx != 0 { - t.Errorf("Expected active tab 0, got %d", header.tabBar.ActiveIdx) - } -} - -// TestHeaderSetWidth verifies the SetWidth method. -func TestHeaderSetWidth(t *testing.T) { - provider := newMockThemeProvider() - tabs := DashboardTabs() - header := NewHeader(provider, tabs, 0, 80) - - // Test setting new width - header.SetWidth(120) - if header.width != 120 { - t.Errorf("Expected width 120, got %d", header.width) - } - if header.tabBar.Width != 120 { - t.Errorf("Expected tabBar width 120, got %d", header.tabBar.Width) - } -} - -// TestHeaderHeight verifies the Height method. -func TestHeaderHeight(t *testing.T) { - provider := newMockThemeProvider() - tabs := DashboardTabs() - - tests := []struct { - name string - width int - showLogo bool - showRule bool - minHeight int // Minimum expected height - }{ - { - name: "Full header (logo + rule + tabs) at 80 cols", - width: 80, - showLogo: true, - showRule: true, - minHeight: 3, // At least: logo (1+) + rule (1) + tabs (1) - }, - { - name: "No logo (rule + tabs) at 80 cols", - width: 80, - showLogo: false, - showRule: true, - minHeight: 2, // rule (1) + tabs (1) - }, - { - name: "No rule (logo + tabs) at 80 cols", - width: 80, - showLogo: true, - showRule: false, - minHeight: 2, // logo (1+) + tabs (1) - }, - { - name: "Minimal (tabs only) at 80 cols", - width: 80, - showLogo: false, - showRule: false, - minHeight: 1, // tabs (1) - }, - { - name: "Full header at narrow terminal (40 cols)", - width: 40, - showLogo: true, - showRule: true, - minHeight: 3, // logo (1) + rule (1) + tabs (1) in minimal mode - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - header := NewHeader(provider, tabs, 0, tt.width) - header.WithLogo(tt.showLogo).WithHorizontalRule(tt.showRule) - - height := header.Height() - if height < tt.minHeight { - t.Errorf("Expected height >= %d, got %d", tt.minHeight, height) - } - }) - } -} - -// TestHeaderRenderStructure verifies the header renders all expected sections. -func TestHeaderRenderStructure(t *testing.T) { - provider := newMockThemeProvider() - tabs := DashboardTabs() - - tests := []struct { - name string - width int - showLogo bool - showRule bool - expectNonEmpty bool - }{ - { - name: "Full header renders", - width: 80, - showLogo: true, - showRule: true, - expectNonEmpty: true, - }, - { - name: "Header without logo renders", - width: 80, - showLogo: false, - showRule: true, - expectNonEmpty: true, - }, - { - name: "Header without rule renders", - width: 80, - showLogo: true, - showRule: false, - expectNonEmpty: true, - }, - { - name: "Minimal header (tabs only) renders", - width: 80, - showLogo: false, - showRule: false, - expectNonEmpty: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - header := NewHeader(provider, tabs, 0, tt.width) - header.WithLogo(tt.showLogo).WithHorizontalRule(tt.showRule) - - rendered := header.Render() - - if tt.expectNonEmpty && rendered == "" { - t.Error("Expected non-empty render output") - } - - // Check that rendered output has multiple lines - lines := strings.Split(rendered, "\n") - if tt.expectNonEmpty && len(lines) < 1 { - t.Errorf("Expected at least 1 line, got %d", len(lines)) - } - }) - } -} - -// TestHeaderRenderWithDifferentWidths verifies header adapts to terminal width. -func TestHeaderRenderWithDifferentWidths(t *testing.T) { - provider := newMockThemeProvider() - tabs := DashboardTabs() - - widths := []int{40, 60, 80, 120, 160} - - for _, width := range widths { - t.Run(string(rune(width))+" columns", func(t *testing.T) { - header := NewHeader(provider, tabs, 0, width) - rendered := header.Render() - - if rendered == "" { - t.Error("Expected non-empty render output") - } - - // Verify rendered content doesn't exceed terminal width - lines := strings.Split(rendered, "\n") - for i, line := range lines { - // Use lipgloss.Width for ANSI-aware width calculation - lineWidth := lipgloss.Width(line) - if lineWidth > width { - t.Errorf("Line %d exceeds terminal width: %d > %d", i, lineWidth, width) - } - } - }) - } -} - -// TestHeaderRenderHorizontalRule verifies the horizontal rule rendering. -func TestHeaderRenderHorizontalRule(t *testing.T) { - provider := newMockThemeProvider() - tabs := DashboardTabs() - header := NewHeader(provider, tabs, 0, 80) - - rule := header.renderHorizontalRule() - - if rule == "" { - t.Error("Expected non-empty horizontal rule") - } - - // Check that rule contains the horizontal line character - plainRule := lipgloss.NewStyle().Render(rule) - if !strings.Contains(plainRule, "─") { - t.Error("Expected horizontal rule to contain '─' character") - } -} - -// TestHeaderView verifies the View alias method. -func TestHeaderView(t *testing.T) { - provider := newMockThemeProvider() - tabs := DashboardTabs() - header := NewHeader(provider, tabs, 0, 80) - - rendered := header.Render() - viewed := header.View() - - if rendered != viewed { - t.Error("View() and Render() should return the same output") - } -} - -// TestHeaderMethodChaining verifies fluent interface pattern. -func TestHeaderMethodChaining(t *testing.T) { - provider := newMockThemeProvider() - tabs := DashboardTabs() - - // Test method chaining - header := NewHeader(provider, tabs, 0, 80). - WithLogo(false). - WithHorizontalRule(false). - SetActiveTab(2). - SetWidth(120) - - if header.showLogo { - t.Error("Expected showLogo to be false") - } - if header.showRule { - t.Error("Expected showRule to be false") - } - if header.tabBar.ActiveIdx != 2 { - t.Errorf("Expected active tab 2, got %d", header.tabBar.ActiveIdx) - } - if header.width != 120 { - t.Errorf("Expected width 120, got %d", header.width) - } -} - -// TestHeaderWithAllProfiles verifies header works with all profile themes. -func TestHeaderWithAllProfiles(t *testing.T) { - tabs := DashboardTabs() - - // Test with a variety of theme configurations - testThemes := []struct { - name string - primary string - muted string - }{ - {"Enterprise", "#00ADD8", "#6272A4"}, - {"Saiyan", "#FF6B35", "#F7931E"}, - {"Jedi", "#4A90E2", "#7ED321"}, - } - - for _, tt := range testThemes { - t.Run(tt.name, func(t *testing.T) { - colorSet := &themes.ColorSet{ - Primary: tt.primary, - Muted: tt.muted, - Background: "#1E1E2E", - Foreground: "#CDD6F4", - Border: "#313244", - } - theme := &themes.Theme{ - Name: tt.name, - Colors: *colorSet, - } - provider := &mockThemeProvider{theme: theme} - - header := NewHeader(provider, tabs, 0, 80) - rendered := header.Render() - - if rendered == "" { - t.Errorf("Expected non-empty render for %s theme", tt.name) - } - }) - } -} - -// TestHeaderWithNarrowTerminal verifies graceful degradation on narrow terminals. -func TestHeaderWithNarrowTerminal(t *testing.T) { - provider := newMockThemeProvider() - tabs := DashboardTabs() - - // Test edge case: very narrow terminal - narrowWidths := []int{40, 50, 59} - - for _, width := range narrowWidths { - t.Run(string(rune(width))+" columns", func(t *testing.T) { - header := NewHeader(provider, tabs, 0, width) - rendered := header.Render() - - if rendered == "" { - t.Error("Expected header to render even on narrow terminal") - } - - // Verify no line exceeds terminal width - lines := strings.Split(rendered, "\n") - for i, line := range lines { - lineWidth := lipgloss.Width(line) - if lineWidth > width { - t.Errorf("Line %d exceeds narrow terminal width: %d > %d", i, lineWidth, width) - } - } - }) - } -} - -// Note: newMockThemeProvider is defined in logo_test.go and shared across component tests diff --git a/pkg/ui/components/hero/.gitkeep b/pkg/ui/components/hero/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/pkg/ui/components/hero/hero.go b/pkg/ui/components/hero/hero.go deleted file mode 100644 index 3a17e2d..0000000 --- a/pkg/ui/components/hero/hero.go +++ /dev/null @@ -1,121 +0,0 @@ -package hero - -import ( - "strings" - - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// Hero displays profile branding (logo + tagline) at the top of views. -// Used in HomeView and InfoView to showcase the active profile's identity. -// -// Design: 017-ui-engine Phase 3 (User Story 2 - Profile Branding) -type Hero struct { - profile *profiles.Profile - theme *themes.Theme -} - -// NewHero creates a new Hero component with the given profile and theme. -// The hero displays the profile's logo with theme-appropriate colors. -func NewHero(profile *profiles.Profile, theme *themes.Theme) *Hero { - return &Hero{ - profile: profile, - theme: theme, - } -} - -// Render returns the hero banner as a centered string for the given width. -// The logo is rendered with the theme's primary color and centered horizontally. -// Width should be 80-160 columns for optimal display. -func (h *Hero) Render(width int) string { - if h.profile == nil || h.theme == nil { - return "" - } - - // Get primary color from profile or fall back to theme - color := h.getPrimaryColor() - - // Apply color styling to each line of the logo - logoLines := strings.Split(strings.TrimRight(h.profile.Logo, "\n"), "\n") - styledLines := make([]string, 0, len(logoLines)) - - style := lipgloss.NewStyle(). - Foreground(color). - Bold(true) - - for _, line := range logoLines { - styledLines = append(styledLines, style.Render(line)) - } - - // Join lines and center horizontally - logo := lipgloss.JoinVertical(lipgloss.Left, styledLines...) - - // Center the entire logo block - centered := lipgloss.NewStyle(). - Width(width). - Align(lipgloss.Center). - Render(logo) - - return centered -} - -// RenderCompact returns a compact version of the hero for smaller headers. -// This displays only the profile name in a stylized format, without the full ASCII logo. -// Useful for views with limited vertical space. -func (h *Hero) RenderCompact(width int) string { - if h.profile == nil || h.theme == nil { - return "" - } - - color := h.getPrimaryColor() - - style := lipgloss.NewStyle(). - Foreground(color). - Bold(true). - Width(width). - Align(lipgloss.Center) - - return style.Render(h.profile.Name) -} - -// SetProfile updates the profile for the hero. -// This allows dynamic profile switching without recreating the component. -func (h *Hero) SetProfile(profile *profiles.Profile) { - h.profile = profile -} - -// SetTheme updates the theme for the hero. -// This allows dynamic theme switching without recreating the component. -func (h *Hero) SetTheme(theme *themes.Theme) { - h.theme = theme -} - -// getPrimaryColor returns the profile's primary color if set, -// otherwise falls back to the theme's primary color. -func (h *Hero) getPrimaryColor() lipgloss.Color { - if h.profile.PrimaryColor != "" { - return lipgloss.Color(h.profile.PrimaryColor) - } - return h.theme.Colors.PrimaryColor() -} - -// Height returns the number of lines the hero will occupy when rendered. -// This is useful for layout calculations in views. -func (h *Hero) Height() int { - if h.profile == nil || h.profile.Logo == "" { - return 0 - } - logoLines := strings.Split(strings.TrimRight(h.profile.Logo, "\n"), "\n") - return len(logoLines) -} - -// CompactHeight returns the height of the compact hero rendering. -func (h *Hero) CompactHeight() int { - if h.profile == nil { - return 0 - } - return 1 // Compact mode is always a single line -} diff --git a/pkg/ui/components/hero/hero_test.go b/pkg/ui/components/hero/hero_test.go deleted file mode 100644 index 7f5d914..0000000 --- a/pkg/ui/components/hero/hero_test.go +++ /dev/null @@ -1,366 +0,0 @@ -package hero - -import ( - "strings" - "testing" - - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// mockProfile returns a test profile with a simple ASCII logo. -func mockProfile() *profiles.Profile { - return &profiles.Profile{ - ID: "test", - Name: "Test Profile", - Description: "A test profile", - TierNames: []string{"Starter", "Pro", "Ultra"}, - ThemeID: "test-theme", - Logo: " TEST\n LOGO\n HERE", - PrimaryColor: "#FF5733", - } -} - -// mockTheme returns a test theme with basic colors. -func mockTheme() *themes.Theme { - return &themes.Theme{ - Name: "Test Theme", - Description: "A test theme", - Version: "1.0.0", - Colors: themes.ColorSet{ - Primary: "#4A90E2", - Secondary: "#7B68EE", - Success: "#28a745", - Error: "#dc3545", - Warning: "#ffc107", - Info: "#17a2b8", - }, - } -} - -func TestNewHero(t *testing.T) { - profile := mockProfile() - theme := mockTheme() - - hero := NewHero(profile, theme) - - if hero == nil { - t.Fatal("NewHero returned nil") - } - - if hero.profile != profile { - t.Errorf("expected profile %v, got %v", profile, hero.profile) - } - - if hero.theme != theme { - t.Errorf("expected theme %v, got %v", theme, hero.theme) - } -} - -func TestHero_Render(t *testing.T) { - tests := []struct { - name string - profile *profiles.Profile - theme *themes.Theme - width int - wantNil bool - validate func(t *testing.T, output string) - }{ - { - name: "renders with valid profile and theme", - profile: mockProfile(), - theme: mockTheme(), - width: 80, - validate: func(t *testing.T, output string) { - if output == "" { - t.Error("expected non-empty output") - } - // Output should contain logo text (may have ANSI codes) - if !strings.Contains(output, "TEST") { - t.Error("output should contain 'TEST' from logo") - } - }, - }, - { - name: "returns empty for nil profile", - profile: nil, - theme: mockTheme(), - width: 80, - validate: func(t *testing.T, output string) { - if output != "" { - t.Errorf("expected empty output for nil profile, got %q", output) - } - }, - }, - { - name: "returns empty for nil theme", - profile: mockProfile(), - theme: nil, - width: 80, - validate: func(t *testing.T, output string) { - if output != "" { - t.Errorf("expected empty output for nil theme, got %q", output) - } - }, - }, - { - name: "handles narrow width", - profile: mockProfile(), - theme: mockTheme(), - width: 40, - validate: func(t *testing.T, output string) { - if output == "" { - t.Error("expected non-empty output for narrow width") - } - }, - }, - { - name: "handles wide width", - profile: mockProfile(), - theme: mockTheme(), - width: 160, - validate: func(t *testing.T, output string) { - if output == "" { - t.Error("expected non-empty output for wide width") - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - hero := NewHero(tt.profile, tt.theme) - output := hero.Render(tt.width) - tt.validate(t, output) - }) - } -} - -func TestHero_RenderCompact(t *testing.T) { - tests := []struct { - name string - profile *profiles.Profile - theme *themes.Theme - width int - validate func(t *testing.T, output string) - }{ - { - name: "renders compact with valid profile and theme", - profile: mockProfile(), - theme: mockTheme(), - width: 80, - validate: func(t *testing.T, output string) { - if output == "" { - t.Error("expected non-empty output") - } - // Compact mode should show profile name - if !strings.Contains(output, "Test Profile") { - t.Errorf("compact output should contain profile name, got %q", output) - } - }, - }, - { - name: "returns empty for nil profile", - profile: nil, - theme: mockTheme(), - width: 80, - validate: func(t *testing.T, output string) { - if output != "" { - t.Errorf("expected empty output for nil profile, got %q", output) - } - }, - }, - { - name: "returns empty for nil theme", - profile: mockProfile(), - theme: nil, - width: 80, - validate: func(t *testing.T, output string) { - if output != "" { - t.Errorf("expected empty output for nil theme, got %q", output) - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - hero := NewHero(tt.profile, tt.theme) - output := hero.RenderCompact(tt.width) - tt.validate(t, output) - }) - } -} - -func TestHero_SetProfile(t *testing.T) { - hero := NewHero(mockProfile(), mockTheme()) - - newProfile := &profiles.Profile{ - ID: "new", - Name: "New Profile", - Logo: "NEW", - } - - hero.SetProfile(newProfile) - - if hero.profile != newProfile { - t.Errorf("SetProfile did not update profile: got %v, want %v", hero.profile, newProfile) - } - - // Verify new profile is used in rendering - output := hero.RenderCompact(80) - if !strings.Contains(output, "New Profile") { - t.Error("SetProfile did not affect rendering") - } -} - -func TestHero_SetTheme(t *testing.T) { - hero := NewHero(mockProfile(), mockTheme()) - - newTheme := &themes.Theme{ - Name: "New Theme", - Colors: themes.ColorSet{ - Primary: "#00FF00", - }, - } - - hero.SetTheme(newTheme) - - if hero.theme != newTheme { - t.Errorf("SetTheme did not update theme: got %v, want %v", hero.theme, newTheme) - } -} - -func TestHero_GetPrimaryColor(t *testing.T) { - tests := []struct { - name string - profileColor string - themeColor string - expectedColor string - description string - }{ - { - name: "uses profile color when set", - profileColor: "#FF5733", - themeColor: "#4A90E2", - expectedColor: "#FF5733", - description: "Profile color should take precedence", - }, - { - name: "falls back to theme color when profile color empty", - profileColor: "", - themeColor: "#4A90E2", - expectedColor: "#4A90E2", - description: "Should use theme color when profile color is empty", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - profile := mockProfile() - profile.PrimaryColor = tt.profileColor - - theme := mockTheme() - theme.Colors.Primary = tt.themeColor - - hero := NewHero(profile, theme) - color := hero.getPrimaryColor() - - if string(color) != tt.expectedColor { - t.Errorf("%s: expected color %s, got %s", tt.description, tt.expectedColor, color) - } - }) - } -} - -func TestHero_Height(t *testing.T) { - tests := []struct { - name string - logo string - expectedHeight int - }{ - { - name: "calculates height for multi-line logo", - logo: "LINE1\nLINE2\nLINE3", - expectedHeight: 3, - }, - { - name: "calculates height for single line logo", - logo: "SINGLE", - expectedHeight: 1, - }, - { - name: "handles logo with trailing newline", - logo: "LINE1\nLINE2\n", - expectedHeight: 2, - }, - { - name: "returns 0 for empty logo", - logo: "", - expectedHeight: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - profile := mockProfile() - profile.Logo = tt.logo - - hero := NewHero(profile, mockTheme()) - height := hero.Height() - - if height != tt.expectedHeight { - t.Errorf("expected height %d, got %d", tt.expectedHeight, height) - } - }) - } -} - -func TestHero_HeightNilProfile(t *testing.T) { - hero := NewHero(nil, mockTheme()) - height := hero.Height() - - if height != 0 { - t.Errorf("expected height 0 for nil profile, got %d", height) - } -} - -func TestHero_CompactHeight(t *testing.T) { - hero := NewHero(mockProfile(), mockTheme()) - height := hero.CompactHeight() - - if height != 1 { - t.Errorf("expected compact height 1, got %d", height) - } -} - -func TestHero_CompactHeightNilProfile(t *testing.T) { - hero := NewHero(nil, mockTheme()) - height := hero.CompactHeight() - - if height != 0 { - t.Errorf("expected compact height 0 for nil profile, got %d", height) - } -} - -func TestHero_ResponsiveRendering(t *testing.T) { - // Test that hero renders at different widths without panic - hero := NewHero(mockProfile(), mockTheme()) - - widths := []int{40, 60, 80, 100, 120, 160} - - for _, width := range widths { - t.Run(string(rune(width)), func(t *testing.T) { - // Should not panic - output := hero.Render(width) - if output == "" { - t.Errorf("expected non-empty output for width %d", width) - } - - compactOutput := hero.RenderCompact(width) - if compactOutput == "" { - t.Errorf("expected non-empty compact output for width %d", width) - } - }) - } -} diff --git a/pkg/ui/components/logo.go b/pkg/ui/components/logo.go deleted file mode 100644 index d3abcb0..0000000 --- a/pkg/ui/components/logo.go +++ /dev/null @@ -1,139 +0,0 @@ -package components - -import ( - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/internal/branding" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// ThemeProvider is an interface for accessing theme information. -// This avoids import cycles between components and ui packages. -type ThemeProvider interface { - Theme() *themes.Theme -} - -// Logo renders the A.R.C. CLI ASCII art logo with profile theming. -// The logo adapts to terminal width: full logo for wide terminals (80+ cols), -// compact logo for medium terminals (60-79 cols), minimal for narrow (40-59 cols). -// -// Design: 016-ui-layout-fix Phase 3 (US1) -type Logo struct { - themeProvider ThemeProvider - width int -} - -// NewLogo creates a new Logo component with the given theme provider and width. -// The theme provider gives access to profile-themed colors for the logo. -// ComponentFactory from pkg/ui implements ThemeProvider interface. -func NewLogo(themeProvider ThemeProvider, width int) *Logo { - return &Logo{ - themeProvider: themeProvider, - width: width, - } -} - -// Render returns the ASCII art logo as a string, themed with profile colors. -// The logo format adapts based on terminal width: -// - 80+ columns: Full logo with ASCII art + tagline -// - 60-79 columns: Compact logo without tagline -// - 40-59 columns: Minimal "A.R.C." text only -func (l *Logo) Render() string { - theme := l.themeProvider.Theme() - primaryColor := theme.Colors.PrimaryColor() - - if l.width >= 80 { - return l.renderFull(primaryColor) - } else if l.width >= 60 { - return l.renderCompact(primaryColor) - } - return l.renderMinimal(primaryColor) -} - -// renderFull returns the full ASCII art logo with tagline. -// Used for terminals with 80+ columns. -func (l *Logo) renderFull(color lipgloss.Color) string { - // Full ASCII art logo (inspired by gh-dash's clean aesthetic) - logoArt := []string{ - " _ ____ ____ ", - " / \\ | _ \\ / ___| ", - " / _ \\ | |_) | | | ", - " / ___ \\ _ | _ < _ | |___ ", - " /_/ \\_\\(_)|_| \\_(_) \\____| ", - } - - style := lipgloss.NewStyle(). - Foreground(color). - Bold(true) - - // Render logo lines (pre-allocate for 5 logo lines + 1 blank + 1 tagline) - lines := make([]string, 0, len(logoArt)+2) - for _, line := range logoArt { - lines = append(lines, style.Render(line)) - } - - // Add tagline (muted) - centered with 2 spaces indent to match logo alignment - taglineStyle := lipgloss.NewStyle(). - Foreground(l.themeProvider.Theme().Colors.MutedColor()). - Italic(true) - - tagline := taglineStyle.Render(" " + branding.Tagline) - lines = append(lines, "", tagline) - - return lipgloss.JoinVertical(lipgloss.Left, lines...) -} - -// renderCompact returns a compact version of the logo without tagline. -// Used for terminals with 60-79 columns. -func (l *Logo) renderCompact(color lipgloss.Color) string { - // Compact ASCII art (3 lines instead of 5) - logoArt := []string{ - " _ ____ ____ ", - " / \\ | _ \\ / ___|", - " / _ \\ | |_) | | | ", - "/_/ \\_\\ |_| \\_\\ \\___| ", - } - - style := lipgloss.NewStyle(). - Foreground(color). - Bold(true) - - // Pre-allocate for compact logo lines - lines := make([]string, 0, len(logoArt)) - for _, line := range logoArt { - lines = append(lines, style.Render(line)) - } - - return lipgloss.JoinVertical(lipgloss.Left, lines...) -} - -// renderMinimal returns a minimal text-only logo. -// Used for terminals with 40-59 columns. -func (l *Logo) renderMinimal(color lipgloss.Color) string { - style := lipgloss.NewStyle(). - Foreground(color). - Bold(true). - Padding(0, 1) - - return style.Render("A.R.C.") -} - -// Height returns the height of the logo in lines for the current width. -func (l *Logo) Height() int { - if l.width >= 80 { - return 7 // 5 lines of logo + 1 blank + 1 tagline - } else if l.width >= 60 { - return 4 // Compact logo - } - return 1 // Minimal text -} - -// Width returns the visual width of the logo for the current terminal width. -func (l *Logo) Width() int { - if l.width >= 80 { - return 33 // Width of full ASCII art - } else if l.width >= 60 { - return 23 // Width of compact ASCII art - } - return 7 // Width of "A.R.C." with padding -} diff --git a/pkg/ui/components/logo_test.go b/pkg/ui/components/logo_test.go deleted file mode 100644 index 64cf5f7..0000000 --- a/pkg/ui/components/logo_test.go +++ /dev/null @@ -1,316 +0,0 @@ -package components - -import ( - "strings" - "testing" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// mockThemeProvider is a minimal ThemeProvider for testing -type mockThemeProvider struct { - theme *themes.Theme -} - -func (m *mockThemeProvider) Theme() *themes.Theme { - return m.theme -} - -// newMockThemeProvider creates a mock theme provider with default colors -func newMockThemeProvider() *mockThemeProvider { - // Create a simple theme with default colors for testing - colorSet := &themes.ColorSet{ - Primary: "#00ADD8", - Secondary: "#9D7CD8", - Foreground: "#E0E0E0", - Background: "#1E1E2E", - Muted: "#6272A4", - Success: "#50FA7B", - Error: "#FF5555", - Warning: "#F1FA8C", - Info: "#8BE9FD", - Border: "#44475A", - } - - theme := &themes.Theme{ - Name: "Test Theme", - Colors: *colorSet, - } - - return &mockThemeProvider{theme: theme} -} - -// TestNewLogo validates Logo constructor. -func TestNewLogo(t *testing.T) { - themeProvider := newMockThemeProvider() - logo := NewLogo(themeProvider, 80) - - if logo == nil { - t.Fatal("Expected non-nil logo") - } - - if logo.themeProvider == nil { - t.Error("Expected themeProvider to be set") - } - - if logo.width != 80 { - t.Errorf("Expected width 80, got %d", logo.width) - } -} - -// TestLogoRender_WidthBreakpoints validates logo rendering at different terminal widths. -func TestLogoRender_WidthBreakpoints(t *testing.T) { - tests := []struct { - name string - width int - expectedLines int // Number of lines in output - contains string // String that should appear in output - }{ - { - name: "full logo at 120 columns", - width: 120, - expectedLines: 7, // 5 logo + 1 blank + 1 tagline - contains: " / \\", // ASCII art contains this - }, - { - name: "full logo at 80 columns (boundary)", - width: 80, - expectedLines: 7, - contains: "Agentic Reasoning Core", - }, - { - name: "compact logo at 70 columns", - width: 70, - expectedLines: 4, - contains: " / \\", // ASCII art contains this - }, - { - name: "compact logo at 60 columns (boundary)", - width: 60, - expectedLines: 4, - contains: " / \\", // ASCII art contains this - }, - { - name: "minimal logo at 50 columns", - width: 50, - expectedLines: 1, - contains: "A.R.C.", // Minimal mode outputs text - }, - { - name: "minimal logo at 40 columns (boundary)", - width: 40, - expectedLines: 1, - contains: "A.R.C.", // Minimal mode outputs text - }, - } - - themeProvider := newMockThemeProvider() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - logo := NewLogo(themeProvider, tt.width) - output := logo.Render() - - // Count lines - lines := strings.Split(output, "\n") - if len(lines) != tt.expectedLines { - t.Errorf("Expected %d lines, got %d\nOutput:\n%s", - tt.expectedLines, len(lines), output) - } - - // Check for expected content (strip ANSI codes for comparison) - plainOutput := stripANSIForLogo(output) - if !strings.Contains(plainOutput, tt.contains) { - t.Errorf("Expected output to contain %q\nPlain output:\n%s", - tt.contains, plainOutput) - } - - // Verify output is not empty - if strings.TrimSpace(output) == "" { - t.Error("Expected non-empty output") - } - - // Note: ANSI color codes may not appear in non-TTY test environments - // This is expected behavior - lipgloss detects TTY and skips coloring in tests - // In actual terminal usage, colors will render correctly - // if !strings.Contains(output, "\x1b[") { - // t.Error("Logo should be colored (contain ANSI codes)") - // } - }) - } -} - -// TestLogoHeight validates Height() method returns correct line count. -func TestLogoHeight(t *testing.T) { - tests := []struct { - name string - width int - expectedHeight int - }{ - {"full logo", 120, 7}, - {"full logo boundary", 80, 7}, - {"compact logo", 70, 4}, - {"compact logo boundary", 60, 4}, - {"minimal logo", 50, 1}, - {"minimal logo boundary", 40, 1}, - } - - themeProvider := newMockThemeProvider() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - logo := NewLogo(themeProvider, tt.width) - height := logo.Height() - - if height != tt.expectedHeight { - t.Errorf("Expected height %d, got %d", tt.expectedHeight, height) - } - }) - } -} - -// TestLogoWidth validates Width() method returns correct visual width. -func TestLogoWidth(t *testing.T) { - tests := []struct { - name string - width int - expectedWidth int - }{ - {"full logo", 120, 33}, - {"full logo boundary", 80, 33}, - {"compact logo", 70, 23}, - {"compact logo boundary", 60, 23}, - {"minimal logo", 50, 7}, - {"minimal logo boundary", 40, 7}, - } - - themeProvider := newMockThemeProvider() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - logo := NewLogo(themeProvider, tt.width) - width := logo.Width() - - if width != tt.expectedWidth { - t.Errorf("Expected width %d, got %d", tt.expectedWidth, width) - } - }) - } -} - -// TestLogoRender_EdgeCaseWidths validates logo rendering at extreme widths. -func TestLogoRender_EdgeCaseWidths(t *testing.T) { - tests := []struct { - name string - width int - }{ - {"very narrow", 10}, - {"very wide", 200}, - {"zero width", 0}, - } - - themeProvider := newMockThemeProvider() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - defer func() { - if r := recover(); r != nil { - t.Errorf("Logo.Render() panicked at width %d: %v", tt.width, r) - } - }() - - logo := NewLogo(themeProvider, tt.width) - output := logo.Render() - - // Should never panic, even with extreme widths - if output == "" { - t.Error("Expected non-empty output") - } - }) - } -} - -// TestLogoRender_ColorTheming validates logo uses provided theme colors. -func TestLogoRender_ColorTheming(t *testing.T) { - // Create theme provider with a specific color - colorSet := &themes.ColorSet{ - Primary: "#FF0000", // Red - Muted: "#888888", - Foreground: "#FFFFFF", - Background: "#000000", - } - - theme := &themes.Theme{ - Name: "Test Red Theme", - Colors: *colorSet, - } - - themeProvider := &mockThemeProvider{theme: theme} - - logo := NewLogo(themeProvider, 80) - output := logo.Render() - - // Note: ANSI codes may not render in non-TTY test environments - // This is expected - lipgloss skips coloring when not attached to a TTY - // In actual usage with a terminal, colors will render correctly - // if !strings.Contains(output, "\x1b[") { - // t.Error("Expected logo to contain ANSI styling codes") - // } - - // Verify output is not empty - if strings.TrimSpace(output) == "" { - t.Error("Expected non-empty logo output") - } -} - -// stripANSIForLogo removes ANSI escape codes from a string for easier testing. -func stripANSIForLogo(s string) string { - // Simple ANSI stripper for testing - result := strings.Builder{} - inEscape := false - - for _, r := range s { - if r == '\x1b' { - inEscape = true - continue - } - - if inEscape { - if r == 'm' { - inEscape = false - } - continue - } - - result.WriteRune(r) - } - - return result.String() -} - -// BenchmarkLogoRender benchmarks logo rendering performance. -func BenchmarkLogoRender(b *testing.B) { - themeProvider := newMockThemeProvider() - logo := NewLogo(themeProvider, 80) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = logo.Render() - } -} - -// BenchmarkLogoRender_AllWidths benchmarks logo rendering at all width breakpoints. -func BenchmarkLogoRender_AllWidths(b *testing.B) { - widths := []int{40, 60, 80, 120} - themeProvider := newMockThemeProvider() - - for _, width := range widths { - b.Run(string(rune(width)), func(b *testing.B) { - logo := NewLogo(themeProvider, width) - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = logo.Render() - } - }) - } -} diff --git a/pkg/ui/components/panel.go b/pkg/ui/components/panel.go deleted file mode 100644 index 5ad0118..0000000 --- a/pkg/ui/components/panel.go +++ /dev/null @@ -1,361 +0,0 @@ -package components - -import ( - "strings" - - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// Panel represents a titled content panel with borders -type Panel struct { - Title string - Content string - Style PanelStyle - Width int - Height int - BorderStyle lipgloss.Border -} - -// PanelStyle configures panel appearance -type PanelStyle struct { - TitleColor lipgloss.Color - BorderColor lipgloss.Color - BackgroundColor lipgloss.Color - TitleAlign lipgloss.Position - ContentAlign lipgloss.Position - Padding int - Margin int - Bold bool - ShowBorder bool -} - -// NewPanel creates a new panel with default styling. -// Deprecated: Use NewThemedPanel for profile-aware theming. -// This version maintains backward compatibility by using default theme. -func NewPanel(title, content string) *Panel { - return NewThemedPanel(title, content, nil) -} - -// NewThemedPanel creates a new panel with default styling using theme colors. -// If theme is nil, falls back to default theme. -func NewThemedPanel(title, content string, theme *themes.Theme) *Panel { - return &Panel{ - Title: title, - Content: content, - Style: DefaultThemedPanelStyle(theme), - Width: 80, - Height: 0, // Auto height - BorderStyle: lipgloss.RoundedBorder(), - } -} - -// NewPanelWithStyle creates a panel with custom styling -func NewPanelWithStyle(title, content string, style *PanelStyle) *Panel { - panel := NewPanel(title, content) // Use default theme - panel.Style = *style - return panel -} - -// SetWidth sets the panel width -func (p *Panel) SetWidth(width int) *Panel { - p.Width = width - return p -} - -// SetHeight sets the panel height (0 for auto) -func (p *Panel) SetHeight(height int) *Panel { - p.Height = height - return p -} - -// SetBorderStyle sets the border style -func (p *Panel) SetBorderStyle(border *lipgloss.Border) *Panel { - p.BorderStyle = *border - return p -} - -// WithTitle sets the panel title -func (p *Panel) WithTitle(title string) *Panel { - p.Title = title - return p -} - -// WithContent sets the panel content -func (p *Panel) WithContent(content string) *Panel { - p.Content = content - return p -} - -// Render returns the panel as a styled string -func (p *Panel) Render() string { - // Create title style - titleStyle := lipgloss.NewStyle(). - Foreground(p.Style.TitleColor). - Bold(p.Style.Bold). - Align(p.Style.TitleAlign) - - // Create content style - contentStyle := lipgloss.NewStyle(). - Align(p.Style.ContentAlign). - Padding(p.Style.Padding) - - // Build content with title if present - var renderedContent string - if p.Title != "" { - titleLine := titleStyle.Render(p.Title) - // Calculate separator width, ensure it's not negative - sepWidth := p.Width - 4 - if sepWidth < 0 { - sepWidth = 0 - } - separator := lipgloss.NewStyle(). - Foreground(p.Style.BorderColor). - Render(strings.Repeat("─", sepWidth)) - renderedContent = lipgloss.JoinVertical( - lipgloss.Left, - titleLine, - separator, - "", - p.Content, - ) - } else { - renderedContent = p.Content - } - - // Apply content styling - styledContent := contentStyle.Render(renderedContent) - - // Create panel style with border if enabled - if p.Style.ShowBorder { - panelStyle := lipgloss.NewStyle(). - Border(p.BorderStyle). - BorderForeground(p.Style.BorderColor). - Width(p.Width). - Margin(p.Style.Margin) - - if p.Height > 0 { - panelStyle = panelStyle.Height(p.Height) - } - - if p.Style.BackgroundColor != "" { - panelStyle = panelStyle.Background(p.Style.BackgroundColor) - } - - return panelStyle.Render(styledContent) - } - - // No border, just return styled content - return styledContent -} - -// View is an alias for Render (for Bubble Tea compatibility) -func (p *Panel) View() string { - return p.Render() -} - -// DefaultPanelStyle returns the default panel style. -// Deprecated: Use DefaultThemedPanelStyle for profile-aware theming. -func DefaultPanelStyle() PanelStyle { - return DefaultThemedPanelStyle(nil) -} - -// DefaultThemedPanelStyle returns the default panel style using theme colors. -// If theme is nil, falls back to default theme. -// -//nolint:dupl // Intentional duplication for clarity - each style function is self-contained -func DefaultThemedPanelStyle(theme *themes.Theme) PanelStyle { - // Fallback to default theme if not provided - if theme == nil { - theme = getDefaultTheme() - } - if theme == nil { - // Ultimate fallback - return PanelStyle{ - TitleColor: lipgloss.Color("#00ADD8"), - BorderColor: lipgloss.Color("#6272A4"), - TitleAlign: lipgloss.Left, - ContentAlign: lipgloss.Left, - Padding: 1, - Margin: 0, - Bold: true, - ShowBorder: true, - } - } - - return PanelStyle{ - TitleColor: theme.Colors.PrimaryColor(), - BorderColor: theme.Colors.MutedColor(), - TitleAlign: lipgloss.Left, - ContentAlign: lipgloss.Left, - Padding: 1, - Margin: 0, - Bold: true, - ShowBorder: true, - } -} - -// InfoPanelStyle returns a style for informational panels. -// Deprecated: Use InfoThemedPanelStyle for profile-aware theming. -func InfoPanelStyle() PanelStyle { - return InfoThemedPanelStyle(nil) -} - -// InfoThemedPanelStyle returns a style for informational panels using theme colors. -// If theme is nil, falls back to default theme. -// -//nolint:dupl // Intentional duplication for clarity - each style function is self-contained -func InfoThemedPanelStyle(theme *themes.Theme) PanelStyle { - // Fallback to default theme if not provided - if theme == nil { - theme = getDefaultTheme() - } - if theme == nil { - // Ultimate fallback - return PanelStyle{ - TitleColor: lipgloss.Color("#00ADD8"), - BorderColor: lipgloss.Color("#00ADD8"), - TitleAlign: lipgloss.Center, - ContentAlign: lipgloss.Center, - Padding: 2, - Margin: 1, - Bold: true, - ShowBorder: true, - } - } - - return PanelStyle{ - TitleColor: theme.Colors.InfoColor(), - BorderColor: theme.Colors.InfoColor(), - TitleAlign: lipgloss.Center, - ContentAlign: lipgloss.Center, - Padding: 2, - Margin: 1, - Bold: true, - ShowBorder: true, - } -} - -// SuccessPanelStyle returns a style for success panels. -// Deprecated: Use SuccessThemedPanelStyle for profile-aware theming. -func SuccessPanelStyle() PanelStyle { - return SuccessThemedPanelStyle(nil) -} - -// SuccessThemedPanelStyle returns a style for success panels using theme colors. -// If theme is nil, falls back to default theme. -// -//nolint:dupl // Intentional duplication for clarity - each style function is self-contained -func SuccessThemedPanelStyle(theme *themes.Theme) PanelStyle { - // Fallback to default theme if not provided - if theme == nil { - theme = getDefaultTheme() - } - if theme == nil { - // Ultimate fallback - return PanelStyle{ - TitleColor: lipgloss.Color("#00E091"), - BorderColor: lipgloss.Color("#00E091"), - TitleAlign: lipgloss.Left, - ContentAlign: lipgloss.Left, - Padding: 1, - Margin: 0, - Bold: true, - ShowBorder: true, - } - } - - return PanelStyle{ - TitleColor: theme.Colors.SuccessColor(), - BorderColor: theme.Colors.SuccessColor(), - TitleAlign: lipgloss.Left, - ContentAlign: lipgloss.Left, - Padding: 1, - Margin: 0, - Bold: true, - ShowBorder: true, - } -} - -// ErrorPanelStyle returns a style for error panels. -// Deprecated: Use ErrorThemedPanelStyle for profile-aware theming. -func ErrorPanelStyle() PanelStyle { - return ErrorThemedPanelStyle(nil) -} - -// ErrorThemedPanelStyle returns a style for error panels using theme colors. -// If theme is nil, falls back to default theme. -// -//nolint:dupl // Intentional duplication for clarity - each style function is self-contained -func ErrorThemedPanelStyle(theme *themes.Theme) PanelStyle { - // Fallback to default theme if not provided - if theme == nil { - theme = getDefaultTheme() - } - if theme == nil { - // Ultimate fallback - return PanelStyle{ - TitleColor: lipgloss.Color("#FF4444"), - BorderColor: lipgloss.Color("#FF4444"), - TitleAlign: lipgloss.Left, - ContentAlign: lipgloss.Left, - Padding: 1, - Margin: 0, - Bold: true, - ShowBorder: true, - } - } - - return PanelStyle{ - TitleColor: theme.Colors.ErrorColor(), - BorderColor: theme.Colors.ErrorColor(), - TitleAlign: lipgloss.Left, - ContentAlign: lipgloss.Left, - Padding: 1, - Margin: 0, - Bold: true, - ShowBorder: true, - } -} - -// WarningPanelStyle returns a style for warning panels. -// Deprecated: Use WarningThemedPanelStyle for profile-aware theming. -func WarningPanelStyle() PanelStyle { - return WarningThemedPanelStyle(nil) -} - -// WarningThemedPanelStyle returns a style for warning panels using theme colors. -// If theme is nil, falls back to default theme. -// -//nolint:dupl // Intentional duplication for clarity - each style function is self-contained -func WarningThemedPanelStyle(theme *themes.Theme) PanelStyle { - // Fallback to default theme if not provided - if theme == nil { - theme = getDefaultTheme() - } - if theme == nil { - // Ultimate fallback - return PanelStyle{ - TitleColor: lipgloss.Color("#FFB86C"), - BorderColor: lipgloss.Color("#FFB86C"), - TitleAlign: lipgloss.Left, - ContentAlign: lipgloss.Left, - Padding: 1, - Margin: 0, - Bold: true, - ShowBorder: true, - } - } - - return PanelStyle{ - TitleColor: theme.Colors.WarningColor(), - BorderColor: theme.Colors.WarningColor(), - TitleAlign: lipgloss.Left, - ContentAlign: lipgloss.Left, - Padding: 1, - Margin: 0, - Bold: true, - ShowBorder: true, - } -} diff --git a/pkg/ui/components/panel_test.go b/pkg/ui/components/panel_test.go deleted file mode 100644 index ada9f4d..0000000 --- a/pkg/ui/components/panel_test.go +++ /dev/null @@ -1,314 +0,0 @@ -package components - -import ( - "strings" - "testing" - - "github.com/charmbracelet/lipgloss" -) - -func TestNewPanel(t *testing.T) { - title := "Test Panel" - content := "Test content" - panel := NewPanel(title, content) - - if panel == nil { - t.Fatal("NewPanel() returned nil") - } - if panel.Title != title { - t.Errorf("Title = %q, want %q", panel.Title, title) - } - if panel.Content != content { - t.Errorf("Content = %q, want %q", panel.Content, content) - } - if panel.Width != 80 { - t.Errorf("Width = %d, want 80", panel.Width) - } - if !panel.Style.ShowBorder { - t.Error("ShowBorder = false, want true") - } -} - -func TestNewPanelWithStyle(t *testing.T) { - title := "Custom Panel" - content := "Custom content" - customStyle := &PanelStyle{ - TitleColor: lipgloss.Color("#FF0000"), - BorderColor: lipgloss.Color("#00FF00"), - TitleAlign: lipgloss.Center, - ContentAlign: lipgloss.Center, - Padding: 2, - Margin: 1, - Bold: false, - ShowBorder: false, - } - - panel := NewPanelWithStyle(title, content, customStyle) - - if panel == nil { - t.Fatal("NewPanelWithStyle() returned nil") - } - if panel.Style.TitleColor != customStyle.TitleColor { - t.Errorf("TitleColor mismatch") - } - if panel.Style.ShowBorder != false { - t.Error("ShowBorder should be false") - } - if panel.Style.Padding != 2 { - t.Errorf("Padding = %d, want 2", panel.Style.Padding) - } -} - -func TestPanel_SetWidth(t *testing.T) { - panel := NewPanel("Test", "Content") - newWidth := 120 - - result := panel.SetWidth(newWidth) - - if result != panel { - t.Error("SetWidth() should return self for chaining") - } - if panel.Width != newWidth { - t.Errorf("Width = %d, want %d", panel.Width, newWidth) - } -} - -func TestPanel_SetHeight(t *testing.T) { - panel := NewPanel("Test", "Content") - newHeight := 10 - - result := panel.SetHeight(newHeight) - - if result != panel { - t.Error("SetHeight() should return self for chaining") - } - if panel.Height != newHeight { - t.Errorf("Height = %d, want %d", panel.Height, newHeight) - } -} - -func TestPanel_WithTitle(t *testing.T) { - panel := NewPanel("Original", "Content") - newTitle := "Updated Title" - - result := panel.WithTitle(newTitle) - - if result != panel { - t.Error("WithTitle() should return self for chaining") - } - if panel.Title != newTitle { - t.Errorf("Title = %q, want %q", panel.Title, newTitle) - } -} - -func TestPanel_WithContent(t *testing.T) { - panel := NewPanel("Title", "Original") - newContent := "Updated Content" - - result := panel.WithContent(newContent) - - if result != panel { - t.Error("WithContent() should return self for chaining") - } - if panel.Content != newContent { - t.Errorf("Content = %q, want %q", panel.Content, newContent) - } -} - -func TestPanel_SetBorderStyle(t *testing.T) { - panel := NewPanel("Title", "Content") - newBorder := lipgloss.DoubleBorder() - - result := panel.SetBorderStyle(&newBorder) - - if result != panel { - t.Error("SetBorderStyle() should return self for chaining") - } -} - -func TestPanel_Render(t *testing.T) { - tests := []struct { - name string - panel *Panel - wantLen bool // Check if output has content - }{ - { - name: "with title and border", - panel: NewPanel("Test Title", "Test Content"), - wantLen: true, - }, - { - name: "without title", - panel: NewPanel("", "Just Content"), - wantLen: true, - }, - { - name: "without border", - panel: &Panel{ - Title: "No Border", - Content: "Content", - Style: PanelStyle{ - ShowBorder: false, - }, - }, - wantLen: true, - }, - { - name: "empty content", - panel: NewPanel("Title", ""), - wantLen: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - output := tt.panel.Render() - if tt.wantLen && len(output) == 0 { - t.Error("Render() returned empty string") - } - if !tt.wantLen && len(output) > 0 { - t.Error("Render() should return empty string") - } - }) - } -} - -func TestPanel_RenderWithTitle(t *testing.T) { - panel := NewPanel("My Title", "My Content") - output := panel.Render() - - // Output should contain both title and content - if !strings.Contains(output, "My Title") { - t.Error("Render() output should contain title") - } - if !strings.Contains(output, "My Content") { - t.Error("Render() output should contain content") - } -} - -func TestPanel_View(t *testing.T) { - panel := NewPanel("Title", "Content") - - view := panel.View() - render := panel.Render() - - if view != render { - t.Error("View() should return same as Render()") - } -} - -func TestPanel_ResponsiveWidth(t *testing.T) { - panel := NewPanel("Title", "Content") - - widths := []int{40, 80, 120, 160} - for _, width := range widths { - panel.SetWidth(width) - output := panel.Render() - if len(output) == 0 { - t.Errorf("Render() with width %d returned empty string", width) - } - } -} - -func TestPanel_EmptyContentHandling(t *testing.T) { - tests := []struct { - name string - title string - content string - }{ - {"empty title and content", "", ""}, - {"empty title", "", "Content"}, - {"empty content", "Title", ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - panel := NewPanel(tt.title, tt.content) - output := panel.Render() - // Should not panic and should return something - if output == "" && (tt.title != "" || tt.content != "") { - t.Error("Render() should not return empty for non-empty input") - } - }) - } -} - -func TestDefaultPanelStyle(t *testing.T) { - style := DefaultPanelStyle() - - if style.TitleColor == "" { - t.Error("DefaultPanelStyle() TitleColor should not be empty") - } - if style.BorderColor == "" { - t.Error("DefaultPanelStyle() BorderColor should not be empty") - } - if !style.ShowBorder { - t.Error("DefaultPanelStyle() ShowBorder should be true") - } - if !style.Bold { - t.Error("DefaultPanelStyle() Bold should be true") - } -} - -func TestInfoPanelStyle(t *testing.T) { - style := InfoPanelStyle() - - if style.TitleAlign != lipgloss.Center { - t.Error("InfoPanelStyle() TitleAlign should be Center") - } - if style.ContentAlign != lipgloss.Center { - t.Error("InfoPanelStyle() ContentAlign should be Center") - } - if style.Margin != 1 { - t.Errorf("InfoPanelStyle() Margin = %d, want 1", style.Margin) - } -} - -func TestSuccessPanelStyle(t *testing.T) { - style := SuccessPanelStyle() - - if style.TitleColor == "" { - t.Error("SuccessPanelStyle() TitleColor should not be empty") - } - if style.BorderColor == "" { - t.Error("SuccessPanelStyle() BorderColor should not be empty") - } - if !style.ShowBorder { - t.Error("SuccessPanelStyle() ShowBorder should be true") - } -} - -func TestErrorPanelStyle(t *testing.T) { - style := ErrorPanelStyle() - - if style.TitleColor == "" { - t.Error("ErrorPanelStyle() TitleColor should not be empty") - } - if style.BorderColor == "" { - t.Error("ErrorPanelStyle() BorderColor should not be empty") - } - if !style.ShowBorder { - t.Error("ErrorPanelStyle() ShowBorder should be true") - } -} - -func TestPanel_ChainedOperations(t *testing.T) { - panel := NewPanel("Initial", "Initial Content"). - SetWidth(100). - SetHeight(20). - WithTitle("Updated Title"). - WithContent("Updated Content") - - if panel.Width != 100 { - t.Errorf("Width = %d, want 100", panel.Width) - } - if panel.Height != 20 { - t.Errorf("Height = %d, want 20", panel.Height) - } - if panel.Title != "Updated Title" { - t.Errorf("Title = %q, want %q", panel.Title, "Updated Title") - } - if panel.Content != "Updated Content" { - t.Errorf("Content = %q, want %q", panel.Content, "Updated Content") - } -} diff --git a/pkg/ui/components/progress.go b/pkg/ui/components/progress.go deleted file mode 100644 index 94ea18a..0000000 --- a/pkg/ui/components/progress.go +++ /dev/null @@ -1,265 +0,0 @@ -package components - -import ( - "fmt" - "time" - - "github.com/charmbracelet/bubbles/progress" - "github.com/charmbracelet/lipgloss" -) - -// ProgressState tracks progress operation state -type ProgressState struct { - Current int64 - Total int64 - StartTime time.Time - Label string - ShowETA bool - ShowRate bool - UnitLabel string // e.g., "MB", "items", "files" -} - -// NewProgressState creates a new progress state tracker -func NewProgressState(total int64, label string) *ProgressState { - return &ProgressState{ - Current: 0, - Total: total, - StartTime: time.Now(), - Label: label, - ShowETA: true, - ShowRate: true, - UnitLabel: "items", - } -} - -// Progress returns the current progress as a percentage (0.0-1.0) -func (ps *ProgressState) Progress() float64 { - if ps.Total == 0 { - return 0.0 - } - return float64(ps.Current) / float64(ps.Total) -} - -// Percent returns the progress as a percentage string -func (ps *ProgressState) Percent() string { - return fmt.Sprintf("%.1f%%", ps.Progress()*100) -} - -// Rate calculates the current rate (items per second) -func (ps *ProgressState) Rate() float64 { - elapsed := time.Since(ps.StartTime).Seconds() - if elapsed == 0 { - return 0 - } - return float64(ps.Current) / elapsed -} - -// RateString returns the rate as a formatted string -func (ps *ProgressState) RateString() string { - rate := ps.Rate() - if rate < 1 { - return fmt.Sprintf("%.2f %s/s", rate, ps.UnitLabel) - } - return fmt.Sprintf("%.1f %s/s", rate, ps.UnitLabel) -} - -// ETA calculates estimated time to completion -func (ps *ProgressState) ETA() time.Duration { - if ps.Current == 0 { - return 0 - } - rate := ps.Rate() - if rate == 0 { - return 0 - } - remaining := ps.Total - ps.Current - seconds := float64(remaining) / rate - return time.Duration(seconds * float64(time.Second)) -} - -// ETAString returns the ETA as a formatted string -func (ps *ProgressState) ETAString() string { - eta := ps.ETA() - if eta == 0 { - return "calculating..." - } - if eta < time.Minute { - return fmt.Sprintf("%ds", int(eta.Seconds())) - } - if eta < time.Hour { - return fmt.Sprintf("%dm %ds", int(eta.Minutes()), int(eta.Seconds())%60) - } - return fmt.Sprintf("%dh %dm", int(eta.Hours()), int(eta.Minutes())%60) -} - -// Update increments the current progress -func (ps *ProgressState) Update(delta int64) { - ps.Current += delta - if ps.Current > ps.Total { - ps.Current = ps.Total - } -} - -// SetCurrent sets the current progress value -func (ps *ProgressState) SetCurrent(current int64) { - ps.Current = current - if ps.Current > ps.Total { - ps.Current = ps.Total - } -} - -// IsComplete returns true if progress is complete -func (ps *ProgressState) IsComplete() bool { - return ps.Current >= ps.Total -} - -// View renders the progress state with bar and metadata -func (ps *ProgressState) View(progressBar *progress.Model) string { - var parts []string - - // Add label if present - if ps.Label != "" { - labelStyle := lipgloss.NewStyle().Bold(true) - parts = append(parts, labelStyle.Render(ps.Label)) - } - - // Render progress bar - parts = append(parts, progressBar.ViewAs(ps.Progress())) - - // Add metadata line - metadata := ps.Percent() - if ps.ShowRate && ps.Current > 0 { - metadata += " • " + ps.RateString() - } - if ps.ShowETA && !ps.IsComplete() && ps.Current > 0 { - metadata += " • ETA: " + ps.ETAString() - } - if ps.Total > 0 { - metadata += fmt.Sprintf(" • %d/%d %s", ps.Current, ps.Total, ps.UnitLabel) - } - - parts = append(parts, metadata) - - return lipgloss.JoinVertical(lipgloss.Left, parts...) -} - -// NewProgress creates a progress bar with default gradient -func NewProgress() progress.Model { - return progress.New( - progress.WithDefaultGradient(), - progress.WithWidth(40), - ) -} - -// NewProgressWithWidth creates a progress bar with custom width -func NewProgressWithWidth(width int) progress.Model { - return progress.New( - progress.WithDefaultGradient(), - progress.WithWidth(width), - ) -} - -// NewProgressWithColors creates a progress bar with custom colors -func NewProgressWithColors(width int, fullColor, emptyColor string) progress.Model { - return progress.New( - progress.WithSolidFill(fullColor), - progress.WithoutPercentage(), - progress.WithWidth(width), - ) -} - -// NewSolidProgress creates a solid color progress bar -func NewSolidProgress(width int, color string) progress.Model { - return progress.New( - progress.WithSolidFill(color), - progress.WithWidth(width), - ) -} - -// MultiProgress manages multiple progress indicators -type MultiProgress struct { - Items []*ProgressState - ProgressBar progress.Model - MinDuration time.Duration // Don't show if operation completes faster than this - Spacing int // Lines between progress bars -} - -// NewMultiProgress creates a new multi-progress manager -func NewMultiProgress(width int) *MultiProgress { - return &MultiProgress{ - Items: make([]*ProgressState, 0), - ProgressBar: NewProgressWithWidth(width), - MinDuration: 200 * time.Millisecond, - Spacing: 1, - } -} - -// Add adds a progress state to the multi-progress -func (mp *MultiProgress) Add(state *ProgressState) { - mp.Items = append(mp.Items, state) -} - -// Remove removes a progress state from the multi-progress -func (mp *MultiProgress) Remove(state *ProgressState) { - for i, item := range mp.Items { - if item == state { - mp.Items = append(mp.Items[:i], mp.Items[i+1:]...) - break - } - } -} - -// ShouldShow returns true if the operation has been running long enough to show progress -func (mp *MultiProgress) ShouldShow(state *ProgressState) bool { - return time.Since(state.StartTime) >= mp.MinDuration -} - -// View renders all progress indicators in a stacked layout -func (mp *MultiProgress) View() string { - if len(mp.Items) == 0 { - return "" - } - - var views []string - for _, state := range mp.Items { - // Only show if operation is slow enough - if mp.ShouldShow(state) && !state.IsComplete() { - views = append(views, state.View(&mp.ProgressBar)) - } - } - - if len(views) == 0 { - return "" - } - - // Add spacing between items - spacing := lipgloss.NewStyle().Height(mp.Spacing).Render("") - return lipgloss.JoinVertical(lipgloss.Left, views...) + spacing -} - -// ActiveCount returns the number of active (incomplete) progress items -func (mp *MultiProgress) ActiveCount() int { - count := 0 - for _, state := range mp.Items { - if !state.IsComplete() { - count++ - } - } - return count -} - -// IsComplete returns true if all progress items are complete -func (mp *MultiProgress) IsComplete() bool { - return mp.ActiveCount() == 0 -} - -// Clear removes all completed items -func (mp *MultiProgress) Clear() { - active := make([]*ProgressState, 0) - for _, state := range mp.Items { - if !state.IsComplete() { - active = append(active, state) - } - } - mp.Items = active -} diff --git a/pkg/ui/components/progress/progress.go b/pkg/ui/components/progress/progress.go deleted file mode 100644 index c63c956..0000000 --- a/pkg/ui/components/progress/progress.go +++ /dev/null @@ -1,229 +0,0 @@ -package progress - -import ( - "fmt" - - "github.com/charmbracelet/bubbles/progress" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// Progress displays a percentage-based progress bar with theme-aware styling. -// Used in workspace operations, loading states, and any task tracking. -// -// Design: 017-ui-engine Phase 3 (User Story - Progress Component) -type Progress struct { - theme *themes.Theme - model progress.Model -} - -const ( - // FilledChar is the character used for the filled portion of the bar (deprecated, kept for test compatibility) - FilledChar = "█" - // EmptyChar is the character used for the empty portion of the bar (deprecated, kept for test compatibility) - EmptyChar = "░" -) - -// NewProgress creates a new Progress component with the given theme. -// The progress bar uses the theme's primary color for filled sections with gradient. -func NewProgress(theme *themes.Theme) *Progress { - p := &Progress{ - theme: theme, - } - - // Initialize with default gradient, then apply theme - if theme != nil { - primaryColor := string(theme.Colors.PrimaryColor()) - secondaryColor := string(theme.Colors.SecondaryColor()) - - // Create gradient from primary to secondary for visual richness - p.model = progress.New( - progress.WithoutPercentage(), - progress.WithWidth(40), - progress.WithGradient(primaryColor, secondaryColor), - ) - - // Apply muted color to empty portion - p.model.EmptyColor = string(theme.Colors.MutedColor()) - } else { - // Fallback to default gradient if no theme - p.model = progress.New( - progress.WithoutPercentage(), - progress.WithWidth(40), - progress.WithDefaultGradient(), - ) - } - - return p -} - -// applyTheme applies the current theme colors to the bubbles progress model -func (p *Progress) applyTheme() { - if p.theme == nil { - return - } - - // Apply theme's primary to secondary gradient for the filled portion - primaryColor := string(p.theme.Colors.PrimaryColor()) - secondaryColor := string(p.theme.Colors.SecondaryColor()) - - // Recreate the model with new gradient - p.model = progress.New( - progress.WithoutPercentage(), - progress.WithWidth(p.model.Width), - progress.WithGradient(primaryColor, secondaryColor), - ) - - // Apply theme's muted color to the empty portion - p.model.EmptyColor = string(p.theme.Colors.MutedColor()) -} - -// Render returns a progress bar as a string for the given width, percent, and optional label. -// Width is the total character width of the progress bar (not including brackets and percentage). -// Percent should be between 0 and 100. -// Label is displayed above the progress bar if provided. -// -// Example output with label: -// -// Loading workspace... -// [████████░░] 80% -// -// Example output without label: -// -// [████████░░] 80% -func (p *Progress) Render(width int, percent float64, label string) string { - if p.theme == nil { - return "" - } - - // Clamp percent to 0-100 range - if percent < 0 { - percent = 0 - } - if percent > 100 { - percent = 100 - } - - // Convert percent to 0-1 range for bubbles - percentDecimal := percent / 100.0 - - // Account for brackets "[" and "]" plus space and percentage text " 100%" - // Total overhead: 2 (brackets) + 1 (space) + 4-5 (percentage) = ~7-8 chars - barWidth := width - 8 - if barWidth < 1 { - barWidth = 1 - } - - // Update model width if needed - p.model.Width = barWidth - - // Render the progress bar using bubbles - progressBar := p.model.ViewAs(percentDecimal) - - // Format percentage - percentStr := fmt.Sprintf("%.0f%%", percent) - - // Build the progress bar line with brackets - bar := fmt.Sprintf("[%s] %s", progressBar, percentStr) - - // If there's a label, render it above the bar - if label != "" { - labelStyle := lipgloss.NewStyle(). - Foreground(p.theme.Colors.ForegroundColor()). - Bold(true) - - return lipgloss.JoinVertical(lipgloss.Left, - labelStyle.Render(label), - bar, - ) - } - - return bar -} - -// SetTheme updates the theme for the progress bar. -// This allows dynamic theme switching without recreating the component. -func (p *Progress) SetTheme(theme *themes.Theme) { - p.theme = theme - p.applyTheme() -} - -// RenderSimple returns a progress bar without brackets, useful for inline display. -// This variant shows only the bar itself with the percentage. -// -// Example output: -// -// ████████░░ 80% -func (p *Progress) RenderSimple(width int, percent float64) string { - if p.theme == nil { - return "" - } - - // Clamp percent to 0-100 range - if percent < 0 { - percent = 0 - } - if percent > 100 { - percent = 100 - } - - // Convert percent to 0-1 range for bubbles - percentDecimal := percent / 100.0 - - // Calculate bar width (no brackets) - barWidth := width - 5 // space + percentage (e.g., " 100%") - if barWidth < 1 { - barWidth = 1 - } - - // Update model width - p.model.Width = barWidth - - // Render the progress bar using bubbles - progressBar := p.model.ViewAs(percentDecimal) - - // Format percentage - percentStr := fmt.Sprintf("%.0f%%", percent) - - return fmt.Sprintf("%s %s", progressBar, percentStr) -} - -// RenderCompact returns a compact progress bar with minimal styling. -// Useful for displaying progress in tight spaces like status lines. -// -// Example output: -// -// [████] 80% -func (p *Progress) RenderCompact(width int, percent float64) string { - if p.theme == nil { - return "" - } - - // Clamp percent to 0-100 range - if percent < 0 { - percent = 0 - } - if percent > 100 { - percent = 100 - } - - // Convert percent to 0-1 range for bubbles - percentDecimal := percent / 100.0 - - // Smaller bar for compact mode - barWidth := width - 8 - if barWidth < 4 { - barWidth = 4 - } - - // Update model width - p.model.Width = barWidth - - // Render the progress bar using bubbles - progressBar := p.model.ViewAs(percentDecimal) - - percentStr := fmt.Sprintf("%.0f%%", percent) - - return fmt.Sprintf("[%s] %s", progressBar, percentStr) -} diff --git a/pkg/ui/components/progress/progress_test.go b/pkg/ui/components/progress/progress_test.go deleted file mode 100644 index 3f37dc5..0000000 --- a/pkg/ui/components/progress/progress_test.go +++ /dev/null @@ -1,439 +0,0 @@ -package progress - -import ( - "strings" - "testing" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// mockTheme creates a simple theme for testing -func mockTheme() *themes.Theme { - return &themes.Theme{ - Name: "Test Theme", - Description: "Theme for testing", - Colors: themes.ColorSet{ - Primary: "#00ADD8", - Secondary: "#FFD700", - Foreground: "#FFFFFF", - Muted: "#808080", - Background: "#000000", - Success: "#00FF00", - Error: "#FF0000", - Warning: "#FFA500", - Info: "#0000FF", - Border: "#444444", - }, - } -} - -func TestNewProgress(t *testing.T) { - theme := mockTheme() - p := NewProgress(theme) - - if p == nil { - t.Fatal("NewProgress returned nil") - } - - if p.theme == nil { - t.Error("Progress theme is nil") - } - - if p.theme.Name != "Test Theme" { - t.Errorf("Expected theme name 'Test Theme', got '%s'", p.theme.Name) - } -} - -func TestProgress_SetTheme(t *testing.T) { - theme1 := mockTheme() - theme2 := &themes.Theme{ - Name: "Another Theme", - Colors: themes.ColorSet{ - Primary: "#FF0000", - }, - } - - p := NewProgress(theme1) - p.SetTheme(theme2) - - if p.theme.Name != "Another Theme" { - t.Errorf("Expected theme name 'Another Theme', got '%s'", p.theme.Name) - } -} - -func TestProgress_Render_WithLabel(t *testing.T) { - theme := mockTheme() - p := NewProgress(theme) - - result := p.Render(40, 50, "Loading...") - - // Should contain the label - if !strings.Contains(result, "Loading...") { - t.Error("Expected result to contain label 'Loading...'") - } - - // Should contain filled characters - if !strings.Contains(result, FilledChar) { - t.Error("Expected result to contain filled characters") - } - - // Should contain empty characters (at 50%) - if !strings.Contains(result, EmptyChar) { - t.Error("Expected result to contain empty characters") - } - - // Should contain percentage - if !strings.Contains(result, "50%") { - t.Error("Expected result to contain '50%'") - } - - // Should contain brackets - if !strings.Contains(result, "[") || !strings.Contains(result, "]") { - t.Error("Expected result to contain brackets") - } - - // Should be multi-line (label + bar) - lines := strings.Split(result, "\n") - if len(lines) < 2 { - t.Errorf("Expected at least 2 lines with label, got %d", len(lines)) - } -} - -func TestProgress_Render_WithoutLabel(t *testing.T) { - theme := mockTheme() - p := NewProgress(theme) - - result := p.Render(40, 75, "") - - // Should not have multiple lines - lines := strings.Split(result, "\n") - if len(lines) > 1 { - t.Errorf("Expected 1 line without label, got %d", len(lines)) - } - - // Should contain percentage - if !strings.Contains(result, "75%") { - t.Error("Expected result to contain '75%'") - } -} - -func TestProgress_Render_PercentClamping(t *testing.T) { - theme := mockTheme() - p := NewProgress(theme) - - tests := []struct { - name string - percent float64 - expectedPercent string - }{ - {"Below 0", -10, "0%"}, - {"Zero", 0, "0%"}, - {"Normal", 50, "50%"}, - {"Full", 100, "100%"}, - {"Above 100", 150, "100%"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := p.Render(40, tt.percent, "") - if !strings.Contains(result, tt.expectedPercent) { - t.Errorf("Expected '%s', got result: %s", tt.expectedPercent, result) - } - }) - } -} - -func TestProgress_Render_EdgeCases(t *testing.T) { - theme := mockTheme() - p := NewProgress(theme) - - t.Run("0% progress", func(t *testing.T) { - result := p.Render(40, 0, "") - // Should contain only empty characters and 0% - if !strings.Contains(result, "0%") { - t.Error("Expected result to contain '0%'") - } - // Should still have brackets - if !strings.Contains(result, "[") { - t.Error("Expected result to contain opening bracket") - } - }) - - t.Run("100% progress", func(t *testing.T) { - result := p.Render(40, 100, "") - // Should contain 100% - if !strings.Contains(result, "100%") { - t.Error("Expected result to contain '100%'") - } - // Should have filled characters - if !strings.Contains(result, FilledChar) { - t.Error("Expected result to contain filled characters") - } - }) - - t.Run("50% progress exact", func(t *testing.T) { - result := p.Render(40, 50, "") - // Should contain both filled and empty - if !strings.Contains(result, FilledChar) || !strings.Contains(result, EmptyChar) { - t.Error("Expected result to contain both filled and empty characters") - } - }) -} - -func TestProgress_Render_NilTheme(t *testing.T) { - p := &Progress{theme: nil} - result := p.Render(40, 50, "Test") - - if result != "" { - t.Errorf("Expected empty string with nil theme, got: %s", result) - } -} - -func TestProgress_Render_SmallWidth(t *testing.T) { - theme := mockTheme() - p := NewProgress(theme) - - // Should handle small widths gracefully - result := p.Render(10, 50, "") - if result == "" { - t.Error("Expected non-empty result even with small width") - } - - // Should still contain percentage - if !strings.Contains(result, "50%") { - t.Error("Expected result to contain percentage") - } -} - -func TestProgress_RenderSimple(t *testing.T) { - theme := mockTheme() - p := NewProgress(theme) - - result := p.RenderSimple(40, 60) - - // Should not contain brackets - if strings.Contains(result, "[") || strings.Contains(result, "]") { - t.Error("RenderSimple should not contain brackets") - } - - // Should contain filled and empty characters - if !strings.Contains(result, FilledChar) { - t.Error("Expected result to contain filled characters") - } - if !strings.Contains(result, EmptyChar) { - t.Error("Expected result to contain empty characters") - } - - // Should contain percentage - if !strings.Contains(result, "60%") { - t.Error("Expected result to contain '60%'") - } -} - -func TestProgress_RenderSimple_EdgeCases(t *testing.T) { - theme := mockTheme() - p := NewProgress(theme) - - tests := []struct { - name string - percent float64 - }{ - {"Zero", 0}, - {"Half", 50}, - {"Full", 100}, - {"Negative", -50}, - {"Over 100", 150}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := p.RenderSimple(40, tt.percent) - if result == "" { - t.Error("Expected non-empty result") - } - // Should not have brackets - if strings.Contains(result, "[") { - t.Error("RenderSimple should not contain brackets") - } - }) - } -} - -func TestProgress_RenderSimple_NilTheme(t *testing.T) { - p := &Progress{theme: nil} - result := p.RenderSimple(40, 50) - - if result != "" { - t.Errorf("Expected empty string with nil theme, got: %s", result) - } -} - -func TestProgress_RenderCompact(t *testing.T) { - theme := mockTheme() - p := NewProgress(theme) - - result := p.RenderCompact(20, 80) - - // Should contain brackets - if !strings.Contains(result, "[") || !strings.Contains(result, "]") { - t.Error("RenderCompact should contain brackets") - } - - // Should contain percentage - if !strings.Contains(result, "80%") { - t.Error("Expected result to contain '80%'") - } - - // Should be shorter than normal render - normalResult := p.Render(20, 80, "") - if len(result) > len(normalResult) { - t.Error("Compact version should not be longer than normal") - } -} - -func TestProgress_RenderCompact_EdgeCases(t *testing.T) { - theme := mockTheme() - p := NewProgress(theme) - - tests := []struct { - name string - percent float64 - }{ - {"Zero", 0}, - {"Quarter", 25}, - {"Half", 50}, - {"Three Quarters", 75}, - {"Full", 100}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := p.RenderCompact(20, tt.percent) - if result == "" { - t.Error("Expected non-empty result") - } - // Should contain brackets - if !strings.Contains(result, "[") { - t.Error("Expected result to contain brackets") - } - }) - } -} - -func TestProgress_RenderCompact_NilTheme(t *testing.T) { - p := &Progress{theme: nil} - result := p.RenderCompact(20, 50) - - if result != "" { - t.Errorf("Expected empty string with nil theme, got: %s", result) - } -} - -func TestProgress_AllMethods_WithDifferentWidths(t *testing.T) { - theme := mockTheme() - p := NewProgress(theme) - - widths := []int{10, 20, 40, 80, 120} - - for _, width := range widths { - t.Run("Width "+string(rune(width)), func(t *testing.T) { - // Test all render methods with different widths - result1 := p.Render(width, 50, "Test") - result2 := p.Render(width, 50, "") - result3 := p.RenderSimple(width, 50) - result4 := p.RenderCompact(width, 50) - - if result1 == "" || result2 == "" || result3 == "" || result4 == "" { - t.Error("Expected non-empty results for all render methods") - } - }) - } -} - -func TestProgress_ThemeColors(t *testing.T) { - theme := mockTheme() - p := NewProgress(theme) - - // Render and check that lipgloss styling is applied - result := p.Render(40, 50, "Testing colors") - - // The result should contain ANSI escape codes from lipgloss styling - // This is a basic check that styling is being applied - if !strings.Contains(result, "Testing colors") { - t.Error("Expected result to contain label") - } - - // Just verify we can call the render without panicking - // The actual color codes are hard to test without visual inspection - t.Log("Progress bar rendered successfully with theme colors") -} - -func TestProgress_PercentageFormatting(t *testing.T) { - theme := mockTheme() - p := NewProgress(theme) - - tests := []struct { - percent float64 - expected string - }{ - {0, "0%"}, - {0.5, "0%"}, // Rounds down - {1, "1%"}, - {50, "50%"}, - {50.4, "50%"}, // Rounds down - {50.5, "50%"}, // Rounds to even (banker's rounding) but formatted as integer - {99, "99%"}, - {99.9, "100%"}, // Rounds up - {100, "100%"}, - } - - for _, tt := range tests { - result := p.Render(40, tt.percent, "") - if !strings.Contains(result, tt.expected) { - t.Errorf("For %.1f%%, expected to contain '%s', got: %s", tt.percent, tt.expected, result) - } - } -} - -func TestProgress_ConsistentOutput(t *testing.T) { - theme := mockTheme() - p := NewProgress(theme) - - // Same input should produce same output - result1 := p.Render(40, 50, "Test") - result2 := p.Render(40, 50, "Test") - - if result1 != result2 { - t.Error("Expected consistent output for same inputs") - } -} - -func TestProgress_FilledEmptyRatio(t *testing.T) { - theme := mockTheme() - p := NewProgress(theme) - - // At 0%, should have no filled characters - result0 := p.RenderSimple(40, 0) - filledCount0 := strings.Count(result0, FilledChar) - if filledCount0 != 0 { - t.Errorf("Expected 0 filled characters at 0%%, got %d", filledCount0) - } - - // At 100%, should have no empty characters - result100 := p.RenderSimple(40, 100) - emptyCount100 := strings.Count(result100, EmptyChar) - if emptyCount100 != 0 { - t.Errorf("Expected 0 empty characters at 100%%, got %d", emptyCount100) - } - - // At 50%, should have roughly equal filled and empty - result50 := p.RenderSimple(40, 50) - filledCount50 := strings.Count(result50, FilledChar) - emptyCount50 := strings.Count(result50, EmptyChar) - - // Allow for rounding differences - diff := filledCount50 - emptyCount50 - if diff < -2 || diff > 2 { - t.Errorf("Expected roughly equal filled (%d) and empty (%d) at 50%%", filledCount50, emptyCount50) - } -} diff --git a/pkg/ui/components/progress_test.go b/pkg/ui/components/progress_test.go deleted file mode 100644 index 3aa9a0e..0000000 --- a/pkg/ui/components/progress_test.go +++ /dev/null @@ -1,337 +0,0 @@ -package components - -import ( - "testing" - "time" - - "github.com/charmbracelet/bubbles/progress" -) - -func TestNewProgressState(t *testing.T) { - total := int64(100) - label := "Test Progress" - ps := NewProgressState(total, label) - - if ps == nil { - t.Fatal("NewProgressState() returned nil") - } - if ps.Total != total { - t.Errorf("Total = %d, want %d", ps.Total, total) - } - if ps.Label != label { - t.Errorf("Label = %q, want %q", ps.Label, label) - } - if ps.Current != 0 { - t.Errorf("Current = %d, want 0", ps.Current) - } - if !ps.ShowETA { - t.Error("ShowETA should be true by default") - } - if !ps.ShowRate { - t.Error("ShowRate should be true by default") - } -} - -func TestProgressState_Progress(t *testing.T) { - tests := []struct { - name string - current int64 - total int64 - want float64 - }{ - {"zero progress", 0, 100, 0.0}, - {"half progress", 50, 100, 0.5}, - {"full progress", 100, 100, 1.0}, - {"zero total", 0, 0, 0.0}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ps := &ProgressState{ - Current: tt.current, - Total: tt.total, - } - got := ps.Progress() - if got != tt.want { - t.Errorf("Progress() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestProgressState_Percent(t *testing.T) { - ps := &ProgressState{ - Current: 25, - Total: 100, - } - - percent := ps.Percent() - if percent == "" { - t.Error("Percent() returned empty string") - } - if percent != "25.0%" { - t.Errorf("Percent() = %q, want %q", percent, "25.0%") - } -} - -func TestProgressState_Rate(t *testing.T) { - ps := &ProgressState{ - Current: 100, - Total: 200, - StartTime: time.Now().Add(-1 * time.Second), - } - - rate := ps.Rate() - if rate < 90 || rate > 110 { - t.Errorf("Rate() = %v, want ~100", rate) - } -} - -func TestProgressState_RateString(t *testing.T) { - ps := &ProgressState{ - Current: 100, - Total: 200, - StartTime: time.Now().Add(-1 * time.Second), - UnitLabel: "items", - } - - rateStr := ps.RateString() - if rateStr == "" { - t.Error("RateString() returned empty string") - } -} - -func TestProgressState_ETA(t *testing.T) { - ps := &ProgressState{ - Current: 50, - Total: 100, - StartTime: time.Now().Add(-1 * time.Second), - } - - eta := ps.ETA() - if eta < 0 { - t.Error("ETA() returned negative duration") - } - // Should be approximately 1 second (same time to complete remaining 50) - if eta < 500*time.Millisecond || eta > 2*time.Second { - t.Errorf("ETA() = %v, want ~1s", eta) - } -} - -func TestProgressState_ETAString(t *testing.T) { - tests := []struct { - name string - current int64 - total int64 - elapsed time.Duration - }{ - {"zero current", 0, 100, 1 * time.Second}, - {"some progress", 50, 100, 1 * time.Second}, - {"near completion", 99, 100, 1 * time.Second}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ps := &ProgressState{ - Current: tt.current, - Total: tt.total, - StartTime: time.Now().Add(-tt.elapsed), - } - etaStr := ps.ETAString() - if etaStr == "" { - t.Error("ETAString() returned empty string") - } - }) - } -} - -func TestProgressState_Update(t *testing.T) { - ps := NewProgressState(100, "Test") - - ps.Update(25) - if ps.Current != 25 { - t.Errorf("After Update(25), Current = %d, want 25", ps.Current) - } - - ps.Update(25) - if ps.Current != 50 { - t.Errorf("After Update(25) again, Current = %d, want 50", ps.Current) - } - - // Test overflow protection - ps.Update(100) - if ps.Current != 100 { - t.Errorf("After Update(100), Current = %d, want 100 (capped)", ps.Current) - } -} - -func TestProgressState_SetCurrent(t *testing.T) { - ps := NewProgressState(100, "Test") - - ps.SetCurrent(75) - if ps.Current != 75 { - t.Errorf("SetCurrent(75), Current = %d, want 75", ps.Current) - } - - // Test overflow protection - ps.SetCurrent(150) - if ps.Current != 100 { - t.Errorf("SetCurrent(150), Current = %d, want 100 (capped)", ps.Current) - } -} - -func TestProgressState_IsComplete(t *testing.T) { - ps := NewProgressState(100, "Test") - - if ps.IsComplete() { - t.Error("IsComplete() = true for 0/100, want false") - } - - ps.SetCurrent(50) - if ps.IsComplete() { - t.Error("IsComplete() = true for 50/100, want false") - } - - ps.SetCurrent(100) - if !ps.IsComplete() { - t.Error("IsComplete() = false for 100/100, want true") - } - - ps.SetCurrent(101) - if !ps.IsComplete() { - t.Error("IsComplete() = false for 101/100, want true") - } -} - -func TestProgressState_View(t *testing.T) { - ps := NewProgressState(100, "Test Progress") - ps.SetCurrent(50) - - pb := progress.New() - view := ps.View(&pb) - - if view == "" { - t.Error("View() returned empty string") - } -} - -func TestNewProgress(t *testing.T) { - pb := NewProgress() - // Should not panic and should return a valid progress model - if pb.Width == 0 { - t.Error("NewProgress() returned progress with 0 width") - } -} - -func TestNewProgressWithWidth(t *testing.T) { - width := 50 - pb := NewProgressWithWidth(width) - - if pb.Width != width { - t.Errorf("NewProgressWithWidth(%d) Width = %d, want %d", width, pb.Width, width) - } -} - -func TestNewProgressWithColors(t *testing.T) { - width := 40 - fullColor := "#00FF00" - emptyColor := "#FF0000" - - pb := NewProgressWithColors(width, fullColor, emptyColor) - - if pb.Width != width { - t.Errorf("Width = %d, want %d", pb.Width, width) - } -} - -func TestNewSolidProgress(t *testing.T) { - width := 30 - color := "#00ADD8" - - pb := NewSolidProgress(width, color) - - if pb.Width != width { - t.Errorf("Width = %d, want %d", pb.Width, width) - } -} - -func TestNewMultiProgress(t *testing.T) { - width := 40 - mp := NewMultiProgress(width) - - if mp == nil { - t.Fatal("NewMultiProgress() returned nil") - } - if mp.ProgressBar.Width != width { - t.Errorf("ProgressBar.Width = %d, want %d", mp.ProgressBar.Width, width) - } - if mp.MinDuration != 200*time.Millisecond { - t.Errorf("MinDuration = %v, want 200ms", mp.MinDuration) - } - if len(mp.Items) != 0 { - t.Errorf("Items length = %d, want 0", len(mp.Items)) - } -} - -func TestMultiProgress_Add(t *testing.T) { - mp := NewMultiProgress(40) - - ps1 := NewProgressState(100, "Task 1") - ps2 := NewProgressState(200, "Task 2") - - mp.Add(ps1) - if len(mp.Items) != 1 { - t.Errorf("After Add(ps1), Items length = %d, want 1", len(mp.Items)) - } - - mp.Add(ps2) - if len(mp.Items) != 2 { - t.Errorf("After Add(ps2), Items length = %d, want 2", len(mp.Items)) - } -} - -func TestProgressState_AutoHideLogic(t *testing.T) { - mp := NewMultiProgress(40) - - // Fast operation (< MinDuration) - fastTime := 100 * time.Millisecond - if fastTime >= mp.MinDuration { - t.Errorf("Test setup error: fastTime should be < MinDuration") - } - - // Slow operation (>= MinDuration) - slowTime := 300 * time.Millisecond - if slowTime < mp.MinDuration { - t.Errorf("Test setup error: slowTime should be >= MinDuration") - } -} - -func TestProgressState_MultipleUpdates(t *testing.T) { - ps := NewProgressState(1000, "Test") - - increments := []int64{100, 200, 300, 400} - expected := int64(0) - - for _, inc := range increments { - ps.Update(inc) - expected += inc - if ps.Current != expected { - t.Errorf("After Update(%d), Current = %d, want %d", inc, ps.Current, expected) - } - } -} - -func TestProgressState_RateCalculationEdgeCases(t *testing.T) { - // Test with zero elapsed time - ps := &ProgressState{ - Current: 100, - Total: 1000, - StartTime: time.Now(), - } - - rate := ps.Rate() - // Should handle division by zero gracefully - if rate < 0 { - t.Error("Rate() should not return negative value") - } -} diff --git a/pkg/ui/components/safeborder.go b/pkg/ui/components/safeborder.go deleted file mode 100644 index 906f028..0000000 --- a/pkg/ui/components/safeborder.go +++ /dev/null @@ -1,350 +0,0 @@ -// Package components provides reusable UI components for the A.R.C. CLI. -package components - -import ( - "os" - "strings" - "sync" - - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/internal/preferences" -) - -// BorderTier represents the three tiers of border rendering capability. -type BorderTier int - -const ( - // BorderTierNone is Tier 1: borderless, uses spacing + color only. Cannot break. - BorderTierNone BorderTier = iota - // BorderTierBlock is Tier 2: half-block borders (▀▄▌▐). High terminal compatibility. - BorderTierBlock - // BorderTierClassic is Tier 3: classic Unicode borders (╭╮╰╯). Opt-in only. - BorderTierClassic -) - -const ( - // BorderTierNameNone is the string representation of BorderTierNone. - BorderTierNameNone = "none" - // BorderTierNameBlock is the string representation of BorderTierBlock. - BorderTierNameBlock = "block" - // BorderTierNameClassic is the string representation of BorderTierClassic. - BorderTierNameClassic = "classic" -) - -// String returns a human-readable string for the BorderTier. -func (bt BorderTier) String() string { - switch bt { - case BorderTierNone: - return BorderTierNameNone - case BorderTierBlock: - return BorderTierNameBlock - case BorderTierClassic: - return BorderTierNameClassic - default: - return "unknown" - } -} - -// ParseBorderTier parses a string into a BorderTier. -// Returns BorderTierNone for invalid or empty strings (safe default). -func ParseBorderTier(s string) BorderTier { - switch strings.ToLower(s) { - case BorderTierNameBlock: - return BorderTierBlock - case BorderTierNameClassic: - return BorderTierClassic - case BorderTierNameNone, "": - return BorderTierNone - default: - return BorderTierNone - } -} - -// TerminalInfo holds detected terminal environment information. -type TerminalInfo struct { - // TermProgram is the TERM_PROGRAM env value (e.g., "iTerm.app", "vscode") - TermProgram string - // Term is the TERM env value (e.g., "xterm-256color") - Term string - // ColorTerm is the COLORTERM env value (e.g., "truecolor") - ColorTerm string - // HasWindowsTerminal is true if WT_SESSION env is set - HasWindowsTerminal bool - // DetectedTier is the auto-detected tier before any overrides - DetectedTier BorderTier - // ActiveTier is the current active tier (may differ from detected if overridden) - ActiveTier BorderTier - // OverrideSource describes where the override came from ("env", "config", "auto") - OverrideSource string -} - -// SafeBorder provides terminal border rendering capability detection and management. -type SafeBorder struct { - tier BorderTier - terminalInfo TerminalInfo - border lipgloss.Border - focusBorder lipgloss.Border - mu sync.RWMutex - prefs *preferences.Preferences -} - -var ( - // globalSafeBorder is the singleton instance, initialized on first use. - globalSafeBorder *SafeBorder - globalOnce sync.Once -) - -// DetectBorderMode performs border capability detection with the priority chain: -// 1. ARC_BORDER_MODE env var (user override) -// 2. state.json border_mode preference (persisted choice) -// 3. TERM_PROGRAM (program identity) -// 4. Terminal-specific env vars (WT_SESSION, KITTY_WINDOW_ID, etc.) -// 5. TERM + LANG (capability hints) -// 6. Default: Tier 1 (borderless — cannot break) -// -//nolint:cyclop,gocyclo // Inherently complex due to environment detection logic — must check multiple env vars and conditions -func DetectBorderMode() (BorderTier, TerminalInfo) { - info := TerminalInfo{ - TermProgram: os.Getenv("TERM_PROGRAM"), - Term: os.Getenv("TERM"), - ColorTerm: os.Getenv("COLORTERM"), - HasWindowsTerminal: os.Getenv("WT_SESSION") != "", - OverrideSource: "auto", - } - - // Priority 1: ARC_BORDER_MODE env var (explicit override) - if envMode := os.Getenv("ARC_BORDER_MODE"); envMode != "" { - tier := ParseBorderTier(envMode) - info.DetectedTier = tier - info.ActiveTier = tier - info.OverrideSource = "env" - return tier, info - } - - // Priority 2: state.json border_mode preference (persisted choice) - if prefs, err := preferences.Load(); err == nil && prefs.BorderMode != "" { - tier := ParseBorderTier(prefs.BorderMode) - info.DetectedTier = tier - info.ActiveTier = tier - info.OverrideSource = "config" - return tier, info - } - - // Priority 3: TERM_PROGRAM (program identity) - most reliable - // Known-good terminals for Tier 2 (half-block borders) - knownGoodTerminals := []string{ - "iTerm.app", // iTerm2 - "WezTerm", // WezTerm - "Ghostty", // Ghostty - "Alacritty", // Alacritty - "kitty", // Kitty - "vscode", // VS Code integrated terminal - "Hyper", // Hyper - "Rio", // Rio - "Warp", // Warp - } - - for _, term := range knownGoodTerminals { - if info.TermProgram == term { - info.DetectedTier = BorderTierBlock - info.ActiveTier = BorderTierBlock - return BorderTierBlock, info - } - } - - // Priority 4: Terminal-specific env vars - if info.HasWindowsTerminal { - // Windows Terminal (full Unicode support) - info.DetectedTier = BorderTierBlock - info.ActiveTier = BorderTierBlock - return BorderTierBlock, info - } - - // Check for other terminal-specific env vars - if os.Getenv("KITTY_WINDOW_ID") != "" || - os.Getenv("ALACRITTY_SOCKET") != "" || - os.Getenv("WEZTERM_EXECUTABLE") != "" || - os.Getenv("GHOSTTY_RESOURCES_DIR") != "" { - info.DetectedTier = BorderTierBlock - info.ActiveTier = BorderTierBlock - return BorderTierBlock, info - } - - // Check for GNOME Terminal and Konsole (good Unicode support) - if strings.Contains(info.Term, "gnome") || - strings.Contains(info.Term, "konsole") { - info.DetectedTier = BorderTierBlock - info.ActiveTier = BorderTierBlock - return BorderTierBlock, info - } - - // Priority 5: TERM + LANG (capability hints) - // Check for UTF-8 encoding support - lang := os.Getenv("LANG") - lcAll := os.Getenv("LC_ALL") - lcCtype := os.Getenv("LC_CTYPE") - - hasUTF8 := strings.Contains(strings.ToUpper(lang), "UTF-8") || - strings.Contains(strings.ToUpper(lcAll), "UTF-8") || - strings.Contains(strings.ToUpper(lcCtype), "UTF-8") - - // Modern terminal with UTF-8 support - if hasUTF8 && (strings.Contains(info.Term, "xterm") || - strings.Contains(info.Term, "screen") || - strings.Contains(info.Term, "tmux")) { - info.DetectedTier = BorderTierBlock - info.ActiveTier = BorderTierBlock - return BorderTierBlock, info - } - - // Priority 6: Default to Tier 1 (borderless) for safety - // This handles: TERM=dumb, legacy Windows cmd.exe, CI/CD log viewers, - // terminals without UTF-8 locale, SSH sessions to unknown terminals - info.DetectedTier = BorderTierNone - info.ActiveTier = BorderTierNone - return BorderTierNone, info -} - -// NewSafeBorder creates a SafeBorder, running detection immediately. -// Detection reads environment variables and caches the result. -// This function is safe to call multiple times (result is cached internally). -func NewSafeBorder() *SafeBorder { - globalOnce.Do(func() { - tier, info := DetectBorderMode() - prefs, _ := preferences.Load() // Ignore error, will use default - - globalSafeBorder = &SafeBorder{ - tier: tier, - terminalInfo: info, - prefs: prefs, - } - - // Pre-compute borders based on tier - globalSafeBorder.updateBorders() - }) - - return globalSafeBorder -} - -// NewSafeBorderWithOverride creates a SafeBorder with a forced tier. -// Used for testing. -func NewSafeBorderWithOverride(tier BorderTier) *SafeBorder { - prefs, _ := preferences.Load() // Ignore error, will use default - - sb := &SafeBorder{ - tier: tier, - terminalInfo: TerminalInfo{ - TermProgram: os.Getenv("TERM_PROGRAM"), - Term: os.Getenv("TERM"), - ColorTerm: os.Getenv("COLORTERM"), - DetectedTier: tier, - ActiveTier: tier, - OverrideSource: "override", - }, - prefs: prefs, - } - - sb.updateBorders() - return sb -} - -// updateBorders pre-computes the borders based on the current tier. -// Must be called with mu held or during initialization. -func (sb *SafeBorder) updateBorders() { - switch sb.tier { - case BorderTierNone: - // Tier 1: invisible borders, preserves layout math - sb.border = lipgloss.HiddenBorder() - sb.focusBorder = lipgloss.HiddenBorder() - - case BorderTierBlock: - // Tier 2: half-block chars (▀▄▌▐) - sb.border = lipgloss.OuterHalfBlockBorder() - // For focus, use the same border but it will be colored differently - sb.focusBorder = lipgloss.OuterHalfBlockBorder() - - case BorderTierClassic: - // Tier 3: classic Unicode borders (╭╮╰╯) - sb.border = lipgloss.RoundedBorder() - // For focus, use thick border - sb.focusBorder = lipgloss.ThickBorder() - } -} - -// Tier returns the detected BorderTier. -func (sb *SafeBorder) Tier() BorderTier { - if sb == nil { - return BorderTierNone - } - sb.mu.RLock() - defer sb.mu.RUnlock() - return sb.tier -} - -// Border returns the lipgloss.Border for the current tier. -// Tier 1: lipgloss.HiddenBorder() — invisible borders, preserves layout math -// Tier 2: lipgloss.OuterHalfBlockBorder() — half-block chars (▀▄▌▐) -// Tier 3: lipgloss.RoundedBorder() — classic Unicode (╭╮╰╯) -func (sb *SafeBorder) Border() lipgloss.Border { - if sb == nil { - return lipgloss.HiddenBorder() - } - sb.mu.RLock() - defer sb.mu.RUnlock() - return sb.border -} - -// FocusBorder returns the border for focused/active elements. -// Same tier as Border() but may use a different style within that tier. -// Tier 1: lipgloss.HiddenBorder() (focus indicated by background color) -// Tier 2: lipgloss.OuterHalfBlockBorder() (focus indicated by brighter color) -// Tier 3: lipgloss.ThickBorder() (thicker border for focus) -func (sb *SafeBorder) FocusBorder() lipgloss.Border { - if sb == nil { - return lipgloss.HiddenBorder() - } - sb.mu.RLock() - defer sb.mu.RUnlock() - return sb.focusBorder -} - -// IsClassic returns true if Tier 3 (classic Unicode) is active. -func (sb *SafeBorder) IsClassic() bool { - return sb.Tier() == BorderTierClassic -} - -// IsBorderless returns true if Tier 1 (no visible borders) is active. -func (sb *SafeBorder) IsBorderless() bool { - return sb.Tier() == BorderTierNone -} - -// TerminalInfo returns detected terminal information for diagnostics. -// Useful for `arc info` display and debugging. -func (sb *SafeBorder) TerminalInfo() TerminalInfo { - if sb == nil { - return TerminalInfo{} - } - sb.mu.RLock() - defer sb.mu.RUnlock() - return sb.terminalInfo -} - -// Override forces a specific tier. Used for runtime switching from Config tab. -// Persists to state.json if persist is true. -func (sb *SafeBorder) Override(tier BorderTier, persist bool) error { - sb.mu.Lock() - defer sb.mu.Unlock() - - sb.tier = tier - sb.terminalInfo.ActiveTier = tier - sb.terminalInfo.OverrideSource = "runtime" - sb.updateBorders() - - if persist && sb.prefs != nil { - sb.prefs.BorderMode = tier.String() - return sb.prefs.Save() - } - - return nil -} diff --git a/pkg/ui/components/safeborder_test.go b/pkg/ui/components/safeborder_test.go deleted file mode 100644 index 8b7bbe3..0000000 --- a/pkg/ui/components/safeborder_test.go +++ /dev/null @@ -1,829 +0,0 @@ -package components - -import ( - "os" - "testing" - - "github.com/charmbracelet/lipgloss" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/internal/preferences" -) - -// saveEnv saves the current environment variables and returns a cleanup function. -func saveEnv(t *testing.T, keys []string) func() { - t.Helper() - saved := make(map[string]string) - for _, key := range keys { - saved[key] = os.Getenv(key) - } - return func() { - for key, value := range saved { - if value == "" { - os.Unsetenv(key) - } else { - os.Setenv(key, value) - } - } - } -} - -// clearEnv clears all environment variables that affect border detection. -func clearEnv(t *testing.T) { - t.Helper() - os.Unsetenv("ARC_BORDER_MODE") - os.Unsetenv("TERM_PROGRAM") - os.Unsetenv("TERM") - os.Unsetenv("COLORTERM") - os.Unsetenv("WT_SESSION") - os.Unsetenv("KITTY_WINDOW_ID") - os.Unsetenv("ALACRITTY_SOCKET") - os.Unsetenv("WEZTERM_EXECUTABLE") - os.Unsetenv("GHOSTTY_RESOURCES_DIR") - os.Unsetenv("LANG") - os.Unsetenv("LC_ALL") - os.Unsetenv("LC_CTYPE") -} - -func TestBorderTier_String(t *testing.T) { - tests := []struct { - name string - tier BorderTier - want string - }{ - { - name: "BorderTierNone", - tier: BorderTierNone, - want: "none", - }, - { - name: "BorderTierBlock", - tier: BorderTierBlock, - want: "block", - }, - { - name: "BorderTierClassic", - tier: BorderTierClassic, - want: "classic", - }, - { - name: "Invalid tier", - tier: BorderTier(99), - want: "unknown", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tt.tier.String() - assert.Equal(t, tt.want, got) - }) - } -} - -func TestParseBorderTier(t *testing.T) { - tests := []struct { - name string - input string - want BorderTier - }{ - { - name: "none lowercase", - input: "none", - want: BorderTierNone, - }, - { - name: "none uppercase", - input: "NONE", - want: BorderTierNone, - }, - { - name: "block lowercase", - input: "block", - want: BorderTierBlock, - }, - { - name: "block uppercase", - input: "BLOCK", - want: BorderTierBlock, - }, - { - name: "classic lowercase", - input: "classic", - want: BorderTierClassic, - }, - { - name: "classic uppercase", - input: "CLASSIC", - want: BorderTierClassic, - }, - { - name: "empty string", - input: "", - want: BorderTierNone, - }, - { - name: "invalid string", - input: "invalid", - want: BorderTierNone, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ParseBorderTier(tt.input) - assert.Equal(t, tt.want, got) - }) - } -} - -func TestDetectBorderMode(t *testing.T) { - // Save and restore environment - defer saveEnv(t, []string{ - "ARC_BORDER_MODE", "TERM_PROGRAM", "TERM", "COLORTERM", - "WT_SESSION", "KITTY_WINDOW_ID", "ALACRITTY_SOCKET", - "WEZTERM_EXECUTABLE", "GHOSTTY_RESOURCES_DIR", - "LANG", "LC_ALL", "LC_CTYPE", - })() - - tests := []struct { - name string - envSetup func() - wantTier BorderTier - wantSource string - }{ - { - name: "ARC_BORDER_MODE env var override - block", - envSetup: func() { - clearEnv(t) - os.Setenv("ARC_BORDER_MODE", "block") - }, - wantTier: BorderTierBlock, - wantSource: "env", - }, - { - name: "ARC_BORDER_MODE env var override - classic", - envSetup: func() { - clearEnv(t) - os.Setenv("ARC_BORDER_MODE", "classic") - }, - wantTier: BorderTierClassic, - wantSource: "env", - }, - { - name: "ARC_BORDER_MODE env var override - none", - envSetup: func() { - clearEnv(t) - os.Setenv("ARC_BORDER_MODE", "none") - }, - wantTier: BorderTierNone, - wantSource: "env", - }, - { - name: "ARC_BORDER_MODE invalid value defaults to none", - envSetup: func() { - clearEnv(t) - os.Setenv("ARC_BORDER_MODE", "invalid") - }, - wantTier: BorderTierNone, - wantSource: "env", - }, - { - name: "iTerm.app detection", - envSetup: func() { - clearEnv(t) - os.Setenv("TERM_PROGRAM", "iTerm.app") - }, - wantTier: BorderTierBlock, - wantSource: "auto", - }, - { - name: "WezTerm detection", - envSetup: func() { - clearEnv(t) - os.Setenv("TERM_PROGRAM", "WezTerm") - }, - wantTier: BorderTierBlock, - wantSource: "auto", - }, - { - name: "Ghostty detection", - envSetup: func() { - clearEnv(t) - os.Setenv("TERM_PROGRAM", "Ghostty") - }, - wantTier: BorderTierBlock, - wantSource: "auto", - }, - { - name: "Alacritty detection", - envSetup: func() { - clearEnv(t) - os.Setenv("TERM_PROGRAM", "Alacritty") - }, - wantTier: BorderTierBlock, - wantSource: "auto", - }, - { - name: "kitty detection via TERM_PROGRAM", - envSetup: func() { - clearEnv(t) - os.Setenv("TERM_PROGRAM", "kitty") - }, - wantTier: BorderTierBlock, - wantSource: "auto", - }, - { - name: "vscode detection", - envSetup: func() { - clearEnv(t) - os.Setenv("TERM_PROGRAM", "vscode") - }, - wantTier: BorderTierBlock, - wantSource: "auto", - }, - { - name: "Hyper detection", - envSetup: func() { - clearEnv(t) - os.Setenv("TERM_PROGRAM", "Hyper") - }, - wantTier: BorderTierBlock, - wantSource: "auto", - }, - { - name: "Rio detection", - envSetup: func() { - clearEnv(t) - os.Setenv("TERM_PROGRAM", "Rio") - }, - wantTier: BorderTierBlock, - wantSource: "auto", - }, - { - name: "Warp detection", - envSetup: func() { - clearEnv(t) - os.Setenv("TERM_PROGRAM", "Warp") - }, - wantTier: BorderTierBlock, - wantSource: "auto", - }, - { - name: "Windows Terminal detection via WT_SESSION", - envSetup: func() { - clearEnv(t) - os.Setenv("WT_SESSION", "12345678-1234-5678-1234-567812345678") - }, - wantTier: BorderTierBlock, - wantSource: "auto", - }, - { - name: "kitty detection via KITTY_WINDOW_ID", - envSetup: func() { - clearEnv(t) - os.Setenv("KITTY_WINDOW_ID", "1") - }, - wantTier: BorderTierBlock, - wantSource: "auto", - }, - { - name: "Alacritty detection via ALACRITTY_SOCKET", - envSetup: func() { - clearEnv(t) - os.Setenv("ALACRITTY_SOCKET", "/tmp/alacritty-socket.sock") - }, - wantTier: BorderTierBlock, - wantSource: "auto", - }, - { - name: "WezTerm detection via WEZTERM_EXECUTABLE", - envSetup: func() { - clearEnv(t) - os.Setenv("WEZTERM_EXECUTABLE", "/usr/local/bin/wezterm") - }, - wantTier: BorderTierBlock, - wantSource: "auto", - }, - { - name: "Ghostty detection via GHOSTTY_RESOURCES_DIR", - envSetup: func() { - clearEnv(t) - os.Setenv("GHOSTTY_RESOURCES_DIR", "/usr/share/ghostty") - }, - wantTier: BorderTierBlock, - wantSource: "auto", - }, - { - name: "GNOME Terminal detection", - envSetup: func() { - clearEnv(t) - os.Setenv("TERM", "gnome-256color") - }, - wantTier: BorderTierBlock, - wantSource: "auto", - }, - { - name: "Konsole detection", - envSetup: func() { - clearEnv(t) - os.Setenv("TERM", "konsole-256color") - }, - wantTier: BorderTierBlock, - wantSource: "auto", - }, - { - name: "xterm with UTF-8", - envSetup: func() { - clearEnv(t) - os.Setenv("TERM", "xterm-256color") - os.Setenv("LANG", "en_US.UTF-8") - }, - wantTier: BorderTierBlock, - wantSource: "auto", - }, - { - name: "screen with UTF-8", - envSetup: func() { - clearEnv(t) - os.Setenv("TERM", "screen-256color") - os.Setenv("LANG", "en_US.UTF-8") - }, - wantTier: BorderTierBlock, - wantSource: "auto", - }, - { - name: "tmux with UTF-8", - envSetup: func() { - clearEnv(t) - os.Setenv("TERM", "tmux-256color") - os.Setenv("LANG", "en_US.UTF-8") - }, - wantTier: BorderTierBlock, - wantSource: "auto", - }, - { - name: "xterm with LC_ALL UTF-8", - envSetup: func() { - clearEnv(t) - os.Setenv("TERM", "xterm") - os.Setenv("LC_ALL", "en_US.UTF-8") - }, - wantTier: BorderTierBlock, - wantSource: "auto", - }, - { - name: "xterm with LC_CTYPE UTF-8", - envSetup: func() { - clearEnv(t) - os.Setenv("TERM", "xterm") - os.Setenv("LC_CTYPE", "en_US.UTF-8") - }, - wantTier: BorderTierBlock, - wantSource: "auto", - }, - { - name: "xterm without UTF-8 - defaults to borderless", - envSetup: func() { - clearEnv(t) - os.Setenv("TERM", "xterm") - }, - wantTier: BorderTierNone, - wantSource: "auto", - }, - { - name: "dumb terminal - defaults to borderless", - envSetup: func() { - clearEnv(t) - os.Setenv("TERM", "dumb") - }, - wantTier: BorderTierNone, - wantSource: "auto", - }, - { - name: "unknown terminal - defaults to borderless", - envSetup: func() { - clearEnv(t) - os.Setenv("TERM", "unknown-terminal") - }, - wantTier: BorderTierNone, - wantSource: "auto", - }, - { - name: "no environment set - defaults to borderless", - envSetup: func() { - clearEnv(t) - }, - wantTier: BorderTierNone, - wantSource: "auto", - }, - { - name: "ARC_BORDER_MODE overrides TERM_PROGRAM", - envSetup: func() { - clearEnv(t) - os.Setenv("ARC_BORDER_MODE", "none") - os.Setenv("TERM_PROGRAM", "iTerm.app") - }, - wantTier: BorderTierNone, - wantSource: "env", - }, - { - name: "ARC_BORDER_MODE overrides WT_SESSION", - envSetup: func() { - clearEnv(t) - os.Setenv("ARC_BORDER_MODE", "classic") - os.Setenv("WT_SESSION", "12345678-1234-5678-1234-567812345678") - }, - wantTier: BorderTierClassic, - wantSource: "env", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tt.envSetup() - - tier, info := DetectBorderMode() - - assert.Equal(t, tt.wantTier, tier, "tier mismatch") - assert.Equal(t, tt.wantSource, info.OverrideSource, "source mismatch") - assert.Equal(t, tt.wantTier, info.ActiveTier, "active tier mismatch") - assert.Equal(t, tt.wantTier, info.DetectedTier, "detected tier mismatch") - }) - } -} - -func TestNewSafeBorderWithOverride(t *testing.T) { - tests := []struct { - name string - tier BorderTier - wantType lipgloss.Border - }{ - { - name: "Override with BorderTierNone", - tier: BorderTierNone, - wantType: lipgloss.HiddenBorder(), - }, - { - name: "Override with BorderTierBlock", - tier: BorderTierBlock, - wantType: lipgloss.OuterHalfBlockBorder(), - }, - { - name: "Override with BorderTierClassic", - tier: BorderTierClassic, - wantType: lipgloss.RoundedBorder(), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sb := NewSafeBorderWithOverride(tt.tier) - require.NotNil(t, sb) - - assert.Equal(t, tt.tier, sb.Tier()) - assert.Equal(t, tt.wantType, sb.Border()) - - // Check terminal info - info := sb.TerminalInfo() - assert.Equal(t, tt.tier, info.DetectedTier) - assert.Equal(t, tt.tier, info.ActiveTier) - assert.Equal(t, "override", info.OverrideSource) - }) - } -} - -func TestSafeBorder_Tier(t *testing.T) { - sb := NewSafeBorderWithOverride(BorderTierBlock) - assert.Equal(t, BorderTierBlock, sb.Tier()) -} - -func TestSafeBorder_Border(t *testing.T) { - tests := []struct { - name string - tier BorderTier - wantType lipgloss.Border - }{ - { - name: "BorderTierNone returns HiddenBorder", - tier: BorderTierNone, - wantType: lipgloss.HiddenBorder(), - }, - { - name: "BorderTierBlock returns OuterHalfBlockBorder", - tier: BorderTierBlock, - wantType: lipgloss.OuterHalfBlockBorder(), - }, - { - name: "BorderTierClassic returns RoundedBorder", - tier: BorderTierClassic, - wantType: lipgloss.RoundedBorder(), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sb := NewSafeBorderWithOverride(tt.tier) - border := sb.Border() - assert.Equal(t, tt.wantType, border) - }) - } -} - -func TestSafeBorder_FocusBorder(t *testing.T) { - tests := []struct { - name string - tier BorderTier - wantType lipgloss.Border - }{ - { - name: "BorderTierNone returns HiddenBorder", - tier: BorderTierNone, - wantType: lipgloss.HiddenBorder(), - }, - { - name: "BorderTierBlock returns OuterHalfBlockBorder", - tier: BorderTierBlock, - wantType: lipgloss.OuterHalfBlockBorder(), - }, - { - name: "BorderTierClassic returns ThickBorder", - tier: BorderTierClassic, - wantType: lipgloss.ThickBorder(), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sb := NewSafeBorderWithOverride(tt.tier) - focusBorder := sb.FocusBorder() - assert.Equal(t, tt.wantType, focusBorder) - }) - } -} - -func TestSafeBorder_IsClassic(t *testing.T) { - tests := []struct { - name string - tier BorderTier - want bool - }{ - { - name: "BorderTierNone is not classic", - tier: BorderTierNone, - want: false, - }, - { - name: "BorderTierBlock is not classic", - tier: BorderTierBlock, - want: false, - }, - { - name: "BorderTierClassic is classic", - tier: BorderTierClassic, - want: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sb := NewSafeBorderWithOverride(tt.tier) - assert.Equal(t, tt.want, sb.IsClassic()) - }) - } -} - -func TestSafeBorder_IsBorderless(t *testing.T) { - tests := []struct { - name string - tier BorderTier - want bool - }{ - { - name: "BorderTierNone is borderless", - tier: BorderTierNone, - want: true, - }, - { - name: "BorderTierBlock is not borderless", - tier: BorderTierBlock, - want: false, - }, - { - name: "BorderTierClassic is not borderless", - tier: BorderTierClassic, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sb := NewSafeBorderWithOverride(tt.tier) - assert.Equal(t, tt.want, sb.IsBorderless()) - }) - } -} - -func TestSafeBorder_TerminalInfo(t *testing.T) { - // Save and restore environment - defer saveEnv(t, []string{"TERM_PROGRAM", "TERM", "COLORTERM"})() - - clearEnv(t) - os.Setenv("TERM_PROGRAM", "iTerm.app") - os.Setenv("TERM", "xterm-256color") - os.Setenv("COLORTERM", "truecolor") - - sb := NewSafeBorderWithOverride(BorderTierBlock) - info := sb.TerminalInfo() - - assert.Equal(t, "iTerm.app", info.TermProgram) - assert.Equal(t, "xterm-256color", info.Term) - assert.Equal(t, "truecolor", info.ColorTerm) - assert.Equal(t, BorderTierBlock, info.DetectedTier) - assert.Equal(t, BorderTierBlock, info.ActiveTier) -} - -func TestSafeBorder_Override(t *testing.T) { - tests := []struct { - name string - initialTier BorderTier - newTier BorderTier - persist bool - }{ - { - name: "Override from None to Block without persist", - initialTier: BorderTierNone, - newTier: BorderTierBlock, - persist: false, - }, - { - name: "Override from Block to Classic without persist", - initialTier: BorderTierBlock, - newTier: BorderTierClassic, - persist: false, - }, - { - name: "Override from Classic to None without persist", - initialTier: BorderTierClassic, - newTier: BorderTierNone, - persist: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sb := NewSafeBorderWithOverride(tt.initialTier) - assert.Equal(t, tt.initialTier, sb.Tier()) - - err := sb.Override(tt.newTier, tt.persist) - assert.NoError(t, err) - - assert.Equal(t, tt.newTier, sb.Tier()) - - info := sb.TerminalInfo() - assert.Equal(t, tt.newTier, info.ActiveTier) - assert.Equal(t, "runtime", info.OverrideSource) - }) - } -} - -func TestSafeBorder_Override_BordersUpdated(t *testing.T) { - sb := NewSafeBorderWithOverride(BorderTierNone) - assert.Equal(t, lipgloss.HiddenBorder(), sb.Border()) - - // Override to Block - err := sb.Override(BorderTierBlock, false) - assert.NoError(t, err) - assert.Equal(t, lipgloss.OuterHalfBlockBorder(), sb.Border()) - assert.Equal(t, lipgloss.OuterHalfBlockBorder(), sb.FocusBorder()) - - // Override to Classic - err = sb.Override(BorderTierClassic, false) - assert.NoError(t, err) - assert.Equal(t, lipgloss.RoundedBorder(), sb.Border()) - assert.Equal(t, lipgloss.ThickBorder(), sb.FocusBorder()) - - // Override back to None - err = sb.Override(BorderTierNone, false) - assert.NoError(t, err) - assert.Equal(t, lipgloss.HiddenBorder(), sb.Border()) - assert.Equal(t, lipgloss.HiddenBorder(), sb.FocusBorder()) -} - -func TestSafeBorder_ThreadSafety(t *testing.T) { - sb := NewSafeBorderWithOverride(BorderTierNone) - - // Run concurrent reads and writes - done := make(chan bool) - for i := 0; i < 10; i++ { - go func(id int) { - for j := 0; j < 100; j++ { - // Read operations - _ = sb.Tier() - _ = sb.Border() - _ = sb.FocusBorder() - _ = sb.IsClassic() - _ = sb.IsBorderless() - _ = sb.TerminalInfo() - - // Write operation - tier := BorderTier(j % 3) - _ = sb.Override(tier, false) - } - done <- true - }(i) - } - - // Wait for all goroutines to complete - for i := 0; i < 10; i++ { - <-done - } - - // No assertion needed - test passes if no race conditions detected -} - -// Benchmark tests -func BenchmarkDetectBorderMode(b *testing.B) { - // Save and restore environment - defer saveEnv(&testing.T{}, []string{"TERM_PROGRAM", "TERM", "LANG"})() - - os.Setenv("TERM_PROGRAM", "iTerm.app") - os.Setenv("TERM", "xterm-256color") - os.Setenv("LANG", "en_US.UTF-8") - - b.ResetTimer() - for i := 0; i < b.N; i++ { - DetectBorderMode() - } -} - -func BenchmarkSafeBorder_Tier(b *testing.B) { - sb := NewSafeBorderWithOverride(BorderTierBlock) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = sb.Tier() - } -} - -func BenchmarkSafeBorder_Border(b *testing.B) { - sb := NewSafeBorderWithOverride(BorderTierBlock) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = sb.Border() - } -} - -// TestNewSafeBorder tests the singleton constructor. -// Note: This test must be run in isolation because it modifies the global singleton. -func TestNewSafeBorder(t *testing.T) { - // Save and restore environment - defer saveEnv(t, []string{"TERM_PROGRAM", "TERM", "LANG"})() - - clearEnv(t) - os.Setenv("TERM_PROGRAM", "iTerm.app") - os.Setenv("TERM", "xterm-256color") - os.Setenv("LANG", "en_US.UTF-8") - - // Note: Since NewSafeBorder uses a singleton, we can only test it once - // in a test run. Subsequent calls will return the cached instance. - // This is OK because the function is designed to be idempotent. - sb := NewSafeBorder() - require.NotNil(t, sb) - - // Verify that it returns a valid SafeBorder - tier := sb.Tier() - assert.True(t, tier >= BorderTierNone && tier <= BorderTierClassic) - - // Verify that calling it again returns the same instance - sb2 := NewSafeBorder() - assert.Equal(t, sb, sb2, "NewSafeBorder should return the same instance") - - // Verify that the border is set correctly - border := sb.Border() - assert.NotNil(t, border) - - focusBorder := sb.FocusBorder() - assert.NotNil(t, focusBorder) -} - -// TestSafeBorder_Override_WithPersist tests the Override method with persist=true. -// This is separated because it modifies the preferences file. -func TestSafeBorder_Override_WithPersist(t *testing.T) { - // Note: This test modifies the preferences file, so we should be careful - // about running it in CI/CD environments. For now, we'll skip persistence testing - // to avoid side effects. - t.Skip("Skipping persistence test to avoid side effects on preferences file") - - sb := NewSafeBorderWithOverride(BorderTierNone) - - err := sb.Override(BorderTierBlock, true) - assert.NoError(t, err) - - // Verify the preference was saved - prefs, err := preferences.Load() - assert.NoError(t, err) - assert.Equal(t, "block", prefs.BorderMode) -} diff --git a/pkg/ui/components/search/.gitkeep b/pkg/ui/components/search/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/pkg/ui/components/search/searchbar.go b/pkg/ui/components/search/searchbar.go deleted file mode 100644 index 4802c7e..0000000 --- a/pkg/ui/components/search/searchbar.go +++ /dev/null @@ -1,305 +0,0 @@ -// Package search provides search and filter components for the UI engine. -package search - -import ( - "strings" - "time" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// SearchBar is an interactive search input component with real-time filtering. -// -// Features: -// - Real-time text input with bubbles/textinput -// - Profile-themed styling (primary color for focus, muted for placeholder) -// - Placeholder text support -// - Clear button (Esc to clear) -// - OnChange callback for filtering -// - Optional debouncing for performance -// -// Usage: -// -// searchBar := NewSearchBar(theme, "Search items...") -// searchBar.SetWidth(40) -// searchBar.SetOnChange(func(query string) { -// // Filter data based on query -// filteredData := filterFunc(query) -// table.SetRows(filteredData) -// }) -// -// Keyboard shortcuts: -// - Type to search -// - Esc to clear -// - Ctrl+K to focus (when integrated with larger UI) -type SearchBar struct { - textInput textinput.Model - theme *themes.Theme - width int - onChange func(string) - lastValue string - debounceTimer *time.Timer - debounceDelay time.Duration -} - -// debounceMsg is sent after the debounce delay to trigger onChange. -type debounceMsg struct { - value string -} - -// NewSearchBar creates a new SearchBar with the given theme and placeholder. -// -// The search bar is initialized with: -// - Profile-themed styling -// - Auto-focus enabled -// - Debouncing disabled by default (can be enabled with SetDebounce) -// -// Example: -// -// theme := &themes.Theme{ -// Colors: themes.ColorSet{ -// Primary: "#00ADD8", -// Muted: "#666666", -// }, -// } -// searchBar := NewSearchBar(theme, "Search...") -func NewSearchBar(theme *themes.Theme, placeholder string) *SearchBar { - ti := textinput.New() - ti.Placeholder = placeholder - ti.Focus() - ti.CharLimit = 256 - ti.Width = 40 - - // Apply theme styles - if theme != nil { - colors := theme.Colors - ti.PromptStyle = lipgloss.NewStyle().Foreground(colors.PrimaryColor()) - ti.TextStyle = lipgloss.NewStyle().Foreground(colors.ForegroundColor()) - ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(colors.MutedColor()) - ti.Cursor.Style = lipgloss.NewStyle().Foreground(colors.PrimaryColor()) - } - - // Set search icon as prompt - ti.Prompt = "🔍 " - - return &SearchBar{ - textInput: ti, - theme: theme, - width: 40, - debounceDelay: 0, // No debouncing by default - } -} - -// Init implements tea.Model. -func (sb *SearchBar) Init() tea.Cmd { - return textinput.Blink -} - -// Update implements tea.Model to handle keyboard input and debouncing. -func (sb *SearchBar) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): - // Clear the search - sb.Clear() - if sb.onChange != nil { - sb.onChange("") - } - return sb, nil - - case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+k"))): - // Focus the search bar - focusCmd := sb.Focus() - return sb, focusCmd - } - - case debounceMsg: - // Debounce timer fired - trigger onChange - if sb.onChange != nil && msg.value == sb.textInput.Value() { - sb.onChange(msg.value) - } - return sb, nil - } - - // Update the text input - sb.textInput, cmd = sb.textInput.Update(msg) - - // Check if value changed and handle accordingly - currentValue := sb.textInput.Value() - if currentValue == sb.lastValue { - return sb, cmd - } - sb.lastValue = currentValue - if debouncedCmd := sb.handleValueChange(currentValue); debouncedCmd != nil { - return sb, debouncedCmd - } - - return sb, cmd -} - -// handleValueChange processes a new input value, triggering debouncing or immediate onChange. -// Returns a tea.Cmd if debouncing, nil otherwise. -func (sb *SearchBar) handleValueChange(value string) tea.Cmd { - if sb.debounceDelay <= 0 { - if sb.onChange != nil { - sb.onChange(value) - } - return nil - } - if sb.debounceTimer != nil { - sb.debounceTimer.Stop() - } - sb.debounceTimer = time.AfterFunc(sb.debounceDelay, func() { - // This runs in a goroutine, we need to send a message - }) - return tea.Tick(sb.debounceDelay, func(t time.Time) tea.Msg { - return debounceMsg{value: value} - }) -} - -// View implements tea.Model to render the search bar. -// -// The search bar is rendered with: -// - Profile-themed border and colors -// - Search icon prompt -// - Current input value or placeholder -// -// Example output: -// -// ┌──────────────────────────────────┐ -// │ 🔍 Search items... │ -// └──────────────────────────────────┘ -func (sb *SearchBar) View() string { - // Create border style - borderStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - Padding(0, 1) - - if sb.theme != nil { - colors := sb.theme.Colors - if sb.textInput.Focused() { - borderStyle = borderStyle.BorderForeground(colors.PrimaryColor()) - } else { - borderStyle = borderStyle.BorderForeground(colors.BorderColor()) - } - } - - // Set width to fill the border (account for padding and border) - sb.textInput.Width = sb.width - 4 // 2 for border, 2 for padding - - return borderStyle.Render(sb.textInput.View()) -} - -// SetWidth adjusts the search bar width. -// -// The width includes the border and padding, so the actual input width -// will be slightly less. -func (sb *SearchBar) SetWidth(width int) { - sb.width = width -} - -// Value returns the current search query. -func (sb *SearchBar) Value() string { - return sb.textInput.Value() -} - -// SetValue sets the search query programmatically. -// -// This will trigger the onChange callback if set. -func (sb *SearchBar) SetValue(value string) { - sb.textInput.SetValue(value) - sb.lastValue = value - if sb.onChange != nil { - sb.onChange(value) - } -} - -// SetOnChange sets the callback function that is called when the search query changes. -// -// The callback receives the current search query as a string. -// -// Example: -// -// searchBar.SetOnChange(func(query string) { -// // Filter table rows -// filtered := []Row{} -// for _, row := range allRows { -// if strings.Contains(strings.ToLower(row[0]), strings.ToLower(query)) { -// filtered = append(filtered, row) -// } -// } -// table.SetRows(filtered) -// }) -func (sb *SearchBar) SetOnChange(fn func(string)) { - sb.onChange = fn -} - -// SetDebounce sets the debounce delay for onChange callbacks. -// -// When debouncing is enabled, the onChange callback will only be triggered -// after the user stops typing for the specified duration. This is useful -// for expensive filtering operations. -// -// Set to 0 to disable debouncing (default). -// -// Example: -// -// // Trigger onChange after 300ms of no typing -// searchBar.SetDebounce(300 * time.Millisecond) -func (sb *SearchBar) SetDebounce(delay time.Duration) { - sb.debounceDelay = delay -} - -// Clear clears the search input. -// -// This will trigger the onChange callback with an empty string. -func (sb *SearchBar) Clear() { - sb.textInput.SetValue("") - sb.lastValue = "" -} - -// Focus gives focus to the search bar and returns a command for the cursor to blink. -func (sb *SearchBar) Focus() tea.Cmd { - sb.textInput.Focus() - return textinput.Blink -} - -// Blur removes focus from the search bar. -func (sb *SearchBar) Blur() { - sb.textInput.Blur() -} - -// Focused returns true if the search bar is currently focused. -func (sb *SearchBar) Focused() bool { - return sb.textInput.Focused() -} - -// Filter is a convenience method that returns true if the given text matches the search query. -// -// The matching is case-insensitive and checks if the query is a substring of the text. -// -// Example: -// -// if searchBar.Filter(row[0]) { -// // Include this row in filtered results -// } -func (sb *SearchBar) Filter(text string) bool { - query := strings.ToLower(sb.Value()) - if query == "" { - return true // Empty query matches everything - } - return strings.Contains(strings.ToLower(text), query) -} - -// Keybindings returns a help string showing the keyboard shortcuts. -func (sb *SearchBar) Keybindings() string { - return "type: search • esc: clear • ctrl+k: focus" -} diff --git a/pkg/ui/components/search/searchbar_test.go b/pkg/ui/components/search/searchbar_test.go deleted file mode 100644 index 373ddc3..0000000 --- a/pkg/ui/components/search/searchbar_test.go +++ /dev/null @@ -1,406 +0,0 @@ -package search - -import ( - "strings" - "testing" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// mockTheme creates a test theme for testing. -func mockTheme() *themes.Theme { - return &themes.Theme{ - Name: "test-theme", - Colors: themes.ColorSet{ - Primary: "#00ADD8", - Secondary: "#F39C12", - Success: "#27AE60", - Error: "#E74C3C", - Warning: "#F39C12", - Info: "#3498DB", - Foreground: "#ECFAFF", - Background: "#001117", - Muted: "#666666", - Border: "#2C3E50", - }, - } -} - -func TestNewSearchBar(t *testing.T) { - theme := mockTheme() - searchBar := NewSearchBar(theme, "Search items...") - - assert.NotNil(t, searchBar) - assert.Equal(t, theme, searchBar.theme) - assert.Equal(t, 40, searchBar.width) - assert.Equal(t, "Search items...", searchBar.textInput.Placeholder) - assert.Equal(t, "🔍 ", searchBar.textInput.Prompt) - assert.True(t, searchBar.Focused()) - assert.Equal(t, time.Duration(0), searchBar.debounceDelay) -} - -func TestNewSearchBar_NilTheme(t *testing.T) { - searchBar := NewSearchBar(nil, "Search...") - - assert.NotNil(t, searchBar) - assert.Nil(t, searchBar.theme) - assert.Equal(t, "Search...", searchBar.textInput.Placeholder) -} - -func TestSearchBar_Init(t *testing.T) { - searchBar := NewSearchBar(mockTheme(), "Search...") - cmd := searchBar.Init() - - assert.NotNil(t, cmd) -} - -func TestSearchBar_SetWidth(t *testing.T) { - searchBar := NewSearchBar(mockTheme(), "Search...") - - searchBar.SetWidth(80) - assert.Equal(t, 80, searchBar.width) - - searchBar.SetWidth(120) - assert.Equal(t, 120, searchBar.width) -} - -func TestSearchBar_ValueAndSetValue(t *testing.T) { - searchBar := NewSearchBar(mockTheme(), "Search...") - - // Initial value should be empty - assert.Equal(t, "", searchBar.Value()) - - // Set value - searchBar.SetValue("test query") - assert.Equal(t, "test query", searchBar.Value()) - assert.Equal(t, "test query", searchBar.lastValue) -} - -func TestSearchBar_Clear(t *testing.T) { - searchBar := NewSearchBar(mockTheme(), "Search...") - - // Set a value first - searchBar.SetValue("test query") - assert.Equal(t, "test query", searchBar.Value()) - - // Clear it - searchBar.Clear() - assert.Equal(t, "", searchBar.Value()) - assert.Equal(t, "", searchBar.lastValue) -} - -func TestSearchBar_FocusAndBlur(t *testing.T) { - searchBar := NewSearchBar(mockTheme(), "Search...") - - // Should be focused by default - assert.True(t, searchBar.Focused()) - - // Blur - searchBar.Blur() - assert.False(t, searchBar.Focused()) - - // Focus again - cmd := searchBar.Focus() - assert.NotNil(t, cmd) - assert.True(t, searchBar.Focused()) -} - -func TestSearchBar_SetOnChange(t *testing.T) { - searchBar := NewSearchBar(mockTheme(), "Search...") - - called := false - var receivedQuery string - - searchBar.SetOnChange(func(query string) { - called = true - receivedQuery = query - }) - - // SetValue should trigger onChange - searchBar.SetValue("test") - assert.True(t, called) - assert.Equal(t, "test", receivedQuery) -} - -func TestSearchBar_SetDebounce(t *testing.T) { - searchBar := NewSearchBar(mockTheme(), "Search...") - - // Default should be no debouncing - assert.Equal(t, time.Duration(0), searchBar.debounceDelay) - - // Set debounce - searchBar.SetDebounce(300 * time.Millisecond) - assert.Equal(t, 300*time.Millisecond, searchBar.debounceDelay) - - // Disable debounce - searchBar.SetDebounce(0) - assert.Equal(t, time.Duration(0), searchBar.debounceDelay) -} - -func TestSearchBar_Update_EscKeyClearsInput(t *testing.T) { - searchBar := NewSearchBar(mockTheme(), "Search...") - - // Set a value first - searchBar.SetValue("test query") - assert.Equal(t, "test query", searchBar.Value()) - - // Send Esc key - msg := tea.KeyMsg{Type: tea.KeyEsc} - searchBar.Update(msg) - - assert.Equal(t, "", searchBar.Value()) -} - -func TestSearchBar_Update_OnChangeTriggered(t *testing.T) { - searchBar := NewSearchBar(mockTheme(), "Search...") - - callCount := 0 - var queries []string - - searchBar.SetOnChange(func(query string) { - callCount++ - queries = append(queries, query) - }) - - // Simulate typing "t" - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'t'}} - searchBar.Update(msg) - - // The onChange should be called when value changes - // Note: We need to check the lastValue to see if it changed - if searchBar.Value() != searchBar.lastValue { - assert.Greater(t, callCount, 0) - } -} - -func TestSearchBar_Update_DebounceMsg(t *testing.T) { - searchBar := NewSearchBar(mockTheme(), "Search...") - searchBar.SetDebounce(100 * time.Millisecond) - - callCount := 0 - var receivedQuery string - - searchBar.SetOnChange(func(query string) { - callCount++ - receivedQuery = query - }) - - // Set value to simulate typing - searchBar.textInput.SetValue("test") - searchBar.lastValue = "" // Simulate that it changed - - // Send debounceMsg - msg := debounceMsg{value: "test"} - searchBar.Update(msg) - - // onChange should be called - assert.Equal(t, 1, callCount) - assert.Equal(t, "test", receivedQuery) -} - -func TestSearchBar_Update_DebounceMsg_ValueMismatch(t *testing.T) { - searchBar := NewSearchBar(mockTheme(), "Search...") - searchBar.SetDebounce(100 * time.Millisecond) - - callCount := 0 - - searchBar.SetOnChange(func(query string) { - callCount++ - }) - - // Set value to simulate typing - searchBar.textInput.SetValue("test") - - // Send debounceMsg with different value (user kept typing) - msg := debounceMsg{value: "tes"} - searchBar.Update(msg) - - // onChange should NOT be called because values don't match - assert.Equal(t, 0, callCount) -} - -func TestSearchBar_Filter(t *testing.T) { - searchBar := NewSearchBar(mockTheme(), "Search...") - - // Empty query should match everything - assert.True(t, searchBar.Filter("anything")) - assert.True(t, searchBar.Filter("")) - - // Set a query - searchBar.SetValue("test") - - // Should match case-insensitively - assert.True(t, searchBar.Filter("This is a test")) - assert.True(t, searchBar.Filter("TEST")) - assert.True(t, searchBar.Filter("testing")) - - // Should not match - assert.False(t, searchBar.Filter("no match")) - assert.False(t, searchBar.Filter("example")) -} - -func TestSearchBar_Filter_CaseSensitivity(t *testing.T) { - searchBar := NewSearchBar(mockTheme(), "Search...") - - searchBar.SetValue("Query") - - // Should match regardless of case - assert.True(t, searchBar.Filter("This query matches")) - assert.True(t, searchBar.Filter("QUERY")) - assert.True(t, searchBar.Filter("query")) - assert.True(t, searchBar.Filter("QuErY")) -} - -func TestSearchBar_Keybindings(t *testing.T) { - searchBar := NewSearchBar(mockTheme(), "Search...") - - keybindings := searchBar.Keybindings() - assert.NotEmpty(t, keybindings) - assert.Contains(t, keybindings, "search") - assert.Contains(t, keybindings, "clear") - assert.Contains(t, keybindings, "focus") -} - -func TestSearchBar_View(t *testing.T) { - searchBar := NewSearchBar(mockTheme(), "Search...") - searchBar.SetWidth(40) - - view := searchBar.View() - - // Should render something - assert.NotEmpty(t, view) - - // Should contain the prompt - assert.Contains(t, view, "🔍") - - // Should have a border (checking for corner characters) - assert.True(t, strings.Contains(view, "─") || strings.Contains(view, "│")) -} - -func TestSearchBar_View_WithValue(t *testing.T) { - searchBar := NewSearchBar(mockTheme(), "Search...") - searchBar.SetWidth(60) - searchBar.SetValue("my search query") - - view := searchBar.View() - - // Should render the value - assert.Contains(t, view, "my search query") -} - -func TestSearchBar_View_NilTheme(t *testing.T) { - searchBar := NewSearchBar(nil, "Search...") - searchBar.SetWidth(40) - - // Should not panic with nil theme - view := searchBar.View() - assert.NotEmpty(t, view) -} - -func TestSearchBar_Integration_WithTable(t *testing.T) { - // This test demonstrates how SearchBar integrates with a data table - - searchBar := NewSearchBar(mockTheme(), "Search...") - - // Mock data - allRows := [][]string{ - {"apple", "fruit"}, - {"banana", "fruit"}, - {"carrot", "vegetable"}, - {"date", "fruit"}, - } - - var filteredRows [][]string - - // Set up onChange to filter rows - searchBar.SetOnChange(func(query string) { - filteredRows = [][]string{} - if query == "" { - // Empty query returns all rows - filteredRows = allRows - return - } - for _, row := range allRows { - // Check if any cell matches - for _, cell := range row { - if strings.Contains(strings.ToLower(cell), strings.ToLower(query)) { - filteredRows = append(filteredRows, row) - break - } - } - } - }) - - // Test empty query returns all - searchBar.SetValue("") - assert.Equal(t, 4, len(filteredRows)) // Empty query should return all rows - - // Test filtering for "fruit" - searchBar.SetValue("fruit") - require.NotNil(t, filteredRows) - assert.Equal(t, 3, len(filteredRows)) - - // Test filtering for "car" - searchBar.SetValue("car") - require.NotNil(t, filteredRows) - assert.Equal(t, 1, len(filteredRows)) - assert.Equal(t, "carrot", filteredRows[0][0]) - - // Test no matches - searchBar.SetValue("xyz") - assert.Equal(t, 0, len(filteredRows)) -} - -func TestSearchBar_Integration_WithDebounce(t *testing.T) { - searchBar := NewSearchBar(mockTheme(), "Search...") - searchBar.SetDebounce(50 * time.Millisecond) - - callCount := 0 - var lastQuery string - - searchBar.SetOnChange(func(query string) { - callCount++ - lastQuery = query - }) - - // The debounce mechanism requires the value to change first - // Let's test the debounceMsg directly - searchBar.textInput.SetValue("a") - - // Send the debounce message - searchBar.Update(debounceMsg{value: "a"}) - - // Should have been called once after debounce - assert.Equal(t, 1, callCount) - assert.Equal(t, "a", lastQuery) -} - -// BenchmarkSearchBar_Filter tests the performance of the Filter method. -func BenchmarkSearchBar_Filter(b *testing.B) { - searchBar := NewSearchBar(mockTheme(), "Search...") - searchBar.SetValue("test") - - text := "This is a test string for benchmarking" - - b.ResetTimer() - for i := 0; i < b.N; i++ { - searchBar.Filter(text) - } -} - -// BenchmarkSearchBar_Update tests the performance of updating the search bar. -func BenchmarkSearchBar_Update(b *testing.B) { - searchBar := NewSearchBar(mockTheme(), "Search...") - - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}} - - b.ResetTimer() - for i := 0; i < b.N; i++ { - searchBar.Update(msg) - } -} diff --git a/pkg/ui/components/section_header.go b/pkg/ui/components/section_header.go deleted file mode 100644 index d032927..0000000 --- a/pkg/ui/components/section_header.go +++ /dev/null @@ -1,247 +0,0 @@ -package components - -import ( - "strings" - - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -const ( - // DefaultSeparatorChar is the default character used for separator lines. - DefaultSeparatorChar = "─" -) - -// SectionHeader represents a themed section divider with icon and title. -// Used to visually separate sections in terminal output with profile-themed styling. -// -// Design: -// - Simple format: [icon] [Title] with optional separator line -// - Uses profile primary color for theming -// - Bold text for emphasis -// - ANSI-aware width calculations for separators -// - Supports both standalone and factory-based construction -// -// Usage: -// -// // Standalone construction (requires manual theme) -// header := NewSectionHeader("📦", "Services", theme.Colors.PrimaryColor(), 80) -// fmt.Println(header.Render()) -// -// // Factory-based construction (preferred, uses profile context) -// factory := NewComponentFactory(profileCtx, tier) -// header := factory.SectionHeader("📦", "Services") -// fmt.Println(header) -type SectionHeader struct { - Icon string - Title string - Style SectionHeaderStyle - Width int // Optional: if > 0, adds separator line -} - -// SectionHeaderStyle configures the appearance of a section header. -type SectionHeaderStyle struct { - Color lipgloss.Color // Primary color from profile - Bold bool // Bold text - ShowSeparator bool // Show separator line below header - SeparatorColor lipgloss.Color // Color for separator line - SeparatorChar string // Character for separator line (default: "─") -} - -// NewSectionHeader creates a section header with profile-themed styling. -// Deprecated: Use NewThemedSectionHeader for profile-aware theming. -// This version maintains backward compatibility by using default theme. -func NewSectionHeader(icon, title string, color lipgloss.Color, width int) *SectionHeader { - return &SectionHeader{ - Icon: icon, - Title: title, - Width: width, - Style: SectionHeaderStyle{ - Color: color, - Bold: true, - ShowSeparator: width > 0, - SeparatorColor: color, - SeparatorChar: DefaultSeparatorChar, - }, - } -} - -// NewThemedSectionHeader creates a section header with profile-themed styling using theme colors. -// If theme is nil, falls back to default theme. -// If width > 0, adds a separator line below the header. -// -// Parameters: -// - icon: Icon/emoji to display before title (e.g., "📦", "🔧", "ℹ") -// - title: Section title text -// - theme: Theme to use for colors (nil = default theme) -// - width: Optional width for separator line (0 = no separator) -// -// Returns a configured SectionHeader ready for rendering. -func NewThemedSectionHeader(icon, title string, theme *themes.Theme, width int) *SectionHeader { - // Fallback to default theme if not provided - if theme == nil { - theme = getDefaultTheme() - } - - // Ultimate fallback if theme is still nil - color := lipgloss.Color("#00ADD8") - if theme != nil { - color = theme.Colors.PrimaryColor() - } - - return &SectionHeader{ - Icon: icon, - Title: title, - Width: width, - Style: SectionHeaderStyle{ - Color: color, - Bold: true, - ShowSeparator: width > 0, - SeparatorColor: color, - SeparatorChar: DefaultSeparatorChar, - }, - } -} - -// NewSectionHeaderWithDefaults creates a section header with default A.R.C. styling. -// Deprecated: Use NewThemedSectionHeader for profile-aware theming. -// This is a convenience constructor when you don't have theme colors available. -// -// Parameters: -// - icon: Icon/emoji to display before title -// - title: Section title text -// - width: Optional width for separator line (0 = no separator) -func NewSectionHeaderWithDefaults(icon, title string, width int) *SectionHeader { - return NewThemedSectionHeader(icon, title, nil, width) -} - -// WithSeparator enables separator line rendering with specified width. -// Returns the SectionHeader for method chaining. -func (sh *SectionHeader) WithSeparator(width int) *SectionHeader { - sh.Width = width - sh.Style.ShowSeparator = true - return sh -} - -// WithoutSeparator disables separator line rendering. -// Returns the SectionHeader for method chaining. -func (sh *SectionHeader) WithoutSeparator() *SectionHeader { - sh.Style.ShowSeparator = false - return sh -} - -// WithColor sets the header and separator color. -// Returns the SectionHeader for method chaining. -func (sh *SectionHeader) WithColor(color lipgloss.Color) *SectionHeader { - sh.Style.Color = color - sh.Style.SeparatorColor = color - return sh -} - -// WithSeparatorColor sets only the separator color (different from header). -// Returns the SectionHeader for method chaining. -func (sh *SectionHeader) WithSeparatorColor(color lipgloss.Color) *SectionHeader { - sh.Style.SeparatorColor = color - return sh -} - -// WithSeparatorChar sets the character used for the separator line. -// Common options: "─" (default), "═", "━", "-", "=" -// Returns the SectionHeader for method chaining. -func (sh *SectionHeader) WithSeparatorChar(char string) *SectionHeader { - sh.Style.SeparatorChar = char - return sh -} - -// Render returns the section header as a styled string. -// Format: [icon] [Title] -// If ShowSeparator is true and Width > 0, adds a separator line below. -func (sh *SectionHeader) Render() string { - // Build header text: icon + title - headerText := sh.Title - if sh.Icon != "" { - headerText = sh.Icon + " " + sh.Title - } - - // Apply styling - headerStyle := lipgloss.NewStyle(). - Foreground(sh.Style.Color). - Bold(sh.Style.Bold) - - renderedHeader := headerStyle.Render(headerText) - - // Add separator if enabled - if sh.Style.ShowSeparator && sh.Width > 0 { - // Calculate separator width using ANSI-aware width - headerWidth := lipgloss.Width(renderedHeader) - - // Separator should match or extend to specified width - separatorWidth := sh.Width - if headerWidth > separatorWidth { - // If header is wider than requested width, match header width - separatorWidth = headerWidth - } - - // Ensure separator width is reasonable - if separatorWidth < 0 { - separatorWidth = 0 - } - - // Build separator line - separatorChar := sh.Style.SeparatorChar - if separatorChar == "" { - separatorChar = DefaultSeparatorChar - } - - separatorStyle := lipgloss.NewStyle(). - Foreground(sh.Style.SeparatorColor) - - separator := separatorStyle.Render(strings.Repeat(separatorChar, separatorWidth)) - - // Join header and separator vertically - return lipgloss.JoinVertical(lipgloss.Left, renderedHeader, separator) - } - - return renderedHeader -} - -// View is an alias for Render (for Bubble Tea compatibility). -func (sh *SectionHeader) View() string { - return sh.Render() -} - -// DefaultSectionHeaderStyle returns the default section header style. -// Deprecated: Use DefaultThemedSectionHeaderStyle for profile-aware theming. -func DefaultSectionHeaderStyle() SectionHeaderStyle { - return DefaultThemedSectionHeaderStyle(nil) -} - -// DefaultThemedSectionHeaderStyle returns the default section header style using theme colors. -// If theme is nil, falls back to default theme. -// - -func DefaultThemedSectionHeaderStyle(theme *themes.Theme) SectionHeaderStyle { - // Fallback to default theme if not provided - if theme == nil { - theme = getDefaultTheme() - } - if theme == nil { - // Ultimate fallback - return SectionHeaderStyle{ - Color: lipgloss.Color("#00ADD8"), - Bold: true, - ShowSeparator: false, - SeparatorColor: lipgloss.Color("#6272A4"), - SeparatorChar: DefaultSeparatorChar, - } - } - - return SectionHeaderStyle{ - Color: theme.Colors.PrimaryColor(), - Bold: true, - ShowSeparator: false, - SeparatorColor: theme.Colors.MutedColor(), - SeparatorChar: DefaultSeparatorChar, - } -} diff --git a/pkg/ui/components/section_header_example_test.go b/pkg/ui/components/section_header_example_test.go deleted file mode 100644 index 44e4145..0000000 --- a/pkg/ui/components/section_header_example_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package components_test - -import ( - "fmt" - - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/components" -) - -// ExampleNewSectionHeader demonstrates basic section header creation. -func ExampleNewSectionHeader() { - // Create a section header with icon and title - header := components.NewSectionHeader( - "📦", // Icon - "Services", // Title - lipgloss.Color("#00ADD8"), // Color - 0, // Width (0 = no separator) - ) - - fmt.Println(header.Render()) - // Output will be styled with ANSI codes: 📦 Services -} - -// ExampleNewSectionHeader_withSeparator demonstrates a section header with separator line. -func ExampleNewSectionHeader_withSeparator() { - // Create a section header with separator - header := components.NewSectionHeader( - "🔧", // Icon - "Configuration", // Title - lipgloss.Color("#FF5555"), // Color - 40, // Width (separator line) - ) - - fmt.Println(header.Render()) - // Output will include a separator line below the header -} - -// ExampleSectionHeader_WithSeparator demonstrates method chaining. -func ExampleSectionHeader_WithSeparator() { - // Start without separator, then add it - header := components.NewSectionHeader( - "📊", - "Dashboard", - lipgloss.Color("#50FA7B"), - 0, - ).WithSeparator(60) - - fmt.Println(header.Render()) -} - -// ExampleSectionHeader_WithColor demonstrates changing colors. -func ExampleSectionHeader_WithColor() { - // Create header and customize colors - header := components.NewSectionHeader( - "ℹ", - "Information", - lipgloss.Color("#8BE9FD"), - 50, - ). - WithSeparatorColor(lipgloss.Color("#6272A4")). // Different separator color - WithSeparatorChar("═") // Double line separator - - fmt.Println(header.Render()) -} - -// ExampleNewSectionHeaderWithDefaults demonstrates using default A.R.C. colors. -func ExampleNewSectionHeaderWithDefaults() { - // Use default A.R.C. styling (no need to specify color) - header := components.NewSectionHeaderWithDefaults( - "🏠", - "Home", - 80, - ) - - fmt.Println(header.Render()) -} - -// ExampleSectionHeader_methodChaining demonstrates fluent API usage. -func ExampleSectionHeader_methodChaining() { - // Build a fully customized header with method chaining - header := components.NewSectionHeaderWithDefaults("📁", "Workspace", 0). - WithSeparator(70). - WithColor(lipgloss.Color("#FFB86C")). - WithSeparatorChar("-") - - fmt.Println(header.Render()) -} - -// ExampleSectionHeader_noIcon demonstrates a header without icon. -func ExampleSectionHeader_noIcon() { - // Create a plain text section header - header := components.NewSectionHeader( - "", // No icon - "Plain Section", - lipgloss.Color("#BD93F9"), - 60, - ) - - fmt.Println(header.Render()) -} diff --git a/pkg/ui/components/section_header_test.go b/pkg/ui/components/section_header_test.go deleted file mode 100644 index a66a463..0000000 --- a/pkg/ui/components/section_header_test.go +++ /dev/null @@ -1,496 +0,0 @@ -package components - -import ( - "strings" - "testing" - - "github.com/charmbracelet/lipgloss" -) - -func TestNewSectionHeader(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - icon string - title string - color lipgloss.Color - width int - wantIcon string - wantTitle string - wantWidth int - wantSep bool - }{ - { - name: "basic header without separator", - icon: "📦", - title: "Services", - color: lipgloss.Color("#00ADD8"), - width: 0, - wantIcon: "📦", - wantTitle: "Services", - wantWidth: 0, - wantSep: false, - }, - { - name: "header with separator", - icon: "🔧", - title: "Configuration", - color: lipgloss.Color("#FF5555"), - width: 80, - wantIcon: "🔧", - wantTitle: "Configuration", - wantWidth: 80, - wantSep: true, - }, - { - name: "header without icon", - icon: "", - title: "Plain Header", - color: lipgloss.Color("#50FA7B"), - width: 0, - wantIcon: "", - wantTitle: "Plain Header", - wantWidth: 0, - wantSep: false, - }, - { - name: "empty title", - icon: "ℹ", - title: "", - color: lipgloss.Color("#8BE9FD"), - width: 0, - wantIcon: "ℹ", - wantTitle: "", - wantWidth: 0, - wantSep: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - header := NewSectionHeader(tt.icon, tt.title, tt.color, tt.width) - - if header.Icon != tt.wantIcon { - t.Errorf("Icon = %q, want %q", header.Icon, tt.wantIcon) - } - - if header.Title != tt.wantTitle { - t.Errorf("Title = %q, want %q", header.Title, tt.wantTitle) - } - - if header.Width != tt.wantWidth { - t.Errorf("Width = %d, want %d", header.Width, tt.wantWidth) - } - - if header.Style.ShowSeparator != tt.wantSep { - t.Errorf("ShowSeparator = %v, want %v", header.Style.ShowSeparator, tt.wantSep) - } - - if header.Style.Bold != true { - t.Errorf("Bold = %v, want true", header.Style.Bold) - } - - if header.Style.Color != tt.color { - t.Errorf("Color = %v, want %v", header.Style.Color, tt.color) - } - }) - } -} - -func TestSectionHeader_Render(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - icon string - title string - width int - wantContains []string - wantNotEmpty bool - }{ - { - name: "basic render with icon and title", - icon: "📦", - title: "Services", - width: 0, - wantContains: []string{"📦", "Services"}, - wantNotEmpty: true, - }, - { - name: "render with separator", - icon: "🔧", - title: "Config", - width: 40, - wantContains: []string{"🔧", "Config", "─"}, - wantNotEmpty: true, - }, - { - name: "render without icon", - icon: "", - title: "Plain Section", - width: 0, - wantContains: []string{"Plain Section"}, - wantNotEmpty: true, - }, - { - name: "render with only icon", - icon: "ℹ", - title: "", - width: 0, - wantContains: []string{"ℹ"}, - wantNotEmpty: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - color := lipgloss.Color("#00ADD8") - header := NewSectionHeader(tt.icon, tt.title, color, tt.width) - rendered := header.Render() - - if tt.wantNotEmpty && rendered == "" { - t.Error("Render() returned empty string") - } - - for _, want := range tt.wantContains { - if !strings.Contains(rendered, want) { - t.Errorf("Render() output missing %q\nGot: %s", want, rendered) - } - } - }) - } -} - -func TestSectionHeader_RenderWithSeparator(t *testing.T) { - t.Parallel() - - header := NewSectionHeader("📦", "Services", lipgloss.Color("#00ADD8"), 80) - rendered := header.Render() - - // Should contain header text - if !strings.Contains(rendered, "📦") || !strings.Contains(rendered, "Services") { - t.Error("Rendered output missing header text") - } - - // Should contain separator - if !strings.Contains(rendered, "─") { - t.Error("Rendered output missing separator") - } - - // Should have multiple lines (header + separator) - lines := strings.Split(rendered, "\n") - if len(lines) < 2 { - t.Errorf("Expected at least 2 lines (header + separator), got %d", len(lines)) - } -} - -func TestSectionHeader_WithSeparator(t *testing.T) { - t.Parallel() - - header := NewSectionHeader("📦", "Services", lipgloss.Color("#00ADD8"), 0) - - // Initially no separator - if header.Style.ShowSeparator { - t.Error("ShowSeparator should be false initially") - } - - // Enable separator - header.WithSeparator(80) - - if !header.Style.ShowSeparator { - t.Error("ShowSeparator should be true after WithSeparator") - } - - if header.Width != 80 { - t.Errorf("Width = %d, want 80", header.Width) - } -} - -func TestSectionHeader_WithoutSeparator(t *testing.T) { - t.Parallel() - - header := NewSectionHeader("📦", "Services", lipgloss.Color("#00ADD8"), 80) - - // Initially has separator - if !header.Style.ShowSeparator { - t.Error("ShowSeparator should be true initially") - } - - // Disable separator - header.WithoutSeparator() - - if header.Style.ShowSeparator { - t.Error("ShowSeparator should be false after WithoutSeparator") - } -} - -func TestSectionHeader_WithColor(t *testing.T) { - t.Parallel() - - header := NewSectionHeader("📦", "Services", lipgloss.Color("#00ADD8"), 0) - newColor := lipgloss.Color("#FF5555") - - header.WithColor(newColor) - - if header.Style.Color != newColor { - t.Errorf("Color = %v, want %v", header.Style.Color, newColor) - } - - if header.Style.SeparatorColor != newColor { - t.Errorf("SeparatorColor = %v, want %v (should match header color)", header.Style.SeparatorColor, newColor) - } -} - -func TestSectionHeader_WithSeparatorColor(t *testing.T) { - t.Parallel() - - headerColor := lipgloss.Color("#00ADD8") - separatorColor := lipgloss.Color("#6272A4") - - header := NewSectionHeader("📦", "Services", headerColor, 0) - header.WithSeparatorColor(separatorColor) - - if header.Style.Color != headerColor { - t.Errorf("Color should remain %v, got %v", headerColor, header.Style.Color) - } - - if header.Style.SeparatorColor != separatorColor { - t.Errorf("SeparatorColor = %v, want %v", header.Style.SeparatorColor, separatorColor) - } -} - -func TestSectionHeader_WithSeparatorChar(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - char string - wantChar string - }{ - { - name: "double line", - char: "═", - wantChar: "═", - }, - { - name: "thick line", - char: "━", - wantChar: "━", - }, - { - name: "ASCII dash", - char: "-", - wantChar: "-", - }, - { - name: "ASCII equals", - char: "=", - wantChar: "=", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - header := NewSectionHeader("📦", "Services", lipgloss.Color("#00ADD8"), 40) - header.WithSeparatorChar(tt.char) - - if header.Style.SeparatorChar != tt.wantChar { - t.Errorf("SeparatorChar = %q, want %q", header.Style.SeparatorChar, tt.wantChar) - } - - rendered := header.Render() - if !strings.Contains(rendered, tt.char) { - t.Errorf("Rendered output should contain separator char %q", tt.char) - } - }) - } -} - -func TestSectionHeader_View(t *testing.T) { - t.Parallel() - - header := NewSectionHeader("📦", "Services", lipgloss.Color("#00ADD8"), 0) - - view := header.View() - render := header.Render() - - if view != render { - t.Error("View() and Render() should return the same output") - } -} - -func TestSectionHeader_MethodChaining(t *testing.T) { - t.Parallel() - - header := NewSectionHeader("📦", "Services", lipgloss.Color("#00ADD8"), 0). - WithSeparator(80). - WithColor(lipgloss.Color("#FF5555")). - WithSeparatorChar("═") - - if !header.Style.ShowSeparator { - t.Error("ShowSeparator should be true after chaining") - } - - if header.Width != 80 { - t.Errorf("Width = %d, want 80", header.Width) - } - - if header.Style.Color != lipgloss.Color("#FF5555") { - t.Error("Color should be updated after chaining") - } - - if header.Style.SeparatorChar != "═" { - t.Errorf("SeparatorChar = %q, want \"═\"", header.Style.SeparatorChar) - } -} - -func TestNewSectionHeaderWithDefaults(t *testing.T) { - t.Parallel() - - header := NewSectionHeaderWithDefaults("📦", "Services", 0) - - if header == nil { - t.Fatal("NewSectionHeaderWithDefaults returned nil") - } - - if header.Icon != "📦" { - t.Errorf("Icon = %q, want \"📦\"", header.Icon) - } - - if header.Title != "Services" { - t.Errorf("Title = %q, want \"Services\"", header.Title) - } - - // Should use default color - if header.Style.Color == "" { - t.Error("Color should have a value") - } - - defaultStyle := DefaultSectionHeaderStyle() - if header.Style.Color != defaultStyle.Color { - t.Errorf("Color = %v, want default %v", header.Style.Color, defaultStyle.Color) - } -} - -func TestDefaultSectionHeaderStyle(t *testing.T) { - t.Parallel() - - style := DefaultSectionHeaderStyle() - - if style.Color == "" { - t.Error("Default style should have a color") - } - - if !style.Bold { - t.Error("Default style should have Bold = true") - } - - if style.ShowSeparator { - t.Error("Default style should have ShowSeparator = false") - } - - if style.SeparatorChar != "─" { - t.Errorf("Default separator char = %q, want \"─\"", style.SeparatorChar) - } -} - -func TestSectionHeader_SeparatorWidthCalculation(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - icon string - title string - width int - description string - }{ - { - name: "separator wider than header", - icon: "📦", - title: "Go", - width: 80, - description: "separator should extend to specified width", - }, - { - name: "separator matches header width", - icon: "📦", - title: "Very Long Section Title That Exceeds Width", - width: 20, - description: "separator should match header width when header is longer", - }, - { - name: "zero width", - icon: "📦", - title: "Services", - width: 0, - description: "no separator with zero width", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - header := NewSectionHeader(tt.icon, tt.title, lipgloss.Color("#00ADD8"), tt.width) - rendered := header.Render() - - if tt.width > 0 { - // Should have separator - if !strings.Contains(rendered, "─") { - t.Error("Expected separator in output") - } - - // Should have multiple lines - lines := strings.Split(rendered, "\n") - if len(lines) < 2 { - t.Errorf("Expected at least 2 lines, got %d", len(lines)) - } - } else { - // Should not have separator - lines := strings.Split(rendered, "\n") - if len(lines) > 1 { - t.Error("Expected single line (no separator) with width=0") - } - } - }) - } -} - -func TestSectionHeader_ANSIAwareWidth(t *testing.T) { - t.Parallel() - - // Test with ANSI-styled title (simulated via lipgloss) - styledTitle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5555")).Render("Services") - - header := &SectionHeader{ - Icon: "📦", - Title: styledTitle, - Width: 80, - Style: SectionHeaderStyle{ - Color: lipgloss.Color("#00ADD8"), - Bold: true, - ShowSeparator: true, - SeparatorColor: lipgloss.Color("#6272A4"), - SeparatorChar: "─", - }, - } - - rendered := header.Render() - - // Should handle ANSI codes correctly - if rendered == "" { - t.Error("Render() should handle ANSI-styled titles") - } - - // Should contain separator - if !strings.Contains(rendered, "─") { - t.Error("Should render separator with ANSI-styled title") - } -} diff --git a/pkg/ui/components/sidebar/.gitkeep b/pkg/ui/components/sidebar/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/pkg/ui/components/sidebar/sidebar.go b/pkg/ui/components/sidebar/sidebar.go deleted file mode 100644 index 72f0a90..0000000 --- a/pkg/ui/components/sidebar/sidebar.go +++ /dev/null @@ -1,291 +0,0 @@ -// Package sidebar provides vertical navigation menu components for the UI engine. -package sidebar - -import ( - "strings" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// MenuItem represents a single menu item in the sidebar. -type MenuItem struct { - ID string - Label string - Icon string // Optional emoji/unicode icon -} - -// Sidebar represents a vertical navigation menu component. -// -// Features: -// - Vertical menu with keyboard navigation -// - Selected item highlighting with profile-themed colors -// - Icon support (optional emoji/unicode) -// - Fixed width layout -// - Arrow key navigation (j/k or up/down) -// -// Usage: -// -// items := []MenuItem{ -// {ID: "dashboard", Label: "Dashboard", Icon: "🏠"}, -// {ID: "services", Label: "Services", Icon: "📡"}, -// } -// sidebar := NewSidebar(items, theme) -// sidebar.SetWidth(25) -type Sidebar struct { - items []MenuItem - selectedIndex int - width int - theme *themes.Theme - - // Cached styles - selectedStyle lipgloss.Style - unselectedStyle lipgloss.Style - borderStyle lipgloss.Style -} - -// NewSidebar creates a new Sidebar with the given menu items and theme. -// -// The sidebar is initialized with: -// - Profile-themed colors for selection -// - Default width of 25 columns -// - First item selected by default -// -// Example: -// -// theme := &themes.Theme{ -// Colors: themes.ColorSet{ -// Primary: "#00ADD8", -// Muted: "#666666", -// }, -// } -// items := []MenuItem{ -// {ID: "dashboard", Label: "Dashboard", Icon: "🏠"}, -// } -// sidebar := NewSidebar(items, theme) -func NewSidebar(items []MenuItem, theme *themes.Theme) *Sidebar { - s := &Sidebar{ - items: items, - selectedIndex: 0, - width: 25, - theme: theme, - } - - s.updateStyles() - return s -} - -// updateStyles rebuilds the cached styles based on current theme. -func (s *Sidebar) updateStyles() { - if s.theme == nil { - // Fallback styles - s.selectedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#00ADD8")). - Bold(true). - Padding(0, 1) - - s.unselectedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#666666")). - Padding(0, 1) - - s.borderStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#44475A")). - Padding(1, 0) - } else { - colors := s.theme.Colors - - s.selectedStyle = lipgloss.NewStyle(). - Foreground(colors.PrimaryColor()). - Bold(true). - Padding(0, 1) - - s.unselectedStyle = lipgloss.NewStyle(). - Foreground(colors.MutedColor()). - Padding(0, 1) - - s.borderStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(colors.BorderColor()). - Padding(1, 0) - } -} - -// Init implements tea.Model for Bubble Tea integration. -func (s *Sidebar) Init() tea.Cmd { - return nil -} - -// Update implements tea.Model to handle keyboard navigation. -// -// Keyboard shortcuts: -// - j, down: Move selection down -// - k, up: Move selection up -// - g: Jump to first item -// - G: Jump to last item -func (s *Sidebar) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - keyMsg, ok := msg.(tea.KeyMsg) - if !ok { - return s, nil - } - switch { - case key.Matches(keyMsg, key.NewBinding(key.WithKeys("j", "down"))): - s.SelectNext() - return s, nil - - case key.Matches(keyMsg, key.NewBinding(key.WithKeys("k", "up"))): - s.SelectPrev() - return s, nil - - case key.Matches(keyMsg, key.NewBinding(key.WithKeys("g"))): - s.SetSelected(0) - return s, nil - - case key.Matches(keyMsg, key.NewBinding(key.WithKeys("G"))): - s.SetSelected(len(s.items) - 1) - return s, nil - } - - return s, nil -} - -// View implements tea.Model to render the sidebar. -// -// The sidebar is rendered with: -// - Profile-themed border -// - Vertical list of menu items -// - Selected item highlighted with primary color -// - Icons displayed before labels (if present) -// -// Example output: -// -// ┌───────────────────┐ -// │ │ -// │ 🏠 Dashboard │ -// │ 📡 Services │ -// │ 📁 Workspace │ -// │ ⚙️ Config │ -// │ │ -// └───────────────────┘ -func (s *Sidebar) View() string { - if len(s.items) == 0 { - return s.borderStyle.Width(s.width).Render("") - } - - menuItems := make([]string, 0, len(s.items)) - for i, item := range s.items { - label := item.Label - if item.Icon != "" { - label = item.Icon + " " + label - } - - var styledLabel string - if i == s.selectedIndex { - // Add selection indicator - indicator := "▸ " - styledLabel = s.selectedStyle.Render(indicator + label) - } else { - styledLabel = s.unselectedStyle.Render(" " + label) - } - - menuItems = append(menuItems, styledLabel) - } - - menu := strings.Join(menuItems, "\n") - - // Render with border and fixed width - return s.borderStyle.Width(s.width).Render(menu) -} - -// SetWidth sets the sidebar width in columns. -// -// The width includes the border, so the actual content width will be -// slightly less (typically width - 4 for border and padding). -func (s *Sidebar) SetWidth(width int) { - s.width = width -} - -// SetSelected sets the selected menu item by index. -// -// If the index is out of bounds, it will be clamped to valid range. -func (s *Sidebar) SetSelected(index int) { - if index < 0 { - s.selectedIndex = 0 - } else if index >= len(s.items) { - s.selectedIndex = len(s.items) - 1 - } else { - s.selectedIndex = index - } -} - -// SelectNext moves the selection to the next item. -// -// If already at the last item, wraps to the first item. -func (s *Sidebar) SelectNext() { - if len(s.items) == 0 { - return - } - s.selectedIndex = (s.selectedIndex + 1) % len(s.items) -} - -// SelectPrev moves the selection to the previous item. -// -// If already at the first item, wraps to the last item. -func (s *Sidebar) SelectPrev() { - if len(s.items) == 0 { - return - } - s.selectedIndex-- - if s.selectedIndex < 0 { - s.selectedIndex = len(s.items) - 1 - } -} - -// SelectedIndex returns the currently selected item index. -func (s *Sidebar) SelectedIndex() int { - return s.selectedIndex -} - -// SelectedItem returns the currently selected menu item. -// -// If no items exist, returns an empty MenuItem. -func (s *Sidebar) SelectedItem() MenuItem { - if len(s.items) == 0 || s.selectedIndex < 0 || s.selectedIndex >= len(s.items) { - return MenuItem{} - } - return s.items[s.selectedIndex] -} - -// SetItems updates the menu items. -// -// The selected index will be reset to 0 if the new items list is shorter -// than the current selected index. -func (s *Sidebar) SetItems(items []MenuItem) { - s.items = items - if s.selectedIndex >= len(items) { - s.selectedIndex = 0 - } -} - -// Items returns the current menu items. -func (s *Sidebar) Items() []MenuItem { - return s.items -} - -// DashboardMenuItems returns the standard menu items for a dashboard view. -func DashboardMenuItems() []MenuItem { - return []MenuItem{ - {ID: "dashboard", Label: "Dashboard", Icon: "🏠"}, - {ID: "services", Label: "Services", Icon: "📡"}, - {ID: "workspace", Label: "Workspace", Icon: "📁"}, - {ID: "config", Label: "Config", Icon: "⚙️"}, - } -} - -// Keybindings returns a help string showing the keyboard shortcuts. -func (s *Sidebar) Keybindings() string { - return "↑/k: up • ↓/j: down • g: first • G: last" -} diff --git a/pkg/ui/components/sidebar/sidebar_test.go b/pkg/ui/components/sidebar/sidebar_test.go deleted file mode 100644 index 6e8b421..0000000 --- a/pkg/ui/components/sidebar/sidebar_test.go +++ /dev/null @@ -1,495 +0,0 @@ -package sidebar - -import ( - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -func createTestTheme() *themes.Theme { - return &themes.Theme{ - Colors: themes.ColorSet{ - Primary: "#00ADD8", - Muted: "#666666", - Border: "#44475A", - Foreground: "#F8F8F2", - Background: "#282A36", - }, - } -} - -func createTestItems() []MenuItem { - return []MenuItem{ - {ID: "dashboard", Label: "Dashboard", Icon: "🏠"}, - {ID: "services", Label: "Services", Icon: "📡"}, - {ID: "workspace", Label: "Workspace", Icon: "📁"}, - {ID: "config", Label: "Config", Icon: "⚙️"}, - } -} - -func TestNewSidebar(t *testing.T) { - items := createTestItems() - theme := createTestTheme() - - sidebar := NewSidebar(items, theme) - - if sidebar == nil { - t.Fatal("NewSidebar returned nil") - } - - if len(sidebar.items) != len(items) { - t.Errorf("Expected %d items, got %d", len(items), len(sidebar.items)) - } - - if sidebar.selectedIndex != 0 { - t.Errorf("Expected initial selected index to be 0, got %d", sidebar.selectedIndex) - } - - if sidebar.width != 25 { - t.Errorf("Expected default width to be 25, got %d", sidebar.width) - } - - if sidebar.theme != theme { - t.Error("Theme not properly set") - } -} - -func TestNewSidebarWithNilTheme(t *testing.T) { - items := createTestItems() - - sidebar := NewSidebar(items, nil) - - if sidebar == nil { - t.Fatal("NewSidebar returned nil") - } - - // Should use fallback styles - if sidebar.selectedStyle.GetForeground() == nil { - t.Error("Expected fallback selected style to have foreground color") - } -} - -func TestSetWidth(t *testing.T) { - sidebar := NewSidebar(createTestItems(), createTestTheme()) - - tests := []int{10, 25, 30, 50} - - for _, width := range tests { - sidebar.SetWidth(width) - if sidebar.width != width { - t.Errorf("SetWidth(%d): expected width %d, got %d", width, width, sidebar.width) - } - } -} - -func TestSetSelected(t *testing.T) { - sidebar := NewSidebar(createTestItems(), createTestTheme()) - - tests := []struct { - name string - index int - expectedIndex int - }{ - {"Valid index 0", 0, 0}, - {"Valid index 1", 1, 1}, - {"Valid index 3", 3, 3}, - {"Negative index", -1, 0}, - {"Out of bounds index", 10, 3}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sidebar.SetSelected(tt.index) - if sidebar.selectedIndex != tt.expectedIndex { - t.Errorf("SetSelected(%d): expected %d, got %d", tt.index, tt.expectedIndex, sidebar.selectedIndex) - } - }) - } -} - -func TestSelectedIndex(t *testing.T) { - sidebar := NewSidebar(createTestItems(), createTestTheme()) - - sidebar.SetSelected(2) - if sidebar.SelectedIndex() != 2 { - t.Errorf("Expected SelectedIndex() to return 2, got %d", sidebar.SelectedIndex()) - } -} - -func TestSelectedItem(t *testing.T) { - items := createTestItems() - sidebar := NewSidebar(items, createTestTheme()) - - sidebar.SetSelected(1) - selected := sidebar.SelectedItem() - - if selected.ID != "services" { - t.Errorf("Expected selected item ID to be 'services', got '%s'", selected.ID) - } - - if selected.Label != "Services" { - t.Errorf("Expected selected item label to be 'Services', got '%s'", selected.Label) - } -} - -func TestSelectedItemEmpty(t *testing.T) { - sidebar := NewSidebar([]MenuItem{}, createTestTheme()) - - selected := sidebar.SelectedItem() - - if selected.ID != "" || selected.Label != "" { - t.Error("Expected empty MenuItem for sidebar with no items") - } -} - -func TestSelectNext(t *testing.T) { - sidebar := NewSidebar(createTestItems(), createTestTheme()) - - // Start at 0 - if sidebar.SelectedIndex() != 0 { - t.Errorf("Expected initial index 0, got %d", sidebar.SelectedIndex()) - } - - // Move to 1 - sidebar.SelectNext() - if sidebar.SelectedIndex() != 1 { - t.Errorf("Expected index 1 after SelectNext, got %d", sidebar.SelectedIndex()) - } - - // Move to 2 - sidebar.SelectNext() - if sidebar.SelectedIndex() != 2 { - t.Errorf("Expected index 2 after SelectNext, got %d", sidebar.SelectedIndex()) - } - - // Move to 3 - sidebar.SelectNext() - if sidebar.SelectedIndex() != 3 { - t.Errorf("Expected index 3 after SelectNext, got %d", sidebar.SelectedIndex()) - } - - // Wrap to 0 - sidebar.SelectNext() - if sidebar.SelectedIndex() != 0 { - t.Errorf("Expected index 0 after wrap, got %d", sidebar.SelectedIndex()) - } -} - -func TestSelectPrev(t *testing.T) { - sidebar := NewSidebar(createTestItems(), createTestTheme()) - - // Start at 0, wrap to last - sidebar.SelectPrev() - if sidebar.SelectedIndex() != 3 { - t.Errorf("Expected index 3 after wrap, got %d", sidebar.SelectedIndex()) - } - - // Move to 2 - sidebar.SelectPrev() - if sidebar.SelectedIndex() != 2 { - t.Errorf("Expected index 2 after SelectPrev, got %d", sidebar.SelectedIndex()) - } - - // Move to 1 - sidebar.SelectPrev() - if sidebar.SelectedIndex() != 1 { - t.Errorf("Expected index 1 after SelectPrev, got %d", sidebar.SelectedIndex()) - } - - // Move to 0 - sidebar.SelectPrev() - if sidebar.SelectedIndex() != 0 { - t.Errorf("Expected index 0 after SelectPrev, got %d", sidebar.SelectedIndex()) - } -} - -func TestSelectNextEmpty(t *testing.T) { - sidebar := NewSidebar([]MenuItem{}, createTestTheme()) - - // Should not panic on empty items - sidebar.SelectNext() - if sidebar.SelectedIndex() != 0 { - t.Errorf("Expected index 0 for empty sidebar, got %d", sidebar.SelectedIndex()) - } -} - -func TestSelectPrevEmpty(t *testing.T) { - sidebar := NewSidebar([]MenuItem{}, createTestTheme()) - - // Should not panic on empty items - sidebar.SelectPrev() - if sidebar.SelectedIndex() != 0 { - t.Errorf("Expected index 0 for empty sidebar, got %d", sidebar.SelectedIndex()) - } -} - -func TestSetItems(t *testing.T) { - sidebar := NewSidebar(createTestItems(), createTestTheme()) - - newItems := []MenuItem{ - {ID: "home", Label: "Home", Icon: "🏠"}, - {ID: "settings", Label: "Settings", Icon: "⚙️"}, - } - - sidebar.SetItems(newItems) - - if len(sidebar.Items()) != 2 { - t.Errorf("Expected 2 items after SetItems, got %d", len(sidebar.Items())) - } - - if sidebar.Items()[0].ID != "home" { - t.Errorf("Expected first item ID to be 'home', got '%s'", sidebar.Items()[0].ID) - } -} - -func TestSetItemsResetsSelection(t *testing.T) { - sidebar := NewSidebar(createTestItems(), createTestTheme()) - - // Select last item - sidebar.SetSelected(3) - - // Set new items with only 2 items - newItems := []MenuItem{ - {ID: "home", Label: "Home"}, - {ID: "settings", Label: "Settings"}, - } - - sidebar.SetItems(newItems) - - // Selection should be reset to 0 - if sidebar.SelectedIndex() != 0 { - t.Errorf("Expected selected index to reset to 0, got %d", sidebar.SelectedIndex()) - } -} - -func TestInit(t *testing.T) { - sidebar := NewSidebar(createTestItems(), createTestTheme()) - - cmd := sidebar.Init() - - if cmd != nil { - t.Error("Expected Init() to return nil command") - } -} - -func TestUpdate_KeyboardNavigation(t *testing.T) { - sidebar := NewSidebar(createTestItems(), createTestTheme()) - - tests := []struct { - name string - key string - expectedIndex int - }{ - {"Down arrow", "down", 1}, - {"j key", "j", 1}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sidebar.SetSelected(0) - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)} - if tt.key == "down" { - msg = tea.KeyMsg{Type: tea.KeyDown} - } - - updatedModel, cmd := sidebar.Update(msg) - updatedSidebar := updatedModel.(*Sidebar) - - if updatedSidebar.SelectedIndex() != tt.expectedIndex { - t.Errorf("Expected index %d after %s, got %d", tt.expectedIndex, tt.name, updatedSidebar.SelectedIndex()) - } - - if cmd != nil { - t.Error("Expected Update to return nil command") - } - }) - } -} - -func TestUpdate_UpNavigation(t *testing.T) { - sidebar := NewSidebar(createTestItems(), createTestTheme()) - - // Set to item 2 - sidebar.SetSelected(2) - - // Press k (up) - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")} - updatedModel, _ := sidebar.Update(msg) - updatedSidebar := updatedModel.(*Sidebar) - - if updatedSidebar.SelectedIndex() != 1 { - t.Errorf("Expected index 1 after 'k', got %d", updatedSidebar.SelectedIndex()) - } - - // Press up arrow - msg = tea.KeyMsg{Type: tea.KeyUp} - updatedModel, _ = updatedSidebar.Update(msg) - updatedSidebar = updatedModel.(*Sidebar) - - if updatedSidebar.SelectedIndex() != 0 { - t.Errorf("Expected index 0 after up arrow, got %d", updatedSidebar.SelectedIndex()) - } -} - -func TestUpdate_JumpToFirst(t *testing.T) { - sidebar := NewSidebar(createTestItems(), createTestTheme()) - - // Set to last item - sidebar.SetSelected(3) - - // Press g (jump to first) - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("g")} - updatedModel, _ := sidebar.Update(msg) - updatedSidebar := updatedModel.(*Sidebar) - - if updatedSidebar.SelectedIndex() != 0 { - t.Errorf("Expected index 0 after 'g', got %d", updatedSidebar.SelectedIndex()) - } -} - -func TestUpdate_JumpToLast(t *testing.T) { - sidebar := NewSidebar(createTestItems(), createTestTheme()) - - // Start at first item - sidebar.SetSelected(0) - - // Press G (jump to last) - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")} - updatedModel, _ := sidebar.Update(msg) - updatedSidebar := updatedModel.(*Sidebar) - - if updatedSidebar.SelectedIndex() != 3 { - t.Errorf("Expected index 3 after 'G', got %d", updatedSidebar.SelectedIndex()) - } -} - -func TestView(t *testing.T) { - items := createTestItems() - theme := createTestTheme() - sidebar := NewSidebar(items, theme) - - view := sidebar.View() - - if view == "" { - t.Error("View() returned empty string") - } - - // Check that all labels are present - for _, item := range items { - if !strings.Contains(view, item.Label) { - t.Errorf("View() missing label '%s'", item.Label) - } - } - - // Check that icons are present - for _, item := range items { - if item.Icon != "" && !strings.Contains(view, item.Icon) { - t.Errorf("View() missing icon '%s'", item.Icon) - } - } -} - -func TestViewEmpty(t *testing.T) { - sidebar := NewSidebar([]MenuItem{}, createTestTheme()) - - view := sidebar.View() - - if view == "" { - t.Error("View() returned empty string for empty sidebar") - } -} - -func TestViewHighlighting(t *testing.T) { - items := createTestItems() - theme := createTestTheme() - sidebar := NewSidebar(items, theme) - - // Select second item - sidebar.SetSelected(1) - - view := sidebar.View() - - // The selected item should have the selection indicator - if !strings.Contains(view, "▸") { - t.Error("View() missing selection indicator") - } -} - -func TestDashboardMenuItems(t *testing.T) { - items := DashboardMenuItems() - - if len(items) != 4 { - t.Errorf("Expected 4 dashboard menu items, got %d", len(items)) - } - - expectedIDs := []string{"dashboard", "services", "workspace", "config"} - for i, expectedID := range expectedIDs { - if items[i].ID != expectedID { - t.Errorf("Expected item %d to have ID '%s', got '%s'", i, expectedID, items[i].ID) - } - } - - // All items should have icons - for i, item := range items { - if item.Icon == "" { - t.Errorf("Expected item %d to have an icon", i) - } - } -} - -func TestKeybindings(t *testing.T) { - sidebar := NewSidebar(createTestItems(), createTestTheme()) - - keybindings := sidebar.Keybindings() - - if keybindings == "" { - t.Error("Keybindings() returned empty string") - } - - // Should mention key shortcuts - expectedKeys := []string{"↑", "k", "↓", "j", "g", "G"} - for _, key := range expectedKeys { - if !strings.Contains(keybindings, key) { - t.Errorf("Keybindings() missing key '%s'", key) - } - } -} - -func TestItemsGetter(t *testing.T) { - items := createTestItems() - sidebar := NewSidebar(items, createTestTheme()) - - retrievedItems := sidebar.Items() - - if len(retrievedItems) != len(items) { - t.Errorf("Expected Items() to return %d items, got %d", len(items), len(retrievedItems)) - } - - for i, item := range items { - if retrievedItems[i].ID != item.ID { - t.Errorf("Item %d: expected ID '%s', got '%s'", i, item.ID, retrievedItems[i].ID) - } - } -} - -func TestMenuItemWithoutIcon(t *testing.T) { - items := []MenuItem{ - {ID: "item1", Label: "Item 1", Icon: ""}, - {ID: "item2", Label: "Item 2"}, - } - - sidebar := NewSidebar(items, createTestTheme()) - view := sidebar.View() - - // Should render labels without icons - if !strings.Contains(view, "Item 1") { - t.Error("View() missing label for item without icon") - } - - if !strings.Contains(view, "Item 2") { - t.Error("View() missing label for second item without icon") - } -} diff --git a/pkg/ui/components/spinner.go b/pkg/ui/components/spinner.go deleted file mode 100644 index 9628a8c..0000000 --- a/pkg/ui/components/spinner.go +++ /dev/null @@ -1,83 +0,0 @@ -package components - -import ( - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// Spinner styles -var ( - // SpinnerDot - Simple dot spinner - SpinnerDot = spinner.Dot - - // SpinnerLine - Line spinner - SpinnerLine = spinner.Line - - // SpinnerMiniDot - Smaller dot - SpinnerMiniDot = spinner.MiniDot - - // SpinnerPoints - Points spinner - SpinnerPoints = spinner.Points - - // SpinnerGlobe - Globe spinner - SpinnerGlobe = spinner.Globe - - // SpinnerMoon - Moon phases spinner - SpinnerMoon = spinner.Moon - - // SpinnerMonkey - Monkey spinner - SpinnerMonkey = spinner.Monkey - - // SpinnerMeter - Meter spinner - SpinnerMeter = spinner.Meter - - // SpinnerHamburger - Hamburger spinner - SpinnerHamburger = spinner.Hamburger -) - -// NewSpinner creates a new spinner with the primary theme color. -// Deprecated: Use NewThemedSpinner for profile-aware theming. -// This version maintains backward compatibility by using default theme. -func NewSpinner() spinner.Model { - return NewThemedSpinner(nil) -} - -// NewThemedSpinner creates a new spinner with the primary theme color. -// If theme is nil, falls back to default theme. -func NewThemedSpinner(theme *themes.Theme) spinner.Model { - // Fallback to default theme if not provided - if theme == nil { - theme = getDefaultTheme() - } - - s := spinner.New() - s.Spinner = spinner.Dot - - // Use theme primary color or fallback to cyan - if theme != nil { - s.Style = lipgloss.NewStyle().Foreground(theme.Colors.PrimaryColor()) - } else { - s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#00ADD8")) - } - return s -} - -// NewSpinnerWithColor creates a spinner with a custom color. -// Deprecated: Use NewSpinner with theme instead for profile-aware theming. -func NewSpinnerWithColor(color string) spinner.Model { - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(color)) - return s -} - -// NewSpinnerWithStyle creates a spinner with a custom spinner style. -// Deprecated: Use NewSpinner with theme instead for profile-aware theming. -func NewSpinnerWithStyle(spinnerType spinner.Spinner, color string) spinner.Model { - s := spinner.New() - s.Spinner = spinnerType - s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(color)) - return s -} diff --git a/pkg/ui/components/spinner_test.go b/pkg/ui/components/spinner_test.go deleted file mode 100644 index afb7d49..0000000 --- a/pkg/ui/components/spinner_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package components - -import ( - "testing" - - "github.com/charmbracelet/bubbles/spinner" -) - -func TestNewSpinner(t *testing.T) { - s := NewSpinner() - - if s.Spinner.FPS == 0 { - t.Error("NewSpinner() returned spinner with 0 FPS") - } -} - -func TestNewSpinnerWithColor(t *testing.T) { - color := "#FF0000" - s := NewSpinnerWithColor(color) - - if s.Spinner.FPS == 0 { - t.Error("NewSpinnerWithColor() returned spinner with 0 FPS") - } -} - -func TestNewSpinnerWithStyle(t *testing.T) { - tests := []struct { - name string - spinnerType spinner.Spinner - color string - }{ - {"dot spinner", SpinnerDot, "#00ADD8"}, - {"line spinner", SpinnerLine, "#FF0000"}, - {"mini dot", SpinnerMiniDot, "#00FF00"}, - {"globe", SpinnerGlobe, "#0000FF"}, - {"moon", SpinnerMoon, "#FFFF00"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s := NewSpinnerWithStyle(tt.spinnerType, tt.color) - if s.Spinner.FPS == 0 { - t.Error("NewSpinnerWithStyle() returned spinner with 0 FPS") - } - }) - } -} - -func TestSpinnerStyles(t *testing.T) { - tests := []struct { - name string - spinner spinner.Spinner - }{ - {"SpinnerDot", SpinnerDot}, - {"SpinnerLine", SpinnerLine}, - {"SpinnerMiniDot", SpinnerMiniDot}, - {"SpinnerPoints", SpinnerPoints}, - {"SpinnerGlobe", SpinnerGlobe}, - {"SpinnerMoon", SpinnerMoon}, - {"SpinnerMonkey", SpinnerMonkey}, - {"SpinnerMeter", SpinnerMeter}, - {"SpinnerHamburger", SpinnerHamburger}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.spinner.FPS == 0 { - t.Errorf("%s has 0 FPS", tt.name) - } - if len(tt.spinner.Frames) == 0 { - t.Errorf("%s has no frames", tt.name) - } - }) - } -} - -func TestSpinner_DefaultColor(t *testing.T) { - s := NewSpinner() - // The default color should be the primary theme color - // Just verify that the style has a foreground color set - styleColor := s.Style.GetForeground() - - // lipgloss.TerminalColor doesn't have String() method, so we just check it's not nil - _ = styleColor // Use the variable to avoid unused error - - // This is a smoke test to ensure color is set - if s.Style.GetForeground() == nil { - t.Error("Spinner should have a foreground color set") - } -} - -func TestSpinner_CustomColors(t *testing.T) { - colors := []string{ - "#FF0000", - "#00FF00", - "#0000FF", - "#FFFF00", - "#FF00FF", - "#00FFFF", - } - - for _, color := range colors { - t.Run(color, func(t *testing.T) { - s := NewSpinnerWithColor(color) - if s.Spinner.FPS == 0 { - t.Error("Spinner has 0 FPS") - } - }) - } -} - -func TestSpinner_AllStylesWithColor(t *testing.T) { - styles := []spinner.Spinner{ - SpinnerDot, - SpinnerLine, - SpinnerMiniDot, - SpinnerPoints, - SpinnerGlobe, - SpinnerMoon, - SpinnerMonkey, - SpinnerMeter, - SpinnerHamburger, - } - - color := "#00ADD8" - for i, style := range styles { - t.Run(string(rune('A'+i)), func(t *testing.T) { - s := NewSpinnerWithStyle(style, color) - if s.Spinner.FPS == 0 { - t.Error("Spinner has 0 FPS") - } - if len(s.Spinner.Frames) == 0 { - t.Error("Spinner has no frames") - } - }) - } -} - -func TestSpinner_FrameConsistency(t *testing.T) { - // Verify that all spinner styles have consistent frame definitions - styles := map[string]spinner.Spinner{ - "Dot": SpinnerDot, - "Line": SpinnerLine, - "MiniDot": SpinnerMiniDot, - "Points": SpinnerPoints, - "Globe": SpinnerGlobe, - "Moon": SpinnerMoon, - "Monkey": SpinnerMonkey, - "Meter": SpinnerMeter, - "Hamburger": SpinnerHamburger, - } - - for name, style := range styles { - t.Run(name, func(t *testing.T) { - if len(style.Frames) < 2 { - t.Errorf("%s has less than 2 frames: %d", name, len(style.Frames)) - } - if style.FPS < 1 { - t.Errorf("%s has invalid FPS: %v", name, style.FPS) - } - }) - } -} diff --git a/pkg/ui/components/split_pane.go b/pkg/ui/components/split_pane.go deleted file mode 100644 index 76c4ce6..0000000 --- a/pkg/ui/components/split_pane.go +++ /dev/null @@ -1,242 +0,0 @@ -package components - -import ( - "github.com/charmbracelet/lipgloss" -) - -// PaneFocus indicates which pane has focus. -type PaneFocus int - -const ( - // FocusLeft indicates the left pane has focus. - FocusLeft PaneFocus = iota - // FocusRight indicates the right pane has focus. - FocusRight -) - -// SplitPane represents a two-pane layout with configurable ratios. -// Automatically handles focus visualization and responsive breakpoints. -// -// Example usage: -// -// split := NewSplitPane(leftContent, rightContent, 80). -// SetRatio(0.3). -// SetFocus(FocusLeft) -// fmt.Println(split.Render()) -type SplitPane struct { - LeftContent string - RightContent string - Width int - Focus PaneFocus - Ratio float64 // Left pane ratio (0.0-1.0), default 0.3 - - // Minimum widths before switching to vertical stacking - MinLeftWidth int // Default: 24 - MinRightWidth int // Default: 40 - - // Border styling - BorderTier BorderTier - FocusedColor lipgloss.Color - UnfocusedColor lipgloss.Color - VerticalGap int // Gap between panes in vertical mode (default: 1) - HorizontalGap int // Gap between panes in horizontal mode (default: 2) - ShowBorder bool - LeftBorderStyle lipgloss.Border - RightBorderStyle lipgloss.Border -} - -// NewSplitPane creates a new split pane with default settings. -func NewSplitPane(leftContent, rightContent string, width int) *SplitPane { - return &SplitPane{ - LeftContent: leftContent, - RightContent: rightContent, - Width: width, - Focus: FocusLeft, - Ratio: 0.3, - MinLeftWidth: 24, - MinRightWidth: 40, - BorderTier: BorderTierClassic, - FocusedColor: lipgloss.Color("#00ADD8"), - UnfocusedColor: lipgloss.Color("#6272A4"), - VerticalGap: 1, - HorizontalGap: 2, - ShowBorder: true, - LeftBorderStyle: lipgloss.RoundedBorder(), - RightBorderStyle: lipgloss.RoundedBorder(), - } -} - -// SetRatio sets the left pane ratio (0.0-1.0). -func (sp *SplitPane) SetRatio(ratio float64) *SplitPane { - if ratio < 0.0 { - ratio = 0.0 - } - if ratio > 1.0 { - ratio = 1.0 - } - sp.Ratio = ratio - return sp -} - -// SetFocus sets which pane has focus. -func (sp *SplitPane) SetFocus(focus PaneFocus) *SplitPane { - sp.Focus = focus - return sp -} - -// ToggleFocus switches focus between panes. -func (sp *SplitPane) ToggleFocus() PaneFocus { - if sp.Focus == FocusLeft { - sp.Focus = FocusRight - } else { - sp.Focus = FocusLeft - } - return sp.Focus -} - -// SetMinWidths sets the minimum widths for both panes. -func (sp *SplitPane) SetMinWidths(left, right int) *SplitPane { - sp.MinLeftWidth = left - sp.MinRightWidth = right - return sp -} - -// SetBorderTier sets the border tier for both panes. -func (sp *SplitPane) SetBorderTier(tier BorderTier) *SplitPane { - sp.BorderTier = tier - return sp -} - -// SetColors sets the focused and unfocused border colors. -func (sp *SplitPane) SetColors(focused, unfocused lipgloss.Color) *SplitPane { - sp.FocusedColor = focused - sp.UnfocusedColor = unfocused - return sp -} - -// Render returns the split pane as a styled string. -// Automatically switches to vertical stacking if width constraints aren't met. -func (sp *SplitPane) Render() string { - // Calculate layout mode (horizontal vs vertical) - leftWidth := int(float64(sp.Width) * sp.Ratio) - rightWidth := sp.Width - leftWidth - sp.HorizontalGap - - // Check if we need to stack vertically - useVerticalLayout := leftWidth < sp.MinLeftWidth || rightWidth < sp.MinRightWidth - - if useVerticalLayout { - return sp.renderVertical() - } - return sp.renderHorizontal(leftWidth, rightWidth) -} - -// renderHorizontal renders the split pane in horizontal mode. -func (sp *SplitPane) renderHorizontal(leftWidth, rightWidth int) string { - // Apply border tier to both panes - leftBorder := sp.getBorder(sp.BorderTier) - rightBorder := sp.getBorder(sp.BorderTier) - - // Determine border colors based on focus - leftColor := sp.UnfocusedColor - rightColor := sp.UnfocusedColor - if sp.Focus == FocusLeft { - leftColor = sp.FocusedColor - } else { - rightColor = sp.FocusedColor - } - - // Create left pane style - leftStyle := lipgloss.NewStyle(). - Width(leftWidth). - Border(leftBorder). - BorderForeground(leftColor) - - if !sp.ShowBorder { - leftStyle = leftStyle.Border(lipgloss.HiddenBorder()) - } - - // Create right pane style - rightStyle := lipgloss.NewStyle(). - Width(rightWidth). - Border(rightBorder). - BorderForeground(rightColor) - - if !sp.ShowBorder { - rightStyle = rightStyle.Border(lipgloss.HiddenBorder()) - } - - // Render both panes - leftPane := leftStyle.Render(sp.LeftContent) - rightPane := rightStyle.Render(sp.RightContent) - - // Create horizontal gap - gap := lipgloss.NewStyle().Width(sp.HorizontalGap).Render("") - - // Join horizontally - return lipgloss.JoinHorizontal(lipgloss.Top, leftPane, gap, rightPane) -} - -// renderVertical renders the split pane in vertical stacking mode. -func (sp *SplitPane) renderVertical() string { - // Apply border tier to both panes - leftBorder := sp.getBorder(sp.BorderTier) - rightBorder := sp.getBorder(sp.BorderTier) - - // Determine border colors based on focus - leftColor := sp.UnfocusedColor - rightColor := sp.UnfocusedColor - if sp.Focus == FocusLeft { - leftColor = sp.FocusedColor - } else { - rightColor = sp.FocusedColor - } - - // Create left pane style (full width in vertical mode) - leftStyle := lipgloss.NewStyle(). - Width(sp.Width). - Border(leftBorder). - BorderForeground(leftColor) - - if !sp.ShowBorder { - leftStyle = leftStyle.Border(lipgloss.HiddenBorder()) - } - - // Create right pane style (full width in vertical mode) - rightStyle := lipgloss.NewStyle(). - Width(sp.Width). - Border(rightBorder). - BorderForeground(rightColor) - - if !sp.ShowBorder { - rightStyle = rightStyle.Border(lipgloss.HiddenBorder()) - } - - // Render both panes - leftPane := leftStyle.Render(sp.LeftContent) - rightPane := rightStyle.Render(sp.RightContent) - - // Create vertical gap - gap := lipgloss.NewStyle().Height(sp.VerticalGap).Render("") - - // Join vertically - return lipgloss.JoinVertical(lipgloss.Left, leftPane, gap, rightPane) -} - -// getBorder returns the appropriate border based on BorderTier. -func (sp *SplitPane) getBorder(tier BorderTier) lipgloss.Border { - switch tier { - case BorderTierNone: - return lipgloss.HiddenBorder() - case BorderTierBlock: - return lipgloss.OuterHalfBlockBorder() - case BorderTierClassic: - return lipgloss.RoundedBorder() - default: - return lipgloss.RoundedBorder() - } -} - -// View implements tea.Model for direct Bubble Tea integration. -func (sp *SplitPane) View() string { - return sp.Render() -} diff --git a/pkg/ui/components/split_pane_test.go b/pkg/ui/components/split_pane_test.go deleted file mode 100644 index 036ecab..0000000 --- a/pkg/ui/components/split_pane_test.go +++ /dev/null @@ -1,926 +0,0 @@ -package components - -import ( - "strings" - "testing" - - "github.com/charmbracelet/lipgloss" -) - -// TestNewSplitPane tests the constructor creates correct defaults. -func TestNewSplitPane(t *testing.T) { - leftContent := "Left Pane" - rightContent := "Right Pane" - width := 100 - - split := NewSplitPane(leftContent, rightContent, width) - - if split == nil { - t.Fatal("NewSplitPane() returned nil") - } - if split.LeftContent != leftContent { - t.Errorf("LeftContent = %q, want %q", split.LeftContent, leftContent) - } - if split.RightContent != rightContent { - t.Errorf("RightContent = %q, want %q", split.RightContent, rightContent) - } - if split.Width != width { - t.Errorf("Width = %d, want %d", split.Width, width) - } - if split.Focus != FocusLeft { - t.Errorf("Focus = %v, want FocusLeft", split.Focus) - } - if split.Ratio != 0.3 { - t.Errorf("Ratio = %f, want 0.3", split.Ratio) - } - if split.MinLeftWidth != 24 { - t.Errorf("MinLeftWidth = %d, want 24", split.MinLeftWidth) - } - if split.MinRightWidth != 40 { - t.Errorf("MinRightWidth = %d, want 40", split.MinRightWidth) - } - if split.BorderTier != BorderTierClassic { - t.Errorf("BorderTier = %v, want BorderTierClassic", split.BorderTier) - } - if split.FocusedColor != lipgloss.Color("#00ADD8") { - t.Errorf("FocusedColor = %v, want #00ADD8", split.FocusedColor) - } - if split.UnfocusedColor != lipgloss.Color("#6272A4") { - t.Errorf("UnfocusedColor = %v, want #6272A4", split.UnfocusedColor) - } - if split.VerticalGap != 1 { - t.Errorf("VerticalGap = %d, want 1", split.VerticalGap) - } - if split.HorizontalGap != 2 { - t.Errorf("HorizontalGap = %d, want 2", split.HorizontalGap) - } - if !split.ShowBorder { - t.Error("ShowBorder = false, want true") - } -} - -// TestSplitPane_SetRatio tests the SetRatio method with valid and invalid values. -func TestSplitPane_SetRatio(t *testing.T) { - tests := []struct { - name string - input float64 - expected float64 - }{ - {"valid middle", 0.5, 0.5}, - {"valid low", 0.2, 0.2}, - {"valid high", 0.8, 0.8}, - {"zero", 0.0, 0.0}, - {"one", 1.0, 1.0}, - {"below zero clamped", -0.5, 0.0}, - {"above one clamped", 1.5, 1.0}, - {"negative large clamped", -10.0, 0.0}, - {"positive large clamped", 10.0, 1.0}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - split := NewSplitPane("Left", "Right", 100) - result := split.SetRatio(tt.input) - - if result != split { - t.Error("SetRatio() should return self for chaining") - } - if split.Ratio != tt.expected { - t.Errorf("Ratio = %f, want %f", split.Ratio, tt.expected) - } - }) - } -} - -// TestSplitPane_SetFocus tests the SetFocus method. -func TestSplitPane_SetFocus(t *testing.T) { - split := NewSplitPane("Left", "Right", 100) - - // Initially FocusLeft - if split.Focus != FocusLeft { - t.Error("Should initially have FocusLeft") - } - - // Set to FocusRight - result := split.SetFocus(FocusRight) - if result != split { - t.Error("SetFocus() should return self for chaining") - } - if split.Focus != FocusRight { - t.Error("Should have FocusRight after SetFocus(FocusRight)") - } - - // Set back to FocusLeft - split.SetFocus(FocusLeft) - if split.Focus != FocusLeft { - t.Error("Should have FocusLeft after SetFocus(FocusLeft)") - } -} - -// TestSplitPane_ToggleFocus tests focus toggling behavior. -func TestSplitPane_ToggleFocus(t *testing.T) { - split := NewSplitPane("Left", "Right", 100) - - // Initially FocusLeft - if split.Focus != FocusLeft { - t.Error("Should initially have FocusLeft") - } - - // Toggle to Right - result := split.ToggleFocus() - if result != FocusRight { - t.Errorf("ToggleFocus() returned %v, want FocusRight", result) - } - if split.Focus != FocusRight { - t.Error("Focus should be FocusRight after toggle") - } - - // Toggle back to Left - result = split.ToggleFocus() - if result != FocusLeft { - t.Errorf("ToggleFocus() returned %v, want FocusLeft", result) - } - if split.Focus != FocusLeft { - t.Error("Focus should be FocusLeft after toggle") - } - - // Toggle multiple times - split.ToggleFocus() - split.ToggleFocus() - split.ToggleFocus() - if split.Focus != FocusRight { - t.Error("Focus should be FocusRight after 3 toggles") - } -} - -// TestSplitPane_SetMinWidths tests the SetMinWidths method. -func TestSplitPane_SetMinWidths(t *testing.T) { - split := NewSplitPane("Left", "Right", 100) - - result := split.SetMinWidths(30, 50) - - if result != split { - t.Error("SetMinWidths() should return self for chaining") - } - if split.MinLeftWidth != 30 { - t.Errorf("MinLeftWidth = %d, want 30", split.MinLeftWidth) - } - if split.MinRightWidth != 50 { - t.Errorf("MinRightWidth = %d, want 50", split.MinRightWidth) - } -} - -// TestSplitPane_SetBorderTier tests border tier configuration. -func TestSplitPane_SetBorderTier(t *testing.T) { - tests := []struct { - name string - tier BorderTier - }{ - {"BorderTierNone", BorderTierNone}, - {"BorderTierBlock", BorderTierBlock}, - {"BorderTierClassic", BorderTierClassic}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - split := NewSplitPane("Left", "Right", 100) - result := split.SetBorderTier(tt.tier) - - if result != split { - t.Error("SetBorderTier() should return self for chaining") - } - if split.BorderTier != tt.tier { - t.Errorf("BorderTier = %v, want %v", split.BorderTier, tt.tier) - } - }) - } -} - -// TestSplitPane_SetColors tests color configuration. -func TestSplitPane_SetColors(t *testing.T) { - split := NewSplitPane("Left", "Right", 100) - - focusColor := lipgloss.Color("#FF0000") - unfocusColor := lipgloss.Color("#00FF00") - - result := split.SetColors(focusColor, unfocusColor) - - if result != split { - t.Error("SetColors() should return self for chaining") - } - if split.FocusedColor != focusColor { - t.Errorf("FocusedColor = %v, want %v", split.FocusedColor, focusColor) - } - if split.UnfocusedColor != unfocusColor { - t.Errorf("UnfocusedColor = %v, want %v", split.UnfocusedColor, unfocusColor) - } -} - -// TestSplitPane_GetBorder tests border selection based on tier. -func TestSplitPane_GetBorder(t *testing.T) { - tests := []struct { - name string - tier BorderTier - expectedBorder lipgloss.Border - }{ - { - name: "BorderTierNone returns HiddenBorder", - tier: BorderTierNone, - expectedBorder: lipgloss.HiddenBorder(), - }, - { - name: "BorderTierBlock returns OuterHalfBlockBorder", - tier: BorderTierBlock, - expectedBorder: lipgloss.OuterHalfBlockBorder(), - }, - { - name: "BorderTierClassic returns RoundedBorder", - tier: BorderTierClassic, - expectedBorder: lipgloss.RoundedBorder(), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - split := NewSplitPane("Left", "Right", 100) - border := split.getBorder(tt.tier) - - if border != tt.expectedBorder { - t.Errorf("getBorder(%v) returned unexpected border", tt.tier) - } - }) - } -} - -// TestSplitPane_LayoutModeHorizontal tests horizontal layout conditions. -func TestSplitPane_LayoutModeHorizontal(t *testing.T) { - tests := []struct { - name string - width int - ratio float64 - minLeftWidth int - minRightWidth int - expectHoriz bool - }{ - { - name: "wide enough for horizontal", - width: 100, - ratio: 0.3, - minLeftWidth: 24, - minRightWidth: 40, - expectHoriz: true, // leftWidth=30, rightWidth=68 - }, - { - name: "left too narrow", - width: 80, - ratio: 0.2, - minLeftWidth: 24, - minRightWidth: 40, - expectHoriz: false, // leftWidth=16 < 24 - }, - { - name: "right too narrow", - width: 80, - ratio: 0.7, - minLeftWidth: 24, - minRightWidth: 40, - expectHoriz: false, // rightWidth=22 < 40 - }, - { - name: "exactly at threshold", - width: 100, - ratio: 0.3, - minLeftWidth: 30, - minRightWidth: 68, - expectHoriz: true, // leftWidth=30, rightWidth=68 - }, - { - name: "just below left threshold", - width: 100, - ratio: 0.29, - minLeftWidth: 30, - minRightWidth: 40, - expectHoriz: false, // leftWidth=29 < 30 - }, - { - name: "just below right threshold", - width: 100, - ratio: 0.6, - minLeftWidth: 24, - minRightWidth: 40, - expectHoriz: false, // rightWidth=38 < 40 - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - split := NewSplitPane("Left", "Right", tt.width) - split.SetRatio(tt.ratio) - split.SetMinWidths(tt.minLeftWidth, tt.minRightWidth) - - // Calculate layout mode - leftWidth := int(float64(split.Width) * split.Ratio) - rightWidth := split.Width - leftWidth - split.HorizontalGap - isHorizontal := leftWidth >= split.MinLeftWidth && rightWidth >= split.MinRightWidth - - if isHorizontal != tt.expectHoriz { - t.Errorf("Expected horizontal=%v, got %v (leftWidth=%d, rightWidth=%d)", - tt.expectHoriz, isHorizontal, leftWidth, rightWidth) - } - }) - } -} - -// TestSplitPane_WidthCalculation tests width calculations from ratio. -func TestSplitPane_WidthCalculation(t *testing.T) { - tests := []struct { - name string - totalWidth int - ratio float64 - horizontalGap int - expectedLeftWidth int - expectedRightCalc int // Width - left - gap - }{ - { - name: "default ratio 0.3", - totalWidth: 100, - ratio: 0.3, - horizontalGap: 2, - expectedLeftWidth: 30, - expectedRightCalc: 68, // 100 - 30 - 2 - }, - { - name: "50-50 split", - totalWidth: 100, - ratio: 0.5, - horizontalGap: 2, - expectedLeftWidth: 50, - expectedRightCalc: 48, // 100 - 50 - 2 - }, - { - name: "70-30 split", - totalWidth: 120, - ratio: 0.7, - horizontalGap: 2, - expectedLeftWidth: 84, - expectedRightCalc: 34, // 120 - 84 - 2 - }, - { - name: "narrow width", - totalWidth: 50, - ratio: 0.4, - horizontalGap: 2, - expectedLeftWidth: 20, - expectedRightCalc: 28, // 50 - 20 - 2 - }, - { - name: "wide width", - totalWidth: 200, - ratio: 0.25, - horizontalGap: 2, - expectedLeftWidth: 50, - expectedRightCalc: 148, // 200 - 50 - 2 - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - split := NewSplitPane("Left", "Right", tt.totalWidth) - split.SetRatio(tt.ratio) - split.HorizontalGap = tt.horizontalGap - - leftWidth := int(float64(split.Width) * split.Ratio) - rightWidth := split.Width - leftWidth - split.HorizontalGap - - if leftWidth != tt.expectedLeftWidth { - t.Errorf("leftWidth = %d, want %d", leftWidth, tt.expectedLeftWidth) - } - if rightWidth != tt.expectedRightCalc { - t.Errorf("rightWidth = %d, want %d", rightWidth, tt.expectedRightCalc) - } - }) - } -} - -// TestSplitPane_Render_Horizontal tests horizontal rendering. -func TestSplitPane_Render_Horizontal(t *testing.T) { - tests := []struct { - name string - setup func() *SplitPane - wantLen bool - }{ - { - name: "default horizontal layout", - setup: func() *SplitPane { - return NewSplitPane("Left Content", "Right Content", 100) - }, - wantLen: true, - }, - { - name: "horizontal with focus right", - setup: func() *SplitPane { - return NewSplitPane("Left", "Right", 100).SetFocus(FocusRight) - }, - wantLen: true, - }, - { - name: "horizontal without borders", - setup: func() *SplitPane { - split := NewSplitPane("Left", "Right", 100) - split.ShowBorder = false - return split - }, - wantLen: true, - }, - { - name: "horizontal with block borders", - setup: func() *SplitPane { - return NewSplitPane("Left", "Right", 100).SetBorderTier(BorderTierBlock) - }, - wantLen: true, - }, - { - name: "horizontal with custom ratio", - setup: func() *SplitPane { - return NewSplitPane("Left", "Right", 120).SetRatio(0.4) - }, - wantLen: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - split := tt.setup() - output := split.Render() - - if tt.wantLen && len(output) == 0 { - t.Error("Render() returned empty string") - } - if !tt.wantLen && len(output) > 0 { - t.Error("Render() should return empty string") - } - - // Verify content is present - if tt.wantLen { - if !strings.Contains(output, "Left") { - t.Error("Output should contain left pane content") - } - if !strings.Contains(output, "Right") { - t.Error("Output should contain right pane content") - } - } - }) - } -} - -// TestSplitPane_Render_Vertical tests vertical stacking mode. -func TestSplitPane_Render_Vertical(t *testing.T) { - tests := []struct { - name string - setup func() *SplitPane - }{ - { - name: "vertical due to narrow width", - setup: func() *SplitPane { - return NewSplitPane("Top", "Bottom", 50) - }, - }, - { - name: "vertical due to left width constraint", - setup: func() *SplitPane { - split := NewSplitPane("Top", "Bottom", 80) - split.SetRatio(0.2) // Forces left pane too narrow - return split - }, - }, - { - name: "vertical due to right width constraint", - setup: func() *SplitPane { - split := NewSplitPane("Top", "Bottom", 80) - split.SetRatio(0.7) // Forces right pane too narrow - return split - }, - }, - { - name: "vertical with custom gap", - setup: func() *SplitPane { - split := NewSplitPane("Top", "Bottom", 50) - split.VerticalGap = 3 - return split - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - split := tt.setup() - output := split.Render() - - if len(output) == 0 { - t.Error("Render() returned empty string") - } - - // Verify content is present - if !strings.Contains(output, "Top") && !strings.Contains(output, "Left") { - t.Error("Output should contain first pane content") - } - if !strings.Contains(output, "Bottom") && !strings.Contains(output, "Right") { - t.Error("Output should contain second pane content") - } - }) - } -} - -// TestSplitPane_Render_FocusIndicator tests focus visualization. -func TestSplitPane_Render_FocusIndicator(t *testing.T) { - split := NewSplitPane("Left", "Right", 100) - - // Render with left focus - leftFocused := split.Render() - - // Render with right focus - split.SetFocus(FocusRight) - rightFocused := split.Render() - - // Both should render successfully - if len(leftFocused) == 0 { - t.Error("Render with left focus should produce output") - } - if len(rightFocused) == 0 { - t.Error("Render with right focus should produce output") - } - - // Content should be present in both - if !strings.Contains(leftFocused, "Left") || !strings.Contains(leftFocused, "Right") { - t.Error("Left focused render should contain both panes") - } - if !strings.Contains(rightFocused, "Left") || !strings.Contains(rightFocused, "Right") { - t.Error("Right focused render should contain both panes") - } -} - -// TestSplitPane_Render_NonEmpty tests that rendering produces output. -func TestSplitPane_Render_NonEmpty(t *testing.T) { - split := NewSplitPane("Test Left", "Test Right", 100) - output := split.Render() - - if len(output) == 0 { - t.Fatal("Render() returned empty string") - } - - // Should contain both content pieces - if !strings.Contains(output, "Test Left") { - t.Error("Render() should contain left content") - } - if !strings.Contains(output, "Test Right") { - t.Error("Render() should contain right content") - } -} - -// TestSplitPane_View tests Bubble Tea Model interface. -func TestSplitPane_View(t *testing.T) { - split := NewSplitPane("Left", "Right", 100) - - view := split.View() - render := split.Render() - - if view != render { - t.Error("View() should return same output as Render()") - } - - if len(view) == 0 { - t.Error("View() should return non-empty output") - } -} - -// TestSplitPane_ChainedOperations tests method chaining. -func TestSplitPane_ChainedOperations(t *testing.T) { - split := NewSplitPane("Left", "Right", 100). - SetRatio(0.4). - SetFocus(FocusRight). - SetBorderTier(BorderTierBlock). - SetColors(lipgloss.Color("#FF0000"), lipgloss.Color("#00FF00")). - SetMinWidths(20, 30) - - if split.Ratio != 0.4 { - t.Errorf("Ratio = %f, want 0.4", split.Ratio) - } - if split.Focus != FocusRight { - t.Error("Focus should be FocusRight") - } - if split.BorderTier != BorderTierBlock { - t.Error("BorderTier should be BorderTierBlock") - } - if split.FocusedColor != lipgloss.Color("#FF0000") { - t.Error("FocusedColor not set correctly") - } - if split.UnfocusedColor != lipgloss.Color("#00FF00") { - t.Error("UnfocusedColor not set correctly") - } - if split.MinLeftWidth != 20 { - t.Errorf("MinLeftWidth = %d, want 20", split.MinLeftWidth) - } - if split.MinRightWidth != 30 { - t.Errorf("MinRightWidth = %d, want 30", split.MinRightWidth) - } - - // Should still render - output := split.Render() - if len(output) == 0 { - t.Error("Chained split should render successfully") - } -} - -// TestSplitPane_BorderTierTransitions tests changing border tiers. -func TestSplitPane_BorderTierTransitions(t *testing.T) { - split := NewSplitPane("Left", "Right", 100) - - // Test all tier transitions - tiers := []BorderTier{BorderTierNone, BorderTierBlock, BorderTierClassic} - - for _, tier := range tiers { - split.SetBorderTier(tier) - output := split.Render() - - if len(output) == 0 { - t.Errorf("Render() with tier %v returned empty string", tier) - } - - // Verify getBorder returns correct border - border := split.getBorder(tier) - switch tier { - case BorderTierNone: - if border != lipgloss.HiddenBorder() { - t.Error("BorderTierNone should use HiddenBorder") - } - case BorderTierBlock: - if border != lipgloss.OuterHalfBlockBorder() { - t.Error("BorderTierBlock should use OuterHalfBlockBorder") - } - case BorderTierClassic: - if border != lipgloss.RoundedBorder() { - t.Error("BorderTierClassic should use RoundedBorder") - } - } - } -} - -// TestSplitPane_EmptyContent tests handling of empty content. -func TestSplitPane_EmptyContent(t *testing.T) { - tests := []struct { - name string - left string - right string - }{ - {"both empty", "", ""}, - {"left empty", "", "Right"}, - {"right empty", "Left", ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - split := NewSplitPane(tt.left, tt.right, 100) - output := split.Render() - - // Should not panic and should render something - if len(output) == 0 { - t.Error("Render() should not return empty string even with empty content") - } - }) - } -} - -// TestSplitPane_MultilineContent tests multiline content handling. -func TestSplitPane_MultilineContent(t *testing.T) { - leftContent := `Line 1 -Line 2 -Line 3` - rightContent := `Right 1 -Right 2 -Right 3 -Right 4` - - split := NewSplitPane(leftContent, rightContent, 100) - output := split.Render() - - if len(output) == 0 { - t.Fatal("Render() should handle multiline content") - } - - // Check that lines are present - if !strings.Contains(output, "Line 1") { - t.Error("Output should contain left content line 1") - } - if !strings.Contains(output, "Right 4") { - t.Error("Output should contain right content line 4") - } -} - -// TestSplitPane_ResponsiveBreakpoints tests responsive width breakpoints. -func TestSplitPane_ResponsiveBreakpoints(t *testing.T) { - tests := []struct { - name string - width int - ratio float64 - minLeftWidth int - minRightWidth int - expectVertical bool - }{ - { - name: "wide terminal - horizontal", - width: 120, - ratio: 0.3, - minLeftWidth: 24, - minRightWidth: 40, - expectVertical: false, - }, - { - name: "medium terminal - horizontal", - width: 100, - ratio: 0.3, - minLeftWidth: 24, - minRightWidth: 40, - expectVertical: false, - }, - { - name: "narrow terminal - vertical", - width: 70, - ratio: 0.3, - minLeftWidth: 24, - minRightWidth: 40, - expectVertical: true, - }, - { - name: "very narrow - vertical", - width: 50, - ratio: 0.3, - minLeftWidth: 24, - minRightWidth: 40, - expectVertical: true, - }, - { - name: "exactly at threshold", - width: 66, // 66 * 0.3 = 19.8 (rounds to 19), right = 66 - 19 - 2 = 45 - ratio: 0.3, - minLeftWidth: 20, - minRightWidth: 40, - expectVertical: true, // 19 < 20 - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - split := NewSplitPane("Left", "Right", tt.width) - split.SetRatio(tt.ratio) - split.SetMinWidths(tt.minLeftWidth, tt.minRightWidth) - - // Calculate what mode should be used - leftWidth := int(float64(split.Width) * split.Ratio) - rightWidth := split.Width - leftWidth - split.HorizontalGap - shouldBeVertical := leftWidth < split.MinLeftWidth || rightWidth < split.MinRightWidth - - if shouldBeVertical != tt.expectVertical { - t.Errorf("Expected vertical=%v, got %v (leftWidth=%d, rightWidth=%d, minLeft=%d, minRight=%d)", - tt.expectVertical, shouldBeVertical, leftWidth, rightWidth, - split.MinLeftWidth, split.MinRightWidth) - } - - // Verify rendering works - output := split.Render() - if len(output) == 0 { - t.Error("Render() should produce output") - } - }) - } -} - -// TestSplitPane_EdgeCases tests edge cases. -func TestSplitPane_EdgeCases(t *testing.T) { - tests := []struct { - name string - setup func() *SplitPane - }{ - { - name: "zero width", - setup: func() *SplitPane { - return NewSplitPane("Left", "Right", 0) - }, - }, - { - name: "very small width", - setup: func() *SplitPane { - return NewSplitPane("Left", "Right", 10) - }, - }, - { - name: "very large width", - setup: func() *SplitPane { - return NewSplitPane("Left", "Right", 1000) - }, - }, - { - name: "ratio at edge 0.0", - setup: func() *SplitPane { - return NewSplitPane("Left", "Right", 100).SetRatio(0.0) - }, - }, - { - name: "ratio at edge 1.0", - setup: func() *SplitPane { - return NewSplitPane("Left", "Right", 100).SetRatio(1.0) - }, - }, - { - name: "very long content", - setup: func() *SplitPane { - longContent := strings.Repeat("A", 500) - return NewSplitPane(longContent, longContent, 100) - }, - }, - { - name: "zero gap", - setup: func() *SplitPane { - split := NewSplitPane("Left", "Right", 100) - split.HorizontalGap = 0 - split.VerticalGap = 0 - return split - }, - }, - { - name: "large gap", - setup: func() *SplitPane { - split := NewSplitPane("Left", "Right", 100) - split.HorizontalGap = 20 - split.VerticalGap = 10 - return split - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - split := tt.setup() - // Should not panic - output := split.Render() - // Should produce some output (even if degenerate) - _ = output - }) - } -} - -// TestPaneFocus_Constants tests PaneFocus constant values. -func TestPaneFocus_Constants(t *testing.T) { - if FocusLeft != 0 { - t.Errorf("FocusLeft = %d, want 0", FocusLeft) - } - if FocusRight != 1 { - t.Errorf("FocusRight = %d, want 1", FocusRight) - } -} - -// TestSplitPane_ANSIWidthHandling tests ANSI escape code handling. -func TestSplitPane_ANSIWidthHandling(t *testing.T) { - // Content with ANSI color codes - styledLeft := "\x1b[31mRed Text\x1b[0m" - styledRight := "\x1b[32mGreen Text\x1b[0m" - - split := NewSplitPane(styledLeft, styledRight, 100) - output := split.Render() - - if len(output) == 0 { - t.Error("Render() should handle ANSI codes without error") - } - - // Verify styled content is present - if !strings.Contains(output, "Red Text") { - t.Error("Output should contain styled left content") - } - if !strings.Contains(output, "Green Text") { - t.Error("Output should contain styled right content") - } -} - -// TestSplitPane_LipglossStyledContent tests pre-styled lipgloss content. -func TestSplitPane_LipglossStyledContent(t *testing.T) { - // Pre-styled content using lipgloss - leftStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")).Bold(true) - rightStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF00")).Italic(true) - - leftContent := leftStyle.Render("Styled Left") - rightContent := rightStyle.Render("Styled Right") - - split := NewSplitPane(leftContent, rightContent, 100) - output := split.Render() - - if len(output) == 0 { - t.Error("Render() should handle lipgloss styled content") - } - - // Verify content is present - if !strings.Contains(output, "Styled Left") { - t.Error("Output should contain left content") - } - if !strings.Contains(output, "Styled Right") { - t.Error("Output should contain right content") - } -} diff --git a/pkg/ui/components/splitpane/splitpane.go b/pkg/ui/components/splitpane/splitpane.go deleted file mode 100644 index bc40fcf..0000000 --- a/pkg/ui/components/splitpane/splitpane.go +++ /dev/null @@ -1,206 +0,0 @@ -package splitpane - -import ( - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// Orientation defines the split direction for the pane. -type Orientation int - -const ( - // Horizontal creates a left/right split. - Horizontal Orientation = iota - // Vertical creates a top/bottom split. - Vertical -) - -// SplitPane displays two panels side-by-side (horizontal) or stacked (vertical). -// Used throughout views to create multi-panel layouts with configurable split ratios. -// -// Design: 017-ui-engine Phase 3 (SplitPane Component) -type SplitPane struct { - orientation Orientation - ratio float64 // 0.0 to 1.0 (e.g., 0.3 = 30% for first pane) - theme *themes.Theme -} - -// NewSplitPane creates a new SplitPane with the given orientation, ratio, and theme. -// The ratio determines the size of the first pane (0.0-1.0). -// For Horizontal orientation: ratio controls left pane width. -// For Vertical orientation: ratio controls top pane height. -func NewSplitPane(orientation Orientation, ratio float64, theme *themes.Theme) *SplitPane { - // Clamp ratio to valid range - if ratio < 0.0 { - ratio = 0.0 - } - if ratio > 1.0 { - ratio = 1.0 - } - - return &SplitPane{ - orientation: orientation, - ratio: ratio, - theme: theme, - } -} - -// Render returns the split pane layout as a string for the given dimensions and content. -// For Horizontal orientation: -// - width and height define the total available space -// - firstContent is rendered in the left pane -// - secondContent is rendered in the right pane -// -// For Vertical orientation: -// - width and height define the total available space -// - firstContent is rendered in the top pane -// - secondContent is rendered in the bottom pane -// -// Each pane is bordered using the theme's border color. -func (sp *SplitPane) Render(width, height int, firstContent, secondContent string) string { - if sp.theme == nil { - // Fallback to simple concatenation without styling - if sp.orientation == Horizontal { - return firstContent + " | " + secondContent - } - return firstContent + "\n" + secondContent - } - - // Get border color from theme - borderColor := sp.theme.Colors.BorderColor() - - if sp.orientation == Horizontal { - return sp.renderHorizontal(width, height, firstContent, secondContent, borderColor) - } - return sp.renderVertical(width, height, firstContent, secondContent, borderColor) -} - -// renderHorizontal creates a left/right split layout. -func (sp *SplitPane) renderHorizontal(width, height int, leftContent, rightContent string, borderColor lipgloss.Color) string { - // Calculate widths for each pane - // Account for borders (2 chars per side = 4 total per pane) - totalBorderWidth := 8 // 4 for left pane + 4 for right pane - availableWidth := width - totalBorderWidth - - if availableWidth < 2 { - // Not enough space for both panes, return empty - return "" - } - - leftWidth := int(float64(availableWidth) * sp.ratio) - rightWidth := availableWidth - leftWidth - - // Ensure minimum widths - if leftWidth < 1 { - leftWidth = 1 - } - if rightWidth < 1 { - rightWidth = 1 - } - - // Create styled panes with borders - leftStyle := lipgloss.NewStyle(). - Width(leftWidth). - Height(height). - Border(lipgloss.RoundedBorder()). - BorderForeground(borderColor). - Padding(0, 1) - - rightStyle := lipgloss.NewStyle(). - Width(rightWidth). - Height(height). - Border(lipgloss.RoundedBorder()). - BorderForeground(borderColor). - Padding(0, 1) - - leftPane := leftStyle.Render(leftContent) - rightPane := rightStyle.Render(rightContent) - - // Join horizontally - return lipgloss.JoinHorizontal(lipgloss.Top, leftPane, rightPane) -} - -// renderVertical creates a top/bottom split layout. -func (sp *SplitPane) renderVertical(width, height int, topContent, bottomContent string, borderColor lipgloss.Color) string { - // Calculate heights for each pane - // Account for borders (2 lines per pane = 4 total) - totalBorderHeight := 4 // 2 for top pane + 2 for bottom pane - availableHeight := height - totalBorderHeight - - if availableHeight < 2 { - // Not enough space for both panes, return empty - return "" - } - - topHeight := int(float64(availableHeight) * sp.ratio) - bottomHeight := availableHeight - topHeight - - // Ensure minimum heights - if topHeight < 1 { - topHeight = 1 - } - if bottomHeight < 1 { - bottomHeight = 1 - } - - // Account for borders (2 chars per side = 4 total) - paneWidth := width - 4 - if paneWidth < 1 { - paneWidth = 1 - } - - // Create styled panes with borders - topStyle := lipgloss.NewStyle(). - Width(paneWidth). - Height(topHeight). - Border(lipgloss.RoundedBorder()). - BorderForeground(borderColor). - Padding(0, 1) - - bottomStyle := lipgloss.NewStyle(). - Width(paneWidth). - Height(bottomHeight). - Border(lipgloss.RoundedBorder()). - BorderForeground(borderColor). - Padding(0, 1) - - topPane := topStyle.Render(topContent) - bottomPane := bottomStyle.Render(bottomContent) - - // Join vertically - return lipgloss.JoinVertical(lipgloss.Left, topPane, bottomPane) -} - -// SetTheme updates the theme for the split pane. -// This allows dynamic theme switching without recreating the component. -func (sp *SplitPane) SetTheme(theme *themes.Theme) { - sp.theme = theme -} - -// SetRatio updates the split ratio. -// The ratio is clamped to the valid range [0.0, 1.0]. -func (sp *SplitPane) SetRatio(ratio float64) { - if ratio < 0.0 { - ratio = 0.0 - } - if ratio > 1.0 { - ratio = 1.0 - } - sp.ratio = ratio -} - -// SetOrientation updates the split orientation. -func (sp *SplitPane) SetOrientation(orientation Orientation) { - sp.orientation = orientation -} - -// Orientation returns the current split orientation. -func (sp *SplitPane) Orientation() Orientation { - return sp.orientation -} - -// Ratio returns the current split ratio. -func (sp *SplitPane) Ratio() float64 { - return sp.ratio -} diff --git a/pkg/ui/components/splitpane/splitpane_test.go b/pkg/ui/components/splitpane/splitpane_test.go deleted file mode 100644 index c8078ba..0000000 --- a/pkg/ui/components/splitpane/splitpane_test.go +++ /dev/null @@ -1,752 +0,0 @@ -package splitpane - -import ( - "strings" - "testing" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// mockTheme returns a test theme with basic colors. -func mockTheme() *themes.Theme { - return &themes.Theme{ - Name: "Test Theme", - Description: "A test theme", - Version: "1.0.0", - Colors: themes.ColorSet{ - Primary: "#4A90E2", - Secondary: "#7B68EE", - Success: "#28a745", - Error: "#dc3545", - Warning: "#ffc107", - Info: "#17a2b8", - Border: "#6272A4", - }, - } -} - -func TestNewSplitPane(t *testing.T) { - theme := mockTheme() - - tests := []struct { - name string - orientation Orientation - ratio float64 - expectRatio float64 // Expected after clamping - }{ - { - name: "horizontal with valid ratio", - orientation: Horizontal, - ratio: 0.3, - expectRatio: 0.3, - }, - { - name: "vertical with valid ratio", - orientation: Vertical, - ratio: 0.5, - expectRatio: 0.5, - }, - { - name: "ratio below minimum is clamped to 0.0", - orientation: Horizontal, - ratio: -0.5, - expectRatio: 0.0, - }, - { - name: "ratio above maximum is clamped to 1.0", - orientation: Horizontal, - ratio: 1.5, - expectRatio: 1.0, - }, - { - name: "ratio at minimum boundary", - orientation: Horizontal, - ratio: 0.0, - expectRatio: 0.0, - }, - { - name: "ratio at maximum boundary", - orientation: Horizontal, - ratio: 1.0, - expectRatio: 1.0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sp := NewSplitPane(tt.orientation, tt.ratio, theme) - - if sp == nil { - t.Fatal("NewSplitPane returned nil") - } - - if sp.orientation != tt.orientation { - t.Errorf("expected orientation %v, got %v", tt.orientation, sp.orientation) - } - - if sp.ratio != tt.expectRatio { - t.Errorf("expected ratio %f, got %f", tt.expectRatio, sp.ratio) - } - - if sp.theme != theme { - t.Errorf("expected theme %v, got %v", theme, sp.theme) - } - }) - } -} - -func TestSplitPane_RenderHorizontal(t *testing.T) { - theme := mockTheme() - - tests := []struct { - name string - width int - height int - ratio float64 - leftContent string - rightContent string - validate func(t *testing.T, output string) - }{ - { - name: "renders with basic content", - width: 80, - height: 10, - ratio: 0.3, - leftContent: "Left Panel", - rightContent: "Right Panel", - validate: func(t *testing.T, output string) { - if output == "" { - t.Error("expected non-empty output") - } - if !strings.Contains(output, "Left Panel") { - t.Error("output should contain left content") - } - if !strings.Contains(output, "Right Panel") { - t.Error("output should contain right content") - } - }, - }, - { - name: "renders with 50/50 split", - width: 80, - height: 10, - ratio: 0.5, - leftContent: "Equal", - rightContent: "Split", - validate: func(t *testing.T, output string) { - if !strings.Contains(output, "Equal") { - t.Error("output should contain left content") - } - if !strings.Contains(output, "Split") { - t.Error("output should contain right content") - } - }, - }, - { - name: "renders with small left pane", - width: 80, - height: 10, - ratio: 0.2, - leftContent: "Small", - rightContent: "Large", - validate: func(t *testing.T, output string) { - if !strings.Contains(output, "Small") { - t.Error("output should contain left content") - } - if !strings.Contains(output, "Large") { - t.Error("output should contain right content") - } - }, - }, - { - name: "renders with large left pane", - width: 80, - height: 10, - ratio: 0.8, - leftContent: "Large", - rightContent: "Small", - validate: func(t *testing.T, output string) { - if !strings.Contains(output, "Large") { - t.Error("output should contain left content") - } - if !strings.Contains(output, "Small") { - t.Error("output should contain right content") - } - }, - }, - { - name: "handles empty content", - width: 80, - height: 10, - ratio: 0.3, - leftContent: "", - rightContent: "", - validate: func(t *testing.T, output string) { - // Should render successfully even with empty content - if output == "" { - t.Error("expected some output even for empty content (borders)") - } - }, - }, - { - name: "handles multi-line content", - width: 80, - height: 10, - ratio: 0.3, - leftContent: "Line 1\nLine 2\nLine 3", - rightContent: "Item A\nItem B\nItem C", - validate: func(t *testing.T, output string) { - if !strings.Contains(output, "Line 1") { - t.Error("output should contain multi-line left content") - } - if !strings.Contains(output, "Item A") { - t.Error("output should contain multi-line right content") - } - }, - }, - { - name: "handles very small width", - width: 20, - height: 5, - ratio: 0.3, - leftContent: "L", - rightContent: "R", - validate: func(t *testing.T, output string) { - // Should handle gracefully - // May return empty if too small for borders - if output != "" && !strings.Contains(output, "L") && !strings.Contains(output, "R") { - t.Error("if rendered, should contain content") - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sp := NewSplitPane(Horizontal, tt.ratio, theme) - output := sp.Render(tt.width, tt.height, tt.leftContent, tt.rightContent) - tt.validate(t, output) - }) - } -} - -func TestSplitPane_RenderVertical(t *testing.T) { - theme := mockTheme() - - tests := []struct { - name string - width int - height int - ratio float64 - topContent string - bottomContent string - validate func(t *testing.T, output string) - }{ - { - name: "renders with basic content", - width: 80, - height: 20, - ratio: 0.3, - topContent: "Top Panel", - bottomContent: "Bottom Panel", - validate: func(t *testing.T, output string) { - if output == "" { - t.Error("expected non-empty output") - } - if !strings.Contains(output, "Top Panel") { - t.Error("output should contain top content") - } - if !strings.Contains(output, "Bottom Panel") { - t.Error("output should contain bottom content") - } - }, - }, - { - name: "renders with 50/50 split", - width: 80, - height: 20, - ratio: 0.5, - topContent: "Equal", - bottomContent: "Split", - validate: func(t *testing.T, output string) { - if !strings.Contains(output, "Equal") { - t.Error("output should contain top content") - } - if !strings.Contains(output, "Split") { - t.Error("output should contain bottom content") - } - }, - }, - { - name: "renders with small top pane", - width: 80, - height: 20, - ratio: 0.2, - topContent: "Small", - bottomContent: "Large", - validate: func(t *testing.T, output string) { - if !strings.Contains(output, "Small") { - t.Error("output should contain top content") - } - if !strings.Contains(output, "Large") { - t.Error("output should contain bottom content") - } - }, - }, - { - name: "renders with large top pane", - width: 80, - height: 20, - ratio: 0.8, - topContent: "Large", - bottomContent: "Small", - validate: func(t *testing.T, output string) { - if !strings.Contains(output, "Large") { - t.Error("output should contain top content") - } - if !strings.Contains(output, "Small") { - t.Error("output should contain bottom content") - } - }, - }, - { - name: "handles empty content", - width: 80, - height: 20, - ratio: 0.3, - topContent: "", - bottomContent: "", - validate: func(t *testing.T, output string) { - // Should render successfully even with empty content - if output == "" { - t.Error("expected some output even for empty content (borders)") - } - }, - }, - { - name: "handles very small height", - width: 80, - height: 8, - ratio: 0.3, - topContent: "T", - bottomContent: "B", - validate: func(t *testing.T, output string) { - // Should handle gracefully - // May return empty if too small for borders - if output != "" && !strings.Contains(output, "T") && !strings.Contains(output, "B") { - t.Error("if rendered, should contain content") - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sp := NewSplitPane(Vertical, tt.ratio, theme) - output := sp.Render(tt.width, tt.height, tt.topContent, tt.bottomContent) - tt.validate(t, output) - }) - } -} - -func TestSplitPane_SetTheme(t *testing.T) { - sp := NewSplitPane(Horizontal, 0.3, mockTheme()) - - newTheme := &themes.Theme{ - Name: "New Theme", - Colors: themes.ColorSet{ - Border: "#FF0000", - }, - } - - sp.SetTheme(newTheme) - - if sp.theme != newTheme { - t.Errorf("SetTheme did not update theme: got %v, want %v", sp.theme, newTheme) - } - - // Verify rendering works with new theme - output := sp.Render(80, 10, "Left", "Right") - if output == "" { - t.Error("expected non-empty output after SetTheme") - } -} - -func TestSplitPane_SetRatio(t *testing.T) { - sp := NewSplitPane(Horizontal, 0.3, mockTheme()) - - tests := []struct { - name string - setRatio float64 - expectRatio float64 - }{ - { - name: "updates to valid ratio", - setRatio: 0.5, - expectRatio: 0.5, - }, - { - name: "clamps negative ratio to 0.0", - setRatio: -0.2, - expectRatio: 0.0, - }, - { - name: "clamps ratio above 1.0 to 1.0", - setRatio: 1.5, - expectRatio: 1.0, - }, - { - name: "accepts minimum ratio", - setRatio: 0.0, - expectRatio: 0.0, - }, - { - name: "accepts maximum ratio", - setRatio: 1.0, - expectRatio: 1.0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sp.SetRatio(tt.setRatio) - - if sp.ratio != tt.expectRatio { - t.Errorf("expected ratio %f, got %f", tt.expectRatio, sp.ratio) - } - - // Verify rendering still works - output := sp.Render(80, 10, "Left", "Right") - if output == "" && tt.expectRatio > 0.0 && tt.expectRatio < 1.0 { - t.Error("expected non-empty output after SetRatio") - } - }) - } -} - -func TestSplitPane_SetOrientation(t *testing.T) { - sp := NewSplitPane(Horizontal, 0.3, mockTheme()) - - // Initial orientation - if sp.Orientation() != Horizontal { - t.Errorf("expected initial orientation Horizontal, got %v", sp.Orientation()) - } - - // Change to Vertical - sp.SetOrientation(Vertical) - - if sp.orientation != Vertical { - t.Errorf("expected orientation Vertical, got %v", sp.orientation) - } - - if sp.Orientation() != Vertical { - t.Errorf("Orientation() should return Vertical, got %v", sp.Orientation()) - } - - // Verify rendering works with new orientation - output := sp.Render(80, 20, "Top", "Bottom") - if output == "" { - t.Error("expected non-empty output after SetOrientation") - } - - // Change back to Horizontal - sp.SetOrientation(Horizontal) - - if sp.orientation != Horizontal { - t.Errorf("expected orientation Horizontal, got %v", sp.orientation) - } -} - -func TestSplitPane_Ratio(t *testing.T) { - tests := []struct { - name string - ratio float64 - }{ - { - name: "returns initial ratio", - ratio: 0.3, - }, - { - name: "returns updated ratio", - ratio: 0.7, - }, - { - name: "returns clamped ratio", - ratio: -0.5, // Will be clamped to 0.0 - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sp := NewSplitPane(Horizontal, tt.ratio, mockTheme()) - - ratio := sp.Ratio() - - // Check that ratio is within valid bounds - if ratio < 0.0 || ratio > 1.0 { - t.Errorf("Ratio() returned invalid value: %f", ratio) - } - - // Verify it matches the clamped ratio - if tt.ratio < 0.0 && ratio != 0.0 { - t.Errorf("expected clamped ratio 0.0, got %f", ratio) - } - if tt.ratio > 1.0 && ratio != 1.0 { - t.Errorf("expected clamped ratio 1.0, got %f", ratio) - } - }) - } -} - -func TestSplitPane_NilTheme(t *testing.T) { - sp := NewSplitPane(Horizontal, 0.3, nil) - - // Should not panic with nil theme - output := sp.Render(80, 10, "Left", "Right") - - // Should return fallback rendering - if output == "" { - t.Error("expected fallback rendering for nil theme") - } - - // Fallback should contain basic content - if !strings.Contains(output, "Left") || !strings.Contains(output, "Right") { - t.Error("fallback rendering should contain content") - } -} - -func TestSplitPane_DynamicUpdates(t *testing.T) { - // Test that split pane can be updated dynamically - sp := NewSplitPane(Horizontal, 0.3, mockTheme()) - - // Initial rendering - output := sp.Render(80, 10, "Left", "Right") - if !strings.Contains(output, "Left") { - t.Error("initial rendering failed") - } - - // Update ratio - sp.SetRatio(0.5) - output = sp.Render(80, 10, "Left", "Right") - if !strings.Contains(output, "Left") { - t.Error("ratio update affected rendering") - } - - // Update orientation - sp.SetOrientation(Vertical) - output = sp.Render(80, 20, "Top", "Bottom") - if !strings.Contains(output, "Top") { - t.Error("orientation update affected rendering") - } - - // Update theme - newTheme := &themes.Theme{ - Colors: themes.ColorSet{ - Border: "#00FF00", - }, - } - sp.SetTheme(newTheme) - output = sp.Render(80, 20, "Top", "Bottom") - if !strings.Contains(output, "Top") { - t.Error("theme update affected rendering") - } -} - -func TestSplitPane_EdgeCases(t *testing.T) { - theme := mockTheme() - - tests := []struct { - name string - orientation Orientation - width int - height int - ratio float64 - validate func(t *testing.T, output string) - }{ - { - name: "zero width horizontal", - orientation: Horizontal, - width: 0, - height: 10, - ratio: 0.3, - validate: func(t *testing.T, output string) { - // Should handle gracefully - if output != "" { - // If it renders, it should be minimal - _ = output - } - }, - }, - { - name: "zero height vertical", - orientation: Vertical, - width: 80, - height: 0, - ratio: 0.3, - validate: func(t *testing.T, output string) { - // Should handle gracefully - if output != "" { - // If it renders, it should be minimal - _ = output - } - }, - }, - { - name: "ratio exactly 0.0", - orientation: Horizontal, - width: 80, - height: 10, - ratio: 0.0, - validate: func(t *testing.T, output string) { - // First pane should have minimal or zero width - // Should not panic - _ = output - }, - }, - { - name: "ratio exactly 1.0", - orientation: Horizontal, - width: 80, - height: 10, - ratio: 1.0, - validate: func(t *testing.T, output string) { - // Second pane should have minimal or zero width - // Should not panic - _ = output - }, - }, - { - name: "very large dimensions", - orientation: Horizontal, - width: 500, - height: 100, - ratio: 0.3, - validate: func(t *testing.T, output string) { - if output == "" { - t.Error("should handle large dimensions") - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sp := NewSplitPane(tt.orientation, tt.ratio, theme) - output := sp.Render(tt.width, tt.height, "First", "Second") - tt.validate(t, output) - }) - } -} - -func TestSplitPane_OrientationCoverage(t *testing.T) { - // Ensure both orientations are tested - theme := mockTheme() - - orientations := []struct { - orientation Orientation - name string - }{ - {Horizontal, "Horizontal"}, - {Vertical, "Vertical"}, - } - - for _, o := range orientations { - t.Run(o.name, func(t *testing.T) { - sp := NewSplitPane(o.orientation, 0.3, theme) - - // Verify orientation is set - if sp.orientation != o.orientation { - t.Errorf("expected orientation %v, got %v", o.orientation, sp.orientation) - } - - // Verify it renders - var output string - if o.orientation == Horizontal { - output = sp.Render(80, 10, "First", "Second") - } else { - output = sp.Render(80, 20, "First", "Second") - } - - if output == "" { - t.Errorf("orientation %s produced empty output", o.name) - } - - // Verify content is present - if !strings.Contains(output, "First") { - t.Errorf("orientation %s did not render first content", o.name) - } - if !strings.Contains(output, "Second") { - t.Errorf("orientation %s did not render second content", o.name) - } - }) - } -} - -func TestSplitPane_SpecialCharacters(t *testing.T) { - theme := mockTheme() - sp := NewSplitPane(Horizontal, 0.3, theme) - - tests := []struct { - name string - content string - }{ - { - name: "unicode characters", - content: "Hello 世界", - }, - { - name: "emojis", - content: "🎉 Party 🎊", - }, - { - name: "special symbols", - content: "Test & Special ", - }, - { - name: "ANSI escape codes", - content: "\x1b[31mRed Text\x1b[0m", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - output := sp.Render(80, 10, tt.content, "Normal") - - if output == "" { - t.Error("expected non-empty output") - } - - // Should not panic and should include content - // (Note: ANSI codes may be processed by lipgloss) - if !strings.Contains(output, "Normal") { - t.Error("special characters affected rendering of other content") - } - }) - } -} - -func TestSplitPane_RatioVariations(t *testing.T) { - theme := mockTheme() - - ratios := []float64{0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9} - - for _, ratio := range ratios { - t.Run(string(rune(int(ratio*10))), func(t *testing.T) { - sp := NewSplitPane(Horizontal, ratio, theme) - - output := sp.Render(100, 10, "Left", "Right") - - if output == "" { - t.Errorf("ratio %f produced empty output", ratio) - } - - // Verify both panes are rendered - if !strings.Contains(output, "Left") { - t.Errorf("ratio %f did not render left content", ratio) - } - if !strings.Contains(output, "Right") { - t.Errorf("ratio %f did not render right content", ratio) - } - }) - } -} diff --git a/pkg/ui/components/status/.gitkeep b/pkg/ui/components/status/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/pkg/ui/components/status/statusbar.go b/pkg/ui/components/status/statusbar.go deleted file mode 100644 index d684282..0000000 --- a/pkg/ui/components/status/statusbar.go +++ /dev/null @@ -1,193 +0,0 @@ -package status - -import ( - "strings" - - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -const ellipsis = "..." - -// StatusBar displays keyboard shortcuts and context messages at the bottom of views. -// -// Design: 017-ui-engine Phase 3 -// The StatusBar shows available keybindings on the left and an optional context -// message on the right, using profile-themed muted/secondary colors. -// -// Example: -// -// bindings := []engine.KeyBinding{ -// {Key: "q", Description: "quit"}, -// {Key: "↑/↓", Description: "navigate"}, -// {Key: "enter", Description: "select"}, -// } -// bar := NewStatusBar(theme) -// rendered := bar.Render(80, bindings, "Ready") -// // Output: "q: quit • ↑/↓: navigate • enter: select Ready" -type StatusBar struct { - theme *themes.Theme -} - -// NewStatusBar creates a new StatusBar with the given theme. -func NewStatusBar(theme *themes.Theme) *StatusBar { - return &StatusBar{ - theme: theme, - } -} - -// SetTheme updates the theme for the status bar. -func (s *StatusBar) SetTheme(theme *themes.Theme) { - s.theme = theme -} - -// Render returns the status bar as a styled string spanning the given width. -// -// Parameters: -// - width: Total width available for the status bar -// - keybindings: Keyboard shortcuts to display (left side) -// - message: Optional context message to display (right side) -// -// Layout: "key: desc • key: desc • key: desc message" -func (s *StatusBar) Render(width int, keybindings []engine.KeyBinding, message string) string { - if width <= 0 { - return "" - } - - // Format keybindings (left side) - bindingsText := s.formatKeybindings(keybindings) - - // Format message (right side) - messageText := message - - // Build the result with appropriate spacing - result := s.layoutContent(keybindings, bindingsText, messageText, lipgloss.Width(bindingsText), lipgloss.Width(messageText), width) - - // Ensure result is exactly width characters (pad if needed) - resultWidth := lipgloss.Width(result) - if resultWidth < width { - result += strings.Repeat(" ", width-resultWidth) - } - - // Style with muted color - style := lipgloss.NewStyle(). - Foreground(s.theme.Colors.MutedColor()) - - return style.Render(result) -} - -// layoutContent arranges bindings and message text into a fixed-width status bar. -func (s *StatusBar) layoutContent(keybindings []engine.KeyBinding, bindingsText, messageText string, bindingsWidth, messageWidth, width int) string { - totalContentWidth := bindingsWidth + messageWidth - if totalContentWidth < width { - // Content fits - add spacing between bindings and message - spacing := width - totalContentWidth - return bindingsText + strings.Repeat(" ", spacing) + messageText - } - if messageWidth > 0 { - return s.layoutWithMessage(keybindings, messageText, messageWidth, width) - } - // No message - just truncate bindings if needed - if bindingsWidth > width { - return s.truncateKeybindings(keybindings, width) - } - return bindingsText -} - -// layoutWithMessage handles layout when a message is present and content is too wide. -func (s *StatusBar) layoutWithMessage(keybindings []engine.KeyBinding, messageText string, messageWidth, width int) string { - maxBindingsWidth := width - messageWidth - 1 // Reserve 1 space - if maxBindingsWidth <= 0 { - // Terminal too narrow - show message only - if messageWidth <= width { - return messageText - } - return s.truncateText(messageText, width) - } - truncatedBindings := s.truncateKeybindings(keybindings, maxBindingsWidth) - truncatedWidth := lipgloss.Width(truncatedBindings) - spacing := width - truncatedWidth - messageWidth - if spacing < 0 { - spacing = 0 - } - return truncatedBindings + strings.Repeat(" ", spacing) + messageText -} - -// formatKeybindings formats keybindings as "key: desc • key: desc • key: desc" -func (s *StatusBar) formatKeybindings(keybindings []engine.KeyBinding) string { - if len(keybindings) == 0 { - return "" - } - - parts := make([]string, 0, len(keybindings)) - for _, kb := range keybindings { - parts = append(parts, kb.Key+": "+kb.Description) - } - - return strings.Join(parts, " • ") -} - -// truncateKeybindings truncates the keybinding list to fit within maxWidth. -// Prioritizes showing as many complete keybindings as possible. -func (s *StatusBar) truncateKeybindings(keybindings []engine.KeyBinding, maxWidth int) string { - if len(keybindings) == 0 { - return "" - } - - parts := make([]string, 0, len(keybindings)) - currentWidth := 0 - separator := " • " - separatorWidth := len(separator) - - for i, kb := range keybindings { - part := kb.Key + ": " + kb.Description - partWidth := lipgloss.Width(part) - - // Add separator width if not first item - if i > 0 { - partWidth += separatorWidth - } - - if currentWidth+partWidth > maxWidth { - // Can't fit this keybinding, stop here - break - } - - parts = append(parts, part) - currentWidth += partWidth - } - - if len(parts) == 0 { - return ellipsis - } - - result := strings.Join(parts, separator) - - // Add ellipsis if we truncated - if len(parts) < len(keybindings) { - ellipsis := " ..." - if currentWidth+lipgloss.Width(ellipsis) <= maxWidth { - result += ellipsis - } - } - - return result -} - -// truncateText truncates text to fit within maxWidth, adding ellipsis if needed. -func (s *StatusBar) truncateText(text string, maxWidth int) string { - textWidth := lipgloss.Width(text) - if textWidth <= maxWidth { - return text - } - - if maxWidth <= 3 { - return ellipsis - } - - // Truncate and add ellipsis - // This is a simple approach - for ANSI strings, would need more sophisticated handling - return text[:maxWidth-3] + ellipsis -} diff --git a/pkg/ui/components/status/statusbar_test.go b/pkg/ui/components/status/statusbar_test.go deleted file mode 100644 index 088f473..0000000 --- a/pkg/ui/components/status/statusbar_test.go +++ /dev/null @@ -1,338 +0,0 @@ -package status - -import ( - "strings" - "testing" - - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// mockTheme creates a minimal theme for testing -func mockTheme() *themes.Theme { - return &themes.Theme{ - Name: "test-theme", - Colors: themes.ColorSet{ - Primary: "#00ADD8", - Secondary: "#5DC9E2", - Muted: "#888888", - }, - } -} - -func TestNewStatusBar(t *testing.T) { - theme := mockTheme() - bar := NewStatusBar(theme) - - if bar == nil { - t.Fatal("NewStatusBar returned nil") - } - - if bar.theme != theme { - t.Error("StatusBar theme not set correctly") - } -} - -func TestSetTheme(t *testing.T) { - bar := NewStatusBar(mockTheme()) - newTheme := &themes.Theme{ - Name: "new-theme", - Colors: themes.ColorSet{ - Muted: "#999999", - }, - } - - bar.SetTheme(newTheme) - - if bar.theme != newTheme { - t.Error("SetTheme did not update theme") - } -} - -func TestRender_EmptyKeybindings(t *testing.T) { - bar := NewStatusBar(mockTheme()) - result := bar.Render(80, []engine.KeyBinding{}, "") - - // Should return styled string with correct width - width := lipgloss.Width(result) - if width != 80 { - t.Errorf("Expected width 80, got %d", width) - } -} - -func TestRender_BasicKeybindings(t *testing.T) { - bar := NewStatusBar(mockTheme()) - bindings := []engine.KeyBinding{ - {Key: "q", Description: "quit"}, - {Key: "↑/↓", Description: "navigate"}, - {Key: "enter", Description: "select"}, - } - - result := bar.Render(80, bindings, "") - stripped := stripANSI(result) - - // Check that keybindings are formatted correctly - if !strings.Contains(stripped, "q: quit") { - t.Error("Missing 'q: quit' in output") - } - if !strings.Contains(stripped, "↑/↓: navigate") { - t.Error("Missing '↑/↓: navigate' in output") - } - if !strings.Contains(stripped, "enter: select") { - t.Error("Missing 'enter: select' in output") - } - if !strings.Contains(stripped, " • ") { - t.Error("Missing bullet separator in output") - } - - // Check total width - if lipgloss.Width(result) != 80 { - t.Errorf("Expected width 80, got %d", lipgloss.Width(result)) - } -} - -func TestRender_WithMessage(t *testing.T) { - bar := NewStatusBar(mockTheme()) - bindings := []engine.KeyBinding{ - {Key: "q", Description: "quit"}, - } - - result := bar.Render(80, bindings, "Ready") - stripped := stripANSI(result) - - // Check that both bindings and message are present - if !strings.Contains(stripped, "q: quit") { - t.Error("Missing 'q: quit' in output") - } - if !strings.HasSuffix(strings.TrimSpace(stripped), "Ready") { - t.Error("Message not at right side of output") - } -} - -func TestRender_Truncation(t *testing.T) { - bar := NewStatusBar(mockTheme()) - bindings := []engine.KeyBinding{ - {Key: "q", Description: "quit"}, - {Key: "↑/↓", Description: "navigate"}, - {Key: "enter", Description: "select"}, - {Key: "tab", Description: "switch"}, - {Key: "?", Description: "help"}, - } - - // Render with narrow width - result := bar.Render(30, bindings, "") - stripped := stripANSI(result) - - // Should have truncated some bindings - if strings.Contains(stripped, "?") && strings.Contains(stripped, "help") { - // If all bindings fit, that's suspicious for width 30 - fullText := "q: quit • ↑/↓: navigate • enter: select • tab: switch • ?: help" - if len(fullText) > 30 { - t.Log("Expected some truncation with width 30") - } - } - - // Should contain ellipsis if truncated - if len(stripped) > 30 && !strings.Contains(stripped, "...") { - t.Log("Expected ellipsis when content doesn't fit") - } -} - -func TestRender_MessagePriority(t *testing.T) { - bar := NewStatusBar(mockTheme()) - bindings := []engine.KeyBinding{ - {Key: "q", Description: "quit"}, - {Key: "↑/↓", Description: "navigate"}, - {Key: "enter", Description: "select"}, - } - - // Render with narrow width but important message - result := bar.Render(40, bindings, "Important") - stripped := stripANSI(result) - - // Message should be preserved - if !strings.Contains(stripped, "Important") { - t.Error("Message should be preserved when truncating") - } -} - -func TestRender_ZeroWidth(t *testing.T) { - bar := NewStatusBar(mockTheme()) - bindings := []engine.KeyBinding{ - {Key: "q", Description: "quit"}, - } - - result := bar.Render(0, bindings, "Message") - - if result != "" { - t.Error("Expected empty string for zero width") - } -} - -func TestRender_NegativeWidth(t *testing.T) { - bar := NewStatusBar(mockTheme()) - bindings := []engine.KeyBinding{ - {Key: "q", Description: "quit"}, - } - - result := bar.Render(-10, bindings, "Message") - - if result != "" { - t.Error("Expected empty string for negative width") - } -} - -func TestFormatKeybindings(t *testing.T) { - bar := NewStatusBar(mockTheme()) - - tests := []struct { - name string - bindings []engine.KeyBinding - want string - }{ - { - name: "empty", - bindings: []engine.KeyBinding{}, - want: "", - }, - { - name: "single", - bindings: []engine.KeyBinding{ - {Key: "q", Description: "quit"}, - }, - want: "q: quit", - }, - { - name: "multiple", - bindings: []engine.KeyBinding{ - {Key: "q", Description: "quit"}, - {Key: "↑/↓", Description: "navigate"}, - }, - want: "q: quit • ↑/↓: navigate", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := bar.formatKeybindings(tt.bindings) - if got != tt.want { - t.Errorf("formatKeybindings() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestTruncateKeybindings(t *testing.T) { - bar := NewStatusBar(mockTheme()) - - bindings := []engine.KeyBinding{ - {Key: "q", Description: "quit"}, - {Key: "↑/↓", Description: "navigate"}, - {Key: "enter", Description: "select"}, - } - - tests := []struct { - name string - maxWidth int - wantMin int // Minimum expected keybindings - wantMax int // Maximum expected keybindings - }{ - { - name: "very_narrow", - maxWidth: 5, - wantMin: 0, - wantMax: 0, - }, - { - name: "fits_one", - maxWidth: 15, - wantMin: 1, - wantMax: 1, - }, - { - name: "fits_all", - maxWidth: 100, - wantMin: 3, - wantMax: 3, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := bar.truncateKeybindings(bindings, tt.maxWidth) - - // Count how many keybindings are in the result - count := 0 - for _, kb := range bindings { - if strings.Contains(result, kb.Key+": "+kb.Description) { - count++ - } - } - - if count < tt.wantMin || count > tt.wantMax { - t.Errorf("truncateKeybindings() contained %d bindings, want between %d and %d", - count, tt.wantMin, tt.wantMax) - } - - // Check width constraint - if lipgloss.Width(result) > tt.maxWidth { - t.Errorf("truncateKeybindings() width %d exceeds maxWidth %d", - lipgloss.Width(result), tt.maxWidth) - } - }) - } -} - -func TestTruncateText(t *testing.T) { - bar := NewStatusBar(mockTheme()) - - tests := []struct { - name string - text string - maxWidth int - want string - }{ - { - name: "fits", - text: "Hello", - maxWidth: 10, - want: "Hello", - }, - { - name: "exact_fit", - text: "Hello", - maxWidth: 5, - want: "Hello", - }, - { - name: "truncate", - text: "Hello World", - maxWidth: 8, - want: "Hello...", - }, - { - name: "very_narrow", - text: "Hello", - maxWidth: 3, - want: "...", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := bar.truncateText(tt.text, tt.maxWidth) - if got != tt.want { - t.Errorf("truncateText() = %q, want %q", got, tt.want) - } - }) - } -} - -// stripANSI removes ANSI escape codes from a string for testing -func stripANSI(s string) string { - // Simple approach: render with lipgloss and it handles stripping - // For more accurate testing, we just check the visible width - return strings.TrimRight(s, " ") -} diff --git a/pkg/ui/components/status_rail.go b/pkg/ui/components/status_rail.go deleted file mode 100644 index 56553cb..0000000 --- a/pkg/ui/components/status_rail.go +++ /dev/null @@ -1,405 +0,0 @@ -package components - -import ( - "strings" - - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// StatusRail represents a bottom status bar with multiple sections. -// Each section can display an icon, label, and value. -type StatusRail struct { - Sections []StatusRailSection - Width int - Style StatusRailStyle - SeparatorChar string - ShowTopBorder bool - ShowSeparators bool - BorderTier BorderTier - BorderColor lipgloss.Color - SectionSpacing int // Space between sections -} - -// StatusRailSection represents a single section in the status rail. -type StatusRailSection struct { - Icon string - Label string - Value string -} - -// StatusRailStyle configures status rail appearance. -type StatusRailStyle struct { - SectionColor lipgloss.Color - IconColor lipgloss.Color - LabelColor lipgloss.Color - ValueColor lipgloss.Color - BorderColor lipgloss.Color - SeparatorColor lipgloss.Color - BackgroundColor lipgloss.Color - Bold bool -} - -// NewStatusRail creates a new status rail with default styling. -// Deprecated: Use NewThemedStatusRail for profile-aware theming. -// This version maintains backward compatibility by using default theme. -func NewStatusRail(sections []StatusRailSection, width int) *StatusRail { - return NewThemedStatusRail(sections, width, nil) -} - -// NewThemedStatusRail creates a new status rail with default styling using theme colors. -// If theme is nil, falls back to default theme. -func NewThemedStatusRail(sections []StatusRailSection, width int, theme *themes.Theme) *StatusRail { - // Fallback to default theme if not provided - if theme == nil { - theme = getDefaultTheme() - } - - // Ultimate fallback if theme is still nil - if theme == nil { - return &StatusRail{ - Sections: sections, - Width: width, - SeparatorChar: "│", - ShowTopBorder: true, - ShowSeparators: true, - BorderTier: BorderTierBlock, - SectionSpacing: 2, - Style: StatusRailStyle{ - SectionColor: lipgloss.Color("#F8F8F2"), - IconColor: lipgloss.Color("#00ADD8"), - LabelColor: lipgloss.Color("#6272A4"), - ValueColor: lipgloss.Color("#F8F8F2"), - BorderColor: lipgloss.Color("#44475A"), - SeparatorColor: lipgloss.Color("#44475A"), - Bold: false, - }, - } - } - - return &StatusRail{ - Sections: sections, - Width: width, - SeparatorChar: "│", - ShowTopBorder: true, - ShowSeparators: true, - BorderTier: BorderTierBlock, - SectionSpacing: 2, - Style: StatusRailStyle{ - SectionColor: lipgloss.Color("#F8F8F2"), - IconColor: theme.Colors.PrimaryColor(), - LabelColor: theme.Colors.MutedColor(), - ValueColor: lipgloss.Color("#F8F8F2"), - BorderColor: lipgloss.Color("#44475A"), - SeparatorColor: lipgloss.Color("#44475A"), - Bold: false, - }, - } -} - -// WithStyle sets custom styling for the status rail. -func (sr *StatusRail) WithStyle(style *StatusRailStyle) *StatusRail { - if style != nil { - sr.Style = *style - } - return sr -} - -// WithBorderTier sets the border tier for the status rail. -func (sr *StatusRail) WithBorderTier(tier BorderTier) *StatusRail { - sr.BorderTier = tier - return sr -} - -// WithBorderColor sets the border color for the status rail. -func (sr *StatusRail) WithBorderColor(color lipgloss.Color) *StatusRail { - sr.BorderColor = color - sr.Style.BorderColor = color - return sr -} - -// WithSeparatorChar sets the character used between sections. -func (sr *StatusRail) WithSeparatorChar(char string) *StatusRail { - sr.SeparatorChar = char - return sr -} - -// WithTopBorder enables or disables the top border line. -func (sr *StatusRail) WithTopBorder(show bool) *StatusRail { - sr.ShowTopBorder = show - return sr -} - -// WithSeparators enables or disables separators between sections. -func (sr *StatusRail) WithSeparators(show bool) *StatusRail { - sr.ShowSeparators = show - return sr -} - -// WithSectionSpacing sets the spacing between sections. -func (sr *StatusRail) WithSectionSpacing(spacing int) *StatusRail { - sr.SectionSpacing = spacing - return sr -} - -// Render returns the status rail as a styled string. -func (sr *StatusRail) Render() string { - if len(sr.Sections) == 0 { - return "" - } - - // Calculate available width for sections - availableWidth := sr.Width - if sr.ShowTopBorder { - // Reserve space for top border - availableWidth = sr.Width - } - - // Render each section - renderedSections := sr.renderSections(availableWidth) - - // Join sections with or without separators - var railContent string - if sr.ShowSeparators && len(renderedSections) > 1 { - separatorStyle := lipgloss.NewStyle(). - Foreground(sr.Style.SeparatorColor) - - // Calculate separator width for proper spacing - parts := make([]string, 0, len(renderedSections)*2-1) - for i, section := range renderedSections { - parts = append(parts, section) - if i < len(renderedSections)-1 { - separator := separatorStyle.Render(" " + sr.SeparatorChar + " ") - parts = append(parts, separator) - } - } - railContent = lipgloss.JoinHorizontal(lipgloss.Top, parts...) - } else { - // Join with spacing - spacing := strings.Repeat(" ", sr.SectionSpacing) - railContent = strings.Join(renderedSections, spacing) - } - - // Add top border if requested and not in borderless mode - if sr.ShowTopBorder && sr.BorderTier != BorderTierNone { - borderStyle := lipgloss.NewStyle(). - Foreground(sr.Style.BorderColor) - - // Use lipgloss.Width to handle ANSI escape codes correctly - contentWidth := lipgloss.Width(railContent) - if contentWidth < sr.Width { - contentWidth = sr.Width - } - - topBorder := borderStyle.Render(strings.Repeat("─", contentWidth)) - return lipgloss.JoinVertical(lipgloss.Left, topBorder, railContent) - } - - return railContent -} - -// renderSections renders each section with width distribution. -func (sr *StatusRail) renderSections(availableWidth int) []string { - if len(sr.Sections) == 0 { - return []string{} - } - - rendered := make([]string, 0, len(sr.Sections)) - - // Calculate spacing overhead - separatorWidth := 0 - if sr.ShowSeparators && len(sr.Sections) > 1 { - // Each separator: " │ " = 3 characters - separatorWidth = (len(sr.Sections) - 1) * 3 - } else if len(sr.Sections) > 1 { - // Regular spacing between sections - separatorWidth = (len(sr.Sections) - 1) * sr.SectionSpacing - } - - // Calculate width per section - contentWidth := availableWidth - separatorWidth - if contentWidth < 0 { - contentWidth = 0 - } - - // Distribute width among sections - sectionWidth := contentWidth - if len(sr.Sections) > 0 { - sectionWidth = contentWidth / len(sr.Sections) - } - - // Minimum width per section - if sectionWidth < 10 { - sectionWidth = 10 - } - - // Render each section - for _, section := range sr.Sections { - renderedSection := sr.renderSection(section, sectionWidth) - rendered = append(rendered, renderedSection) - } - - return rendered -} - -// renderSection renders a single section with its icon, label, and value. -func (sr *StatusRail) renderSection(section StatusRailSection, maxWidth int) string { - var parts []string - - // Icon styling - if section.Icon != "" { - iconStyle := lipgloss.NewStyle(). - Foreground(sr.Style.IconColor). - Bold(sr.Style.Bold) - parts = append(parts, iconStyle.Render(section.Icon)) - } - - // Label styling - if section.Label != "" { - labelStyle := lipgloss.NewStyle(). - Foreground(sr.Style.LabelColor). - Bold(sr.Style.Bold) - parts = append(parts, labelStyle.Render(section.Label)) - } - - // Value styling - if section.Value != "" { - valueStyle := lipgloss.NewStyle(). - Foreground(sr.Style.ValueColor). - Bold(sr.Style.Bold) - - // Format: label: value or just value - if section.Label != "" { - parts = append(parts, valueStyle.Render(":")) - } - parts = append(parts, valueStyle.Render(section.Value)) - } - - // Join parts with spaces - sectionContent := strings.Join(parts, " ") - - // Truncate if exceeds max width (accounting for ANSI codes) - if lipgloss.Width(sectionContent) > maxWidth && maxWidth > 3 { - // Simple truncation - in real impl might use lipgloss truncate - plainContent := stripANSI(sectionContent) - if lipgloss.Width(plainContent) > maxWidth-3 { - // Re-render truncated content - truncatedPlain := plainContent[:maxWidth-3] + "..." - return lipgloss.NewStyle(). - Foreground(sr.Style.SectionColor). - Render(truncatedPlain) - } - } - - return sectionContent -} - -// stripANSI removes ANSI escape codes from a string (simple implementation). -// This is a helper for width calculations. -func stripANSI(s string) string { - // Simple implementation - lipgloss.Width is better for actual width - // This is just for truncation fallback - result := strings.Builder{} - inEscape := false - - for _, r := range s { - if r == '\x1b' { - inEscape = true - continue - } - if inEscape { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { - inEscape = false - } - continue - } - result.WriteRune(r) - } - - return result.String() -} - -// View is an alias for Render (for Bubble Tea compatibility). -func (sr *StatusRail) View() string { - return sr.Render() -} - -// DefaultStatusRailStyle returns the default status rail style. -// Deprecated: Use DefaultThemedStatusRailStyle for profile-aware theming. -func DefaultStatusRailStyle() StatusRailStyle { - return DefaultThemedStatusRailStyle(nil) -} - -// DefaultThemedStatusRailStyle returns the default status rail style using theme colors. -// If theme is nil, falls back to default theme. -// - -func DefaultThemedStatusRailStyle(theme *themes.Theme) StatusRailStyle { - // Fallback to default theme if not provided - if theme == nil { - theme = getDefaultTheme() - } - if theme == nil { - // Ultimate fallback - return StatusRailStyle{ - SectionColor: lipgloss.Color("#F8F8F2"), - IconColor: lipgloss.Color("#00ADD8"), - LabelColor: lipgloss.Color("#6272A4"), - ValueColor: lipgloss.Color("#F8F8F2"), - BorderColor: lipgloss.Color("#44475A"), - SeparatorColor: lipgloss.Color("#44475A"), - Bold: false, - } - } - - return StatusRailStyle{ - SectionColor: lipgloss.Color("#F8F8F2"), - IconColor: theme.Colors.PrimaryColor(), - LabelColor: theme.Colors.MutedColor(), - ValueColor: lipgloss.Color("#F8F8F2"), - BorderColor: lipgloss.Color("#44475A"), - SeparatorColor: lipgloss.Color("#44475A"), - Bold: false, - } -} - -// MutedStatusRailStyle returns a muted style for the status rail. -// Deprecated: Use MutedThemedStatusRailStyle for profile-aware theming. -func MutedStatusRailStyle() StatusRailStyle { - return MutedThemedStatusRailStyle(nil) -} - -// MutedThemedStatusRailStyle returns a muted style for the status rail using theme colors. -// If theme is nil, falls back to default theme. -// - -func MutedThemedStatusRailStyle(theme *themes.Theme) StatusRailStyle { - // Fallback to default theme if not provided - if theme == nil { - theme = getDefaultTheme() - } - if theme == nil { - // Ultimate fallback - return StatusRailStyle{ - SectionColor: lipgloss.Color("#6272A4"), - IconColor: lipgloss.Color("#6272A4"), - LabelColor: lipgloss.Color("#6272A4"), - ValueColor: lipgloss.Color("#6272A4"), - BorderColor: lipgloss.Color("#44475A"), - SeparatorColor: lipgloss.Color("#44475A"), - Bold: false, - } - } - - mutedColor := theme.Colors.MutedColor() - return StatusRailStyle{ - SectionColor: mutedColor, - IconColor: mutedColor, - LabelColor: mutedColor, - ValueColor: mutedColor, - BorderColor: lipgloss.Color("#44475A"), - SeparatorColor: lipgloss.Color("#44475A"), - Bold: false, - } -} diff --git a/pkg/ui/components/status_rail_test.go b/pkg/ui/components/status_rail_test.go deleted file mode 100644 index 98a9d30..0000000 --- a/pkg/ui/components/status_rail_test.go +++ /dev/null @@ -1,717 +0,0 @@ -package components - -import ( - "strings" - "testing" - - "github.com/charmbracelet/lipgloss" -) - -func TestNewStatusRail(t *testing.T) { - sections := []StatusRailSection{ - {Icon: "✓", Label: "Status", Value: "OK"}, - } - width := 100 - - rail := NewStatusRail(sections, width) - - if rail == nil { - t.Fatal("NewStatusRail() returned nil") - } - if rail.Width != width { - t.Errorf("Width = %d, want %d", rail.Width, width) - } - if len(rail.Sections) != 1 { - t.Errorf("Sections count = %d, want 1", len(rail.Sections)) - } - if !rail.ShowTopBorder { - t.Error("ShowTopBorder should be true by default") - } - if !rail.ShowSeparators { - t.Error("ShowSeparators should be true by default") - } - if rail.SeparatorChar != "│" { - t.Errorf("SeparatorChar = %q, want %q", rail.SeparatorChar, "│") - } - if rail.SectionSpacing != 2 { - t.Errorf("SectionSpacing = %d, want 2", rail.SectionSpacing) - } -} - -func TestStatusRail_EmptySections(t *testing.T) { - rail := NewStatusRail([]StatusRailSection{}, 100) - output := rail.Render() - - if output != "" { - t.Errorf("Expected empty output for empty sections, got %q", output) - } -} - -func TestStatusRail_SingleSection(t *testing.T) { - sections := []StatusRailSection{ - {Icon: "✓", Label: "Status", Value: "OK"}, - } - rail := NewStatusRail(sections, 100) - output := rail.Render() - - if output == "" { - t.Error("Render() returned empty string for valid section") - } - - // Should contain all parts - if !strings.Contains(output, "Status") { - t.Error("Output should contain label") - } - if !strings.Contains(output, "OK") { - t.Error("Output should contain value") - } -} - -func TestStatusRail_MultipleSections(t *testing.T) { - sections := []StatusRailSection{ - {Icon: "✓", Label: "Status", Value: "OK"}, - {Icon: "📊", Label: "Items", Value: "42"}, - {Icon: "⏱", Label: "Time", Value: "10s"}, - } - rail := NewStatusRail(sections, 120) - output := rail.Render() - - if output == "" { - t.Error("Render() returned empty string") - } - - // Should contain all labels - expectedLabels := []string{"Status", "Items", "Time"} - for _, label := range expectedLabels { - if !strings.Contains(output, label) { - t.Errorf("Output missing label %q", label) - } - } - - // Should contain all values - expectedValues := []string{"OK", "42", "10s"} - for _, value := range expectedValues { - if !strings.Contains(output, value) { - t.Errorf("Output missing value %q", value) - } - } -} - -func TestStatusRail_WithSeparators(t *testing.T) { - sections := []StatusRailSection{ - {Icon: "✓", Label: "Status", Value: "OK"}, - {Icon: "📊", Label: "Items", Value: "42"}, - } - - tests := []struct { - name string - showSeparators bool - wantSeparator bool - }{ - { - name: "with separators enabled", - showSeparators: true, - wantSeparator: true, - }, - { - name: "with separators disabled", - showSeparators: false, - wantSeparator: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rail := NewStatusRail(sections, 100) - rail.ShowSeparators = tt.showSeparators - output := rail.Render() - - if output == "" { - t.Error("Render() returned empty string") - } - - hasSeparator := strings.Contains(output, "│") - if tt.wantSeparator && !hasSeparator { - t.Error("Expected separator character in output") - } - if !tt.wantSeparator && hasSeparator { - t.Error("Did not expect separator character in output") - } - }) - } -} - -func TestStatusRail_WithTopBorder(t *testing.T) { - sections := []StatusRailSection{ - {Icon: "✓", Label: "Status", Value: "OK"}, - } - - tests := []struct { - name string - showTopBorder bool - borderTier BorderTier - wantBorder bool - }{ - { - name: "with top border enabled - block tier", - showTopBorder: true, - borderTier: BorderTierBlock, - wantBorder: true, - }, - { - name: "with top border disabled", - showTopBorder: false, - borderTier: BorderTierBlock, - wantBorder: false, - }, - { - name: "with top border enabled - borderless tier", - showTopBorder: true, - borderTier: BorderTierNone, - wantBorder: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rail := NewStatusRail(sections, 100) - rail.ShowTopBorder = tt.showTopBorder - rail.BorderTier = tt.borderTier - output := rail.Render() - - if output == "" { - t.Error("Render() returned empty string") - } - - hasBorder := strings.Contains(output, "─") - if tt.wantBorder && !hasBorder { - t.Error("Expected border line in output") - } - if !tt.wantBorder && hasBorder { - t.Error("Did not expect border line in output") - } - }) - } -} - -func TestStatusRail_WidthDistribution(t *testing.T) { - tests := []struct { - name string - sections []StatusRailSection - width int - }{ - { - name: "narrow width", - sections: []StatusRailSection{ - {Icon: "✓", Label: "A", Value: "1"}, - {Icon: "✓", Label: "B", Value: "2"}, - }, - width: 40, - }, - { - name: "wide width", - sections: []StatusRailSection{ - {Icon: "✓", Label: "Status", Value: "OK"}, - {Icon: "📊", Label: "Items", Value: "42"}, - {Icon: "⏱", Label: "Time", Value: "10s"}, - }, - width: 150, - }, - { - name: "very narrow width", - sections: []StatusRailSection{ - {Icon: "✓", Label: "X", Value: "Y"}, - }, - width: 20, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rail := NewStatusRail(tt.sections, tt.width) - output := rail.Render() - - if output == "" { - t.Error("Render() returned empty string") - } - - // Verify output doesn't panic with different widths - // Actual width verification is complex due to ANSI codes - }) - } -} - -func TestStatusRail_DifferentSectionCounts(t *testing.T) { - tests := []struct { - name string - sectionCount int - }{ - {name: "one section", sectionCount: 1}, - {name: "two sections", sectionCount: 2}, - {name: "three sections", sectionCount: 3}, - {name: "five sections", sectionCount: 5}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sections := make([]StatusRailSection, tt.sectionCount) - for i := 0; i < tt.sectionCount; i++ { - sections[i] = StatusRailSection{ - Icon: "✓", - Label: "Label", - Value: "Value", - } - } - - rail := NewStatusRail(sections, 100) - output := rail.Render() - - if output == "" { - t.Error("Render() returned empty string") - } - }) - } -} - -func TestStatusRail_SectionWithoutIcon(t *testing.T) { - sections := []StatusRailSection{ - {Label: "Status", Value: "OK"}, - } - rail := NewStatusRail(sections, 100) - output := rail.Render() - - if output == "" { - t.Error("Render() returned empty string") - } - if !strings.Contains(output, "Status") { - t.Error("Output should contain label") - } - if !strings.Contains(output, "OK") { - t.Error("Output should contain value") - } -} - -func TestStatusRail_SectionWithoutLabel(t *testing.T) { - sections := []StatusRailSection{ - {Icon: "✓", Value: "OK"}, - } - rail := NewStatusRail(sections, 100) - output := rail.Render() - - if output == "" { - t.Error("Render() returned empty string") - } - if !strings.Contains(output, "OK") { - t.Error("Output should contain value") - } -} - -func TestStatusRail_SectionWithoutValue(t *testing.T) { - sections := []StatusRailSection{ - {Icon: "✓", Label: "Status"}, - } - rail := NewStatusRail(sections, 100) - output := rail.Render() - - if output == "" { - t.Error("Render() returned empty string") - } - if !strings.Contains(output, "Status") { - t.Error("Output should contain label") - } -} - -func TestStatusRail_SectionOnlyIcon(t *testing.T) { - sections := []StatusRailSection{ - {Icon: "✓"}, - } - rail := NewStatusRail(sections, 100) - output := rail.Render() - - if output == "" { - t.Error("Render() returned empty string") - } -} - -func TestStatusRail_WithStyle(t *testing.T) { - sections := []StatusRailSection{ - {Icon: "✓", Label: "Status", Value: "OK"}, - } - - customStyle := StatusRailStyle{ - SectionColor: lipgloss.Color("#FF0000"), - IconColor: lipgloss.Color("#00FF00"), - LabelColor: lipgloss.Color("#0000FF"), - ValueColor: lipgloss.Color("#FFFF00"), - BorderColor: lipgloss.Color("#FF00FF"), - SeparatorColor: lipgloss.Color("#00FFFF"), - Bold: true, - } - - rail := NewStatusRail(sections, 100).WithStyle(&customStyle) - - if rail.Style.SectionColor != customStyle.SectionColor { - t.Error("Custom style not applied") - } - if !rail.Style.Bold { - t.Error("Bold style not applied") - } - - output := rail.Render() - if output == "" { - t.Error("Render() returned empty string") - } -} - -func TestStatusRail_WithBorderTier(t *testing.T) { - sections := []StatusRailSection{ - {Icon: "✓", Label: "Status", Value: "OK"}, - } - - tests := []struct { - name string - tier BorderTier - }{ - {name: "tier none", tier: BorderTierNone}, - {name: "tier block", tier: BorderTierBlock}, - {name: "tier classic", tier: BorderTierClassic}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rail := NewStatusRail(sections, 100).WithBorderTier(tt.tier) - - if rail.BorderTier != tt.tier { - t.Errorf("BorderTier = %v, want %v", rail.BorderTier, tt.tier) - } - - output := rail.Render() - if output == "" { - t.Error("Render() returned empty string") - } - }) - } -} - -func TestStatusRail_WithBorderColor(t *testing.T) { - sections := []StatusRailSection{ - {Icon: "✓", Label: "Status", Value: "OK"}, - } - - customColor := lipgloss.Color("#FF0000") - rail := NewStatusRail(sections, 100).WithBorderColor(customColor) - - if rail.BorderColor != customColor { - t.Error("BorderColor not set") - } - if rail.Style.BorderColor != customColor { - t.Error("Style.BorderColor not set") - } - - output := rail.Render() - if output == "" { - t.Error("Render() returned empty string") - } -} - -func TestStatusRail_WithSeparatorChar(t *testing.T) { - sections := []StatusRailSection{ - {Icon: "✓", Label: "A", Value: "1"}, - {Icon: "✓", Label: "B", Value: "2"}, - } - - customSep := "|" - rail := NewStatusRail(sections, 100).WithSeparatorChar(customSep) - - if rail.SeparatorChar != customSep { - t.Errorf("SeparatorChar = %q, want %q", rail.SeparatorChar, customSep) - } - - output := rail.Render() - if output == "" { - t.Error("Render() returned empty string") - } - if !strings.Contains(output, customSep) { - t.Errorf("Output should contain custom separator %q", customSep) - } -} - -func TestStatusRail_WithTopBorderMethod(t *testing.T) { - sections := []StatusRailSection{ - {Icon: "✓", Label: "Status", Value: "OK"}, - } - - rail := NewStatusRail(sections, 100).WithTopBorder(false) - - if rail.ShowTopBorder { - t.Error("ShowTopBorder should be false") - } - - output := rail.Render() - if output == "" { - t.Error("Render() returned empty string") - } -} - -func TestStatusRail_WithSeparatorsMethod(t *testing.T) { - sections := []StatusRailSection{ - {Icon: "✓", Label: "A", Value: "1"}, - {Icon: "✓", Label: "B", Value: "2"}, - } - - rail := NewStatusRail(sections, 100).WithSeparators(false) - - if rail.ShowSeparators { - t.Error("ShowSeparators should be false") - } - - output := rail.Render() - if output == "" { - t.Error("Render() returned empty string") - } -} - -func TestStatusRail_WithSectionSpacing(t *testing.T) { - sections := []StatusRailSection{ - {Icon: "✓", Label: "A", Value: "1"}, - {Icon: "✓", Label: "B", Value: "2"}, - } - - customSpacing := 5 - rail := NewStatusRail(sections, 100).WithSectionSpacing(customSpacing) - - if rail.SectionSpacing != customSpacing { - t.Errorf("SectionSpacing = %d, want %d", rail.SectionSpacing, customSpacing) - } - - output := rail.Render() - if output == "" { - t.Error("Render() returned empty string") - } -} - -func TestStatusRail_View(t *testing.T) { - sections := []StatusRailSection{ - {Icon: "✓", Label: "Status", Value: "OK"}, - } - rail := NewStatusRail(sections, 100) - - view := rail.View() - render := rail.Render() - - if view != render { - t.Error("View() should return same as Render()") - } -} - -func TestStatusRail_ChainedMethods(t *testing.T) { - sections := []StatusRailSection{ - {Icon: "✓", Label: "Status", Value: "OK"}, - } - - rail := NewStatusRail(sections, 100). - WithBorderTier(BorderTierClassic). - WithBorderColor(lipgloss.Color("#FF0000")). - WithSeparatorChar("|"). - WithTopBorder(false). - WithSeparators(true). - WithSectionSpacing(3) - - if rail.BorderTier != BorderTierClassic { - t.Error("BorderTier not set correctly") - } - if rail.BorderColor != lipgloss.Color("#FF0000") { - t.Error("BorderColor not set correctly") - } - if rail.SeparatorChar != "|" { - t.Error("SeparatorChar not set correctly") - } - if rail.ShowTopBorder { - t.Error("ShowTopBorder should be false") - } - if !rail.ShowSeparators { - t.Error("ShowSeparators should be true") - } - if rail.SectionSpacing != 3 { - t.Error("SectionSpacing not set correctly") - } - - output := rail.Render() - if output == "" { - t.Error("Render() returned empty string") - } -} - -func TestDefaultStatusRailStyle(t *testing.T) { - style := DefaultStatusRailStyle() - - if style.SectionColor == "" { - t.Error("SectionColor should not be empty") - } - if style.IconColor == "" { - t.Error("IconColor should not be empty") - } - if style.LabelColor == "" { - t.Error("LabelColor should not be empty") - } - if style.ValueColor == "" { - t.Error("ValueColor should not be empty") - } - if style.BorderColor == "" { - t.Error("BorderColor should not be empty") - } - if style.SeparatorColor == "" { - t.Error("SeparatorColor should not be empty") - } - if style.Bold { - t.Error("Bold should be false by default") - } - - // Use the style in a StatusRail to ensure it works - sections := []StatusRailSection{ - {Icon: "✓", Label: "Test", Value: "OK"}, - } - rail := NewStatusRail(sections, 100).WithStyle(&style) - output := rail.Render() - if output == "" { - t.Error("DefaultStatusRailStyle should produce valid output") - } -} - -func TestMutedStatusRailStyle(t *testing.T) { - style := MutedStatusRailStyle() - - if style.SectionColor == "" { - t.Error("SectionColor should not be empty") - } - if style.IconColor != style.LabelColor { - t.Error("Muted style should use same color for icon and label") - } - if style.Bold { - t.Error("Bold should be false for muted style") - } - - // Use the style in a StatusRail to ensure it works - sections := []StatusRailSection{ - {Icon: "✓", Label: "Test", Value: "OK"}, - } - rail := NewStatusRail(sections, 100).WithStyle(&style) - output := rail.Render() - if output == "" { - t.Error("MutedStatusRailStyle should produce valid output") - } -} - -func TestStatusRail_ComplexScenario(t *testing.T) { - // Test a realistic dashboard scenario - sections := []StatusRailSection{ - {Icon: "👤", Label: "Profile", Value: "saiyan"}, - {Icon: "⚡", Label: "Tier", Value: "Premium"}, - {Icon: "📁", Label: "Path", Value: "/project"}, - {Icon: "🔧", Label: "Services", Value: "4"}, - } - - rail := NewStatusRail(sections, 120). - WithBorderTier(BorderTierBlock). - WithSeparators(true). - WithTopBorder(true) - - output := rail.Render() - - if output == "" { - t.Error("Render() returned empty string") - } - - // Verify all labels are present - labels := []string{"Profile", "Tier", "Path", "Services"} - for _, label := range labels { - if !strings.Contains(output, label) { - t.Errorf("Output missing label %q", label) - } - } - - // Verify key values are present (some may be truncated) - values := []string{"saiyan", "Premium"} - for _, value := range values { - if !strings.Contains(output, value) { - t.Errorf("Output missing value %q", value) - } - } -} - -func TestStatusRail_EdgeCases(t *testing.T) { - tests := []struct { - name string - sections []StatusRailSection - width int - }{ - { - name: "zero width", - sections: []StatusRailSection{{Icon: "✓", Label: "Test", Value: "OK"}}, - width: 0, - }, - { - name: "negative width", - sections: []StatusRailSection{{Icon: "✓", Label: "Test", Value: "OK"}}, - width: -10, - }, - { - name: "very long values", - sections: []StatusRailSection{ - {Icon: "✓", Label: "Test", Value: "ThisIsAVeryLongValueThatShouldBeHandledProperly"}, - }, - width: 40, - }, - { - name: "empty section fields", - sections: []StatusRailSection{ - {Icon: "", Label: "", Value: ""}, - }, - width: 100, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rail := NewStatusRail(tt.sections, tt.width) - // Should not panic - output := rail.Render() - - // For zero/negative width or empty fields, we still expect valid output or empty string - _ = output - }) - } -} - -func TestStatusRail_StripANSI(t *testing.T) { - tests := []struct { - name string - input string - want string - }{ - { - name: "plain text", - input: "Hello World", - want: "Hello World", - }, - { - name: "text with ANSI codes", - input: "\x1b[31mRed\x1b[0m Normal", - want: "Red Normal", - }, - { - name: "empty string", - input: "", - want: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := stripANSI(tt.input) - if got != tt.want { - t.Errorf("stripANSI() = %q, want %q", got, tt.want) - } - }) - } -} diff --git a/pkg/ui/components/tab_bar.go b/pkg/ui/components/tab_bar.go deleted file mode 100644 index c69e1a0..0000000 --- a/pkg/ui/components/tab_bar.go +++ /dev/null @@ -1,337 +0,0 @@ -package components - -import ( - "strings" - - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// TabItem represents a single tab in the tab bar. -type TabItem struct { - ID int - Label string - Icon string -} - -// TabBar represents a horizontal tab navigation bar. -// -// Deprecated: Use the new engine-based views in pkg/ui/views/ instead. -type TabBar struct { - Tabs []TabItem - ActiveIdx int - Width int - Style TabBarStyle -} - -// TabBarStyle configures tab bar appearance. -type TabBarStyle struct { - ActiveColor lipgloss.Color - InactiveColor lipgloss.Color - SeparatorColor lipgloss.Color - ActiveBold bool - ShowSeparator bool - TabSpacing int // Spacing between tabs - ActiveUnderline bool -} - -// NewTabBar creates a new tab bar with default styling. -// Deprecated: Use NewThemedTabBar for profile-aware theming. -// This version maintains backward compatibility by using default theme. -func NewTabBar(tabs []TabItem, activeIdx int) *TabBar { - return NewThemedTabBar(tabs, activeIdx, nil) -} - -// NewThemedTabBar creates a new tab bar with default styling using theme colors. -// If theme is nil, falls back to default theme. -func NewThemedTabBar(tabs []TabItem, activeIdx int, theme *themes.Theme) *TabBar { - // Fallback to default theme if not provided - if theme == nil { - theme = getDefaultTheme() - } - - // Ultimate fallback if theme is still nil - if theme == nil { - return &TabBar{ - Tabs: tabs, - ActiveIdx: activeIdx, - Width: 80, - Style: TabBarStyle{ - ActiveColor: lipgloss.Color("#00ADD8"), - InactiveColor: lipgloss.Color("#6272A4"), - SeparatorColor: lipgloss.Color("#6272A4"), - ActiveBold: true, - ShowSeparator: true, - TabSpacing: 2, - ActiveUnderline: false, - }, - } - } - - return &TabBar{ - Tabs: tabs, - ActiveIdx: activeIdx, - Width: 80, - Style: TabBarStyle{ - ActiveColor: theme.Colors.PrimaryColor(), - InactiveColor: theme.Colors.MutedColor(), - SeparatorColor: theme.Colors.MutedColor(), - ActiveBold: true, - ShowSeparator: true, - TabSpacing: 2, - ActiveUnderline: false, - }, - } -} - -// NewTabBarWithStyle creates a tab bar with custom styling. -func NewTabBarWithStyle(tabs []TabItem, activeIdx int, style *TabBarStyle) *TabBar { - tabBar := NewTabBar(tabs, activeIdx) - tabBar.Style = *style - return tabBar -} - -// SetWidth sets the tab bar width. -func (tb *TabBar) SetWidth(width int) *TabBar { - tb.Width = width - return tb -} - -// SetActiveIdx sets the active tab index. -func (tb *TabBar) SetActiveIdx(idx int) *TabBar { - if idx >= 0 && idx < len(tb.Tabs) { - tb.ActiveIdx = idx - } - return tb -} - -// WithTabs sets the tabs. -func (tb *TabBar) WithTabs(tabs []TabItem) *TabBar { - tb.Tabs = tabs - return tb -} - -// calculateTabWidth calculates the width available for each tab. -func (tb *TabBar) calculateTabWidth() int { - if len(tb.Tabs) == 0 { - return 0 - } - totalSpacing := (len(tb.Tabs) - 1) * tb.Style.TabSpacing - availableWidth := tb.Width - totalSpacing - if availableWidth < 0 { - return 0 - } - return availableWidth / len(tb.Tabs) -} - -// formatTabLabel formats a tab label with icon and truncates if needed. -func (tb *TabBar) formatTabLabel(tab TabItem, maxWidth int) string { - label := tab.Label - if tab.Icon != "" { - label = tab.Icon + " " + label - } - - // Truncate label if it exceeds max width - if maxWidth > 0 && lipgloss.Width(label) > maxWidth { - maxLen := maxWidth - 1 // Leave room for ellipsis - if maxLen < 1 { - maxLen = 1 - } - // Use lipgloss.Width for visual truncation to handle multi-byte characters - for lipgloss.Width(label) > maxLen && label != "" { - // Trim one rune at a time from the end - runes := []rune(label) - label = string(runes[:len(runes)-1]) - } - label += "…" - } - - return label -} - -// styleActiveTab applies active styling to a tab label. -func (tb *TabBar) styleActiveTab(label string, tabWidth int) string { - style := lipgloss.NewStyle(). - Foreground(tb.Style.ActiveColor). - Bold(tb.Style.ActiveBold) - - if tb.Style.ActiveUnderline { - style = style.Underline(true) - } - - if tabWidth > 0 { - style = style.Width(tabWidth).Align(lipgloss.Center) - } - - return style.Render(label) -} - -// styleInactiveTab applies inactive styling to a tab label. -func (tb *TabBar) styleInactiveTab(label string, tabWidth int) string { - style := lipgloss.NewStyle(). - Foreground(tb.Style.InactiveColor) - - if tabWidth > 0 { - style = style.Width(tabWidth).Align(lipgloss.Center) - } - - return style.Render(label) -} - -// renderTabs renders all tabs with appropriate styling. -func (tb *TabBar) renderTabs() []string { - tabWidth := tb.calculateTabWidth() - renderedTabs := make([]string, len(tb.Tabs)) - - for i, tab := range tb.Tabs { - label := tb.formatTabLabel(tab, tabWidth) - - if i == tb.ActiveIdx { - renderedTabs[i] = tb.styleActiveTab(label, tabWidth) - } else { - renderedTabs[i] = tb.styleInactiveTab(label, tabWidth) - } - } - - return renderedTabs -} - -// joinTabs joins rendered tabs with spacing. -func (tb *TabBar) joinTabs(renderedTabs []string) string { - if len(renderedTabs) == 0 { - return "" - } - - spacing := strings.Repeat(" ", tb.Style.TabSpacing) - tabBar := renderedTabs[0] - - for i := 1; i < len(renderedTabs); i++ { - tabBar = lipgloss.JoinHorizontal(lipgloss.Top, tabBar, spacing, renderedTabs[i]) - } - - return tabBar -} - -// addSeparator adds a separator line below the tab bar. -func (tb *TabBar) addSeparator(tabBar string) string { - style := lipgloss.NewStyle(). - Foreground(tb.Style.SeparatorColor) - - barWidth := lipgloss.Width(tabBar) - if barWidth < tb.Width { - barWidth = tb.Width - } - - separator := style.Render(strings.Repeat("─", barWidth)) - return lipgloss.JoinVertical(lipgloss.Left, tabBar, separator) -} - -// Render returns the tab bar as a styled string. -func (tb *TabBar) Render() string { - if len(tb.Tabs) == 0 { - return "" - } - - renderedTabs := tb.renderTabs() - tabBar := tb.joinTabs(renderedTabs) - - if tb.Style.ShowSeparator { - return tb.addSeparator(tabBar) - } - - return tabBar -} - -// View is an alias for Render (for Bubble Tea compatibility). -func (tb *TabBar) View() string { - return tb.Render() -} - -// DefaultTabBarStyle returns the default tab bar style. -// Deprecated: Use DefaultThemedTabBarStyle for profile-aware theming. -func DefaultTabBarStyle() TabBarStyle { - return DefaultThemedTabBarStyle(nil) -} - -// DefaultThemedTabBarStyle returns the default tab bar style using theme colors. -// If theme is nil, falls back to default theme. -// -//nolint:dupl // Intentional duplication for clarity - each style function is self-contained -func DefaultThemedTabBarStyle(theme *themes.Theme) TabBarStyle { - // Fallback to default theme if not provided - if theme == nil { - theme = getDefaultTheme() - } - if theme == nil { - // Ultimate fallback - return TabBarStyle{ - ActiveColor: lipgloss.Color("#00ADD8"), - InactiveColor: lipgloss.Color("#6272A4"), - SeparatorColor: lipgloss.Color("#6272A4"), - ActiveBold: true, - ShowSeparator: true, - TabSpacing: 2, - ActiveUnderline: false, - } - } - - return TabBarStyle{ - ActiveColor: theme.Colors.PrimaryColor(), - InactiveColor: theme.Colors.MutedColor(), - SeparatorColor: theme.Colors.MutedColor(), - ActiveBold: true, - ShowSeparator: true, - TabSpacing: 2, - ActiveUnderline: false, - } -} - -// MinimalTabBarStyle returns a minimal tab bar style without separator. -// Deprecated: Use MinimalThemedTabBarStyle for profile-aware theming. -func MinimalTabBarStyle() TabBarStyle { - return MinimalThemedTabBarStyle(nil) -} - -// MinimalThemedTabBarStyle returns a minimal tab bar style without separator using theme colors. -// If theme is nil, falls back to default theme. -// -//nolint:dupl // Intentional duplication for clarity - each style function is self-contained -func MinimalThemedTabBarStyle(theme *themes.Theme) TabBarStyle { - // Fallback to default theme if not provided - if theme == nil { - theme = getDefaultTheme() - } - if theme == nil { - // Ultimate fallback - return TabBarStyle{ - ActiveColor: lipgloss.Color("#00ADD8"), - InactiveColor: lipgloss.Color("#6272A4"), - SeparatorColor: lipgloss.Color("#6272A4"), - ActiveBold: true, - ShowSeparator: false, - TabSpacing: 3, - ActiveUnderline: true, - } - } - - return TabBarStyle{ - ActiveColor: theme.Colors.PrimaryColor(), - InactiveColor: theme.Colors.MutedColor(), - SeparatorColor: theme.Colors.MutedColor(), - ActiveBold: true, - ShowSeparator: false, - TabSpacing: 3, - ActiveUnderline: true, - } -} - -// DashboardTabs returns the standard 4 tabs for the dashboard. -func DashboardTabs() []TabItem { - return []TabItem{ - {ID: 0, Label: "Dashboard", Icon: "🏠"}, - {ID: 1, Label: "Services", Icon: "📡"}, - {ID: 2, Label: "Workspace", Icon: "📁"}, - {ID: 3, Label: "Config", Icon: "⚙️"}, - } -} diff --git a/pkg/ui/components/tab_bar_test.go b/pkg/ui/components/tab_bar_test.go deleted file mode 100644 index 32450f9..0000000 --- a/pkg/ui/components/tab_bar_test.go +++ /dev/null @@ -1,491 +0,0 @@ -package components - -import ( - "strings" - "testing" - - "github.com/charmbracelet/lipgloss" -) - -func TestNewTabBar(t *testing.T) { - tabs := []TabItem{ - {ID: 0, Label: "Home", Icon: "🏠"}, - {ID: 1, Label: "Settings", Icon: "⚙️"}, - } - activeIdx := 0 - - tabBar := NewTabBar(tabs, activeIdx) - - if tabBar == nil { - t.Fatal("NewTabBar() returned nil") - } - if len(tabBar.Tabs) != 2 { - t.Errorf("Tabs count = %d, want 2", len(tabBar.Tabs)) - } - if tabBar.ActiveIdx != activeIdx { - t.Errorf("ActiveIdx = %d, want %d", tabBar.ActiveIdx, activeIdx) - } - if tabBar.Width != 80 { - t.Errorf("Width = %d, want 80", tabBar.Width) - } - if !tabBar.Style.ShowSeparator { - t.Error("ShowSeparator = false, want true") - } -} - -func TestNewTabBarWithStyle(t *testing.T) { - tabs := []TabItem{{ID: 0, Label: "Test", Icon: "📌"}} - customStyle := &TabBarStyle{ - ActiveColor: lipgloss.Color("#FF0000"), - InactiveColor: lipgloss.Color("#00FF00"), - SeparatorColor: lipgloss.Color("#0000FF"), - ActiveBold: false, - ShowSeparator: false, - TabSpacing: 5, - ActiveUnderline: true, - } - - tabBar := NewTabBarWithStyle(tabs, 0, customStyle) - - if tabBar == nil { - t.Fatal("NewTabBarWithStyle() returned nil") - } - if tabBar.Style.ActiveColor != customStyle.ActiveColor { - t.Error("ActiveColor mismatch") - } - if tabBar.Style.ShowSeparator != false { - t.Error("ShowSeparator should be false") - } - if tabBar.Style.TabSpacing != 5 { - t.Errorf("TabSpacing = %d, want 5", tabBar.Style.TabSpacing) - } - if !tabBar.Style.ActiveUnderline { - t.Error("ActiveUnderline should be true") - } -} - -func TestTabBar_SetWidth(t *testing.T) { - tabBar := NewTabBar(DashboardTabs(), 0) - newWidth := 120 - - result := tabBar.SetWidth(newWidth) - - if result != tabBar { - t.Error("SetWidth() should return self for chaining") - } - if tabBar.Width != newWidth { - t.Errorf("Width = %d, want %d", tabBar.Width, newWidth) - } -} - -func TestTabBar_SetActiveIdx(t *testing.T) { - tabs := DashboardTabs() - tabBar := NewTabBar(tabs, 0) - - // Valid index - result := tabBar.SetActiveIdx(2) - if result != tabBar { - t.Error("SetActiveIdx() should return self for chaining") - } - if tabBar.ActiveIdx != 2 { - t.Errorf("ActiveIdx = %d, want 2", tabBar.ActiveIdx) - } - - // Invalid index (negative) - tabBar.SetActiveIdx(-1) - if tabBar.ActiveIdx != 2 { - t.Error("SetActiveIdx() should not change index for negative value") - } - - // Invalid index (out of bounds) - tabBar.SetActiveIdx(10) - if tabBar.ActiveIdx != 2 { - t.Error("SetActiveIdx() should not change index for out of bounds value") - } -} - -func TestTabBar_WithTabs(t *testing.T) { - tabBar := NewTabBar([]TabItem{{ID: 0, Label: "Old"}}, 0) - newTabs := DashboardTabs() - - result := tabBar.WithTabs(newTabs) - - if result != tabBar { - t.Error("WithTabs() should return self for chaining") - } - if len(tabBar.Tabs) != 4 { - t.Errorf("Tabs count = %d, want 4", len(tabBar.Tabs)) - } - if tabBar.Tabs[0].Label != "Dashboard" { - t.Errorf("First tab label = %q, want %q", tabBar.Tabs[0].Label, "Dashboard") - } -} - -func TestTabBar_Render_EmptyTabs(t *testing.T) { - tabBar := NewTabBar([]TabItem{}, 0) - output := tabBar.Render() - - if output != "" { - t.Error("Render() should return empty string for empty tabs") - } -} - -func TestTabBar_Render_AllFourTabs(t *testing.T) { - tabs := DashboardTabs() - tabBar := NewTabBar(tabs, 0) - output := tabBar.Render() - - if len(output) == 0 { - t.Fatal("Render() returned empty string") - } - - // Check all tab labels are present - expectedLabels := []string{"Dashboard", "Services", "Workspace", "Config"} - for _, label := range expectedLabels { - if !strings.Contains(output, label) { - t.Errorf("Render() output should contain %q", label) - } - } - - // Check all icons are present - expectedIcons := []string{"🏠", "📡", "📁", "⚙️"} - for _, icon := range expectedIcons { - if !strings.Contains(output, icon) { - t.Errorf("Render() output should contain icon %q", icon) - } - } - - // Check separator is present (default style has separator) - if !strings.Contains(output, "─") { - t.Error("Render() output should contain separator") - } -} - -func TestTabBar_Render_ActiveHighlight(t *testing.T) { - tabs := []TabItem{ - {ID: 0, Label: "Tab1", Icon: "1️⃣"}, - {ID: 1, Label: "Tab2", Icon: "2️⃣"}, - } - - // Note: lipgloss may disable colors in non-TTY environments (like tests) - // We verify that the component handles different active states correctly - - // Test active tab 0 - tabBar0 := NewTabBar(tabs, 0) - output0 := tabBar0.Render() - if tabBar0.ActiveIdx != 0 { - t.Errorf("ActiveIdx should be 0, got %d", tabBar0.ActiveIdx) - } - - // Test active tab 1 - tabBar1 := NewTabBar(tabs, 1) - output1 := tabBar1.Render() - if tabBar1.ActiveIdx != 1 { - t.Errorf("ActiveIdx should be 1, got %d", tabBar1.ActiveIdx) - } - - // Both should contain both tab labels - for _, output := range []string{output0, output1} { - if !strings.Contains(output, "Tab1") { - t.Error("Output should contain Tab1") - } - if !strings.Contains(output, "Tab2") { - t.Error("Output should contain Tab2") - } - } - - // Verify outputs have content - if len(output0) == 0 { - t.Error("Output 0 should not be empty") - } - if len(output1) == 0 { - t.Error("Output 1 should not be empty") - } -} - -func TestTabBar_Render_NarrowWidth(t *testing.T) { - tabs := DashboardTabs() - tabBar := NewTabBar(tabs, 0).SetWidth(40) // Narrow width - output := tabBar.Render() - - if len(output) == 0 { - t.Fatal("Render() returned empty string for narrow width") - } - - // Should still contain some content (may be truncated) - // At minimum, icons should be present - hasContent := false - for _, icon := range []string{"🏠", "📡", "📁", "⚙️"} { - if strings.Contains(output, icon) { - hasContent = true - break - } - } - - if !hasContent { - t.Error("Render() should contain at least some tab content even at narrow width") - } -} - -func TestTabBar_Render_WideWidth(t *testing.T) { - tabs := DashboardTabs() - tabBar := NewTabBar(tabs, 1).SetWidth(160) // Wide width - output := tabBar.Render() - - if len(output) == 0 { - t.Fatal("Render() returned empty string for wide width") - } - - // All labels should be fully visible at wide width - expectedLabels := []string{"Dashboard", "Services", "Workspace", "Config"} - for _, label := range expectedLabels { - if !strings.Contains(output, label) { - t.Errorf("Render() output should contain full label %q at wide width", label) - } - } -} - -func TestTabBar_Render_NoSeparator(t *testing.T) { - tabs := DashboardTabs() - style := DefaultTabBarStyle() - style.ShowSeparator = false - - tabBar := NewTabBarWithStyle(tabs, 0, &style) - output := tabBar.Render() - - if len(output) == 0 { - t.Fatal("Render() returned empty string") - } - - // Should not contain separator when disabled - lines := strings.Split(output, "\n") - if len(lines) > 1 { - // If there are multiple lines, check if last line is all separator chars - lastLine := strings.TrimSpace(lines[len(lines)-1]) - if strings.Contains(lastLine, "─") && len(strings.ReplaceAll(lastLine, "─", "")) == 0 { - t.Error("Render() should not contain separator line when ShowSeparator is false") - } - } -} - -func TestTabBar_Render_WithUnderline(t *testing.T) { - tabs := []TabItem{{ID: 0, Label: "Test", Icon: "📌"}} - style := DefaultTabBarStyle() - style.ActiveUnderline = true - - tabBar := NewTabBarWithStyle(tabs, 0, &style) - output := tabBar.Render() - - if len(output) == 0 { - t.Fatal("Render() returned empty string") - } - - // Just verify it renders without error - // Actual underline styling is handled by lipgloss -} - -func TestTabBar_View(t *testing.T) { - tabBar := NewTabBar(DashboardTabs(), 0) - - view := tabBar.View() - render := tabBar.Render() - - if view != render { - t.Error("View() should return same as Render()") - } -} - -func TestTabBar_ChainedOperations(t *testing.T) { - initialTabs := []TabItem{{ID: 0, Label: "Initial"}} - newTabs := DashboardTabs() - - // Chain operations: set tabs first, then active index - tabBar := NewTabBar(initialTabs, 0). - SetWidth(100). - WithTabs(newTabs). - SetActiveIdx(2) - - if tabBar.Width != 100 { - t.Errorf("Width = %d, want 100", tabBar.Width) - } - if tabBar.ActiveIdx != 2 { - t.Errorf("ActiveIdx = %d, want 2", tabBar.ActiveIdx) - } - if len(tabBar.Tabs) != 4 { - t.Errorf("Tabs count = %d, want 4", len(tabBar.Tabs)) - } -} - -func TestDefaultTabBarStyle(t *testing.T) { - style := DefaultTabBarStyle() - - if style.ActiveColor == "" { - t.Error("DefaultTabBarStyle() ActiveColor should not be empty") - } - if style.InactiveColor == "" { - t.Error("DefaultTabBarStyle() InactiveColor should not be empty") - } - if !style.ShowSeparator { - t.Error("DefaultTabBarStyle() ShowSeparator should be true") - } - if !style.ActiveBold { - t.Error("DefaultTabBarStyle() ActiveBold should be true") - } - if style.TabSpacing < 0 { - t.Error("DefaultTabBarStyle() TabSpacing should be >= 0") - } -} - -func TestMinimalTabBarStyle(t *testing.T) { - style := MinimalTabBarStyle() - - if style.ShowSeparator { - t.Error("MinimalTabBarStyle() ShowSeparator should be false") - } - if !style.ActiveUnderline { - t.Error("MinimalTabBarStyle() ActiveUnderline should be true") - } - if style.ActiveColor == "" { - t.Error("MinimalTabBarStyle() ActiveColor should not be empty") - } -} - -func TestDashboardTabs(t *testing.T) { - tabs := DashboardTabs() - - if len(tabs) != 4 { - t.Fatalf("DashboardTabs() returned %d tabs, want 4", len(tabs)) - } - - expectedTabs := []struct { - ID int - Label string - Icon string - }{ - {0, "Dashboard", "🏠"}, - {1, "Services", "📡"}, - {2, "Workspace", "📁"}, - {3, "Config", "⚙️"}, - } - - for i, expected := range expectedTabs { - if tabs[i].ID != expected.ID { - t.Errorf("Tab %d: ID = %d, want %d", i, tabs[i].ID, expected.ID) - } - if tabs[i].Label != expected.Label { - t.Errorf("Tab %d: Label = %q, want %q", i, tabs[i].Label, expected.Label) - } - if tabs[i].Icon != expected.Icon { - t.Errorf("Tab %d: Icon = %q, want %q", i, tabs[i].Icon, expected.Icon) - } - } -} - -func TestTabBar_Render_LabelTruncation(t *testing.T) { - // Create tabs with very long labels - tabs := []TabItem{ - {ID: 0, Label: "VeryLongDashboardLabel", Icon: "🏠"}, - {ID: 1, Label: "VeryLongServicesLabel", Icon: "📡"}, - } - - // Set very narrow width to force truncation - tabBar := NewTabBar(tabs, 0).SetWidth(20) - output := tabBar.Render() - - if len(output) == 0 { - t.Fatal("Render() returned empty string") - } - - // Should contain ellipsis for truncated labels - // Note: This is an approximation test, actual truncation depends on character widths - if strings.Contains(output, "VeryLongDashboardLabel") { - // If width is really narrow, label should be truncated - // This might not trigger if width calculation allows full label - t.Log("Label not truncated - width may be sufficient") - } -} - -func TestTabBar_Render_ZeroWidth(t *testing.T) { - tabs := DashboardTabs() - tabBar := NewTabBar(tabs, 0).SetWidth(0) - output := tabBar.Render() - - // Should still render something (graceful handling) - // At minimum should not panic - if len(output) == 0 { - t.Log("Zero width produces empty output - acceptable") - } -} - -func TestTabBar_Render_SingleTab(t *testing.T) { - tabs := []TabItem{{ID: 0, Label: "Only", Icon: "⭐"}} - tabBar := NewTabBar(tabs, 0) - output := tabBar.Render() - - if len(output) == 0 { - t.Fatal("Render() returned empty string for single tab") - } - - if !strings.Contains(output, "Only") { - t.Error("Render() output should contain tab label") - } - if !strings.Contains(output, "⭐") { - t.Error("Render() output should contain tab icon") - } -} - -func TestTabBar_Render_TabsWithoutIcons(t *testing.T) { - tabs := []TabItem{ - {ID: 0, Label: "Home", Icon: ""}, - {ID: 1, Label: "Settings", Icon: ""}, - } - - tabBar := NewTabBar(tabs, 0) - output := tabBar.Render() - - if len(output) == 0 { - t.Fatal("Render() returned empty string") - } - - if !strings.Contains(output, "Home") { - t.Error("Render() output should contain Home") - } - if !strings.Contains(output, "Settings") { - t.Error("Render() output should contain Settings") - } -} - -func TestTabBar_Render_CustomSpacing(t *testing.T) { - tabs := DashboardTabs() - style := DefaultTabBarStyle() - style.TabSpacing = 10 // Large spacing - - tabBar := NewTabBarWithStyle(tabs, 0, &style).SetWidth(200) - output := tabBar.Render() - - if len(output) == 0 { - t.Fatal("Render() returned empty string") - } - - // Just verify it renders without error - // Actual spacing is visually verified -} - -func TestTabBar_Render_MultipleActiveIndices(t *testing.T) { - tabs := DashboardTabs() - - // Test each tab as active - for i := 0; i < len(tabs); i++ { - tabBar := NewTabBar(tabs, i) - output := tabBar.Render() - - if len(output) == 0 { - t.Errorf("Render() returned empty string for active index %d", i) - } - - // Verify expected tab is in output - if !strings.Contains(output, tabs[i].Label) { - t.Errorf("Render() output should contain active tab label %q", tabs[i].Label) - } - } -} diff --git a/pkg/ui/components/table.go b/pkg/ui/components/table.go deleted file mode 100644 index ef3093b..0000000 --- a/pkg/ui/components/table.go +++ /dev/null @@ -1,220 +0,0 @@ -package components - -import ( - "strings" - - "github.com/charmbracelet/bubbles/table" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" -) - -// TableStyleConfig defines customizable table appearance -type TableStyleConfig struct { - HeaderColor lipgloss.Color - SelectedBG lipgloss.Color - SelectedFG lipgloss.Color - BorderColor lipgloss.Color - ShowBorder bool - HeaderBold bool - Padding int -} - -// DefaultTableStyle returns the default table style configuration -func DefaultTableStyle() TableStyleConfig { - return TableStyleConfig{ - HeaderColor: lipgloss.Color("#00ADD8"), - SelectedBG: lipgloss.Color("#00ADD8"), - SelectedFG: lipgloss.Color("229"), - BorderColor: lipgloss.Color("#00ADD8"), - ShowBorder: true, - HeaderBold: true, - Padding: 1, - } -} - -// TableStyle defines the visual style for tables -var TableStyle = table.Styles{ - Header: lipgloss.NewStyle(). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("#00ADD8")). - BorderBottom(true). - Bold(true), - Selected: lipgloss.NewStyle(). - Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("#00ADD8")). - Bold(false), -} - -// CreateTableStyles creates table styles from configuration -func CreateTableStyles(config *TableStyleConfig) table.Styles { - styles := table.Styles{ - Header: lipgloss.NewStyle(). - Foreground(config.HeaderColor). - Bold(config.HeaderBold). - Padding(0, config.Padding), - Selected: lipgloss.NewStyle(). - Foreground(config.SelectedFG). - Background(config.SelectedBG). - Bold(false), - } - - if config.ShowBorder { - styles.Header = styles.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(config.BorderColor). - BorderBottom(true) - } - - return styles -} - -// ColumnAlignment defines text alignment for table columns -type ColumnAlignment int - -const ( - AlignLeft ColumnAlignment = iota - AlignCenter - AlignRight -) - -// ColumnDef defines an enhanced column with alignment -type ColumnDef struct { - Title string - Width int - Alignment ColumnAlignment -} - -// AutoSizeColumns calculates optimal column widths based on content -func AutoSizeColumns(headers []string, rows [][]string, maxWidth, minColWidth int) []table.Column { - if len(headers) == 0 { - return []table.Column{} - } - - numCols := len(headers) - widths := make([]int, numCols) - - // Initialize with header widths - for i, header := range headers { - widths[i] = lipgloss.Width(header) - } - - // Check all rows for max width per column - for _, row := range rows { - for i := 0; i < numCols && i < len(row); i++ { - cellLen := lipgloss.Width(row[i]) - if cellLen > widths[i] { - widths[i] = cellLen - } - } - } - - // Apply minimum width - for i := range widths { - if widths[i] < minColWidth { - widths[i] = minColWidth - } - } - - // Calculate total width needed - totalWidth := 0 - for _, w := range widths { - totalWidth += w - } - totalWidth += (numCols - 1) * 3 // Account for column separators - - // Scale down if exceeds max width - if maxWidth > 0 && totalWidth > maxWidth { - scale := float64(maxWidth) / float64(totalWidth) - for i := range widths { - widths[i] = int(float64(widths[i]) * scale) - if widths[i] < minColWidth { - widths[i] = minColWidth - } - } - } - - // Create columns - columns := make([]table.Column, numCols) - for i, header := range headers { - columns[i] = table.Column{ - Title: header, - Width: widths[i], - } - } - - return columns -} - -// AlignCell aligns text within a cell based on alignment and width -func AlignCell(text string, width int, alignment ColumnAlignment) string { - textLen := lipgloss.Width(text) - if textLen >= width { - return ansi.Truncate(text, width, "") - } - - padding := width - textLen - switch alignment { - case AlignCenter: - leftPad := padding / 2 - rightPad := padding - leftPad - return strings.Repeat(" ", leftPad) + text + strings.Repeat(" ", rightPad) - case AlignRight: - return strings.Repeat(" ", padding) + text - default: // AlignLeft - return text + strings.Repeat(" ", padding) - } -} - -// FormatRowsWithAlignment formats rows with per-column alignment -func FormatRowsWithAlignment(rows [][]string, columns []ColumnDef) []table.Row { - formatted := make([]table.Row, len(rows)) - for i, row := range rows { - formattedRow := make([]string, len(columns)) - for j, col := range columns { - if j < len(row) { - formattedRow[j] = AlignCell(row[j], col.Width, col.Alignment) - } else { - formattedRow[j] = strings.Repeat(" ", col.Width) - } - } - formatted[i] = table.Row(formattedRow) - } - return formatted -} - -// NewTable creates a new table with default styling -func NewTable(columns []table.Column, rows []table.Row) table.Model { - t := table.New( - table.WithColumns(columns), - table.WithRows(rows), - table.WithFocused(false), - table.WithHeight(10), - ) - t.SetStyles(TableStyle) - return t -} - -// NewFocusedTable creates a table that can be navigated -func NewFocusedTable(columns []table.Column, rows []table.Row, height int) table.Model { - t := table.New( - table.WithColumns(columns), - table.WithRows(rows), - table.WithFocused(true), - table.WithHeight(height), - ) - t.SetStyles(TableStyle) - return t -} - -// Column creates a table column -func Column(title string, width int) table.Column { - return table.Column{ - Title: title, - Width: width, - } -} - -// Row creates a table row -func Row(values ...string) table.Row { - return table.Row(values) -} diff --git a/pkg/ui/components/table/datatable.go b/pkg/ui/components/table/datatable.go deleted file mode 100644 index a0a9714..0000000 --- a/pkg/ui/components/table/datatable.go +++ /dev/null @@ -1,250 +0,0 @@ -// Package table provides interactive data table components for the UI engine. -package table - -import ( - "sort" - "strings" - - "github.com/charmbracelet/bubbles/key" - bubbletable "github.com/charmbracelet/bubbles/table" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// DataTable is an interactive table component with sorting and selection. -// -// Features: -// - Sortable columns (click or press 's' to sort) -// - Row selection with keyboard navigation -// - Profile-themed styling -// - Responsive width adjustment -// - Pagination support -// -// Usage: -// -// columns := []Column{ -// {Title: "Name", Width: 20}, -// {Title: "Status", Width: 10}, -// {Title: "Port", Width: 8}, -// } -// -// rows := []Row{ -// {"postgres", "running", "5432"}, -// {"redis", "stopped", "6379"}, -// } -// -// table := NewDataTable(columns, rows, theme) -// table.SetWidth(80) -type DataTable struct { - table bubbletable.Model - columns []Column - rows []Row - sortColumn int - sortAscending bool - theme *themes.Theme - width int - height int -} - -// Column defines a table column with title and width. -type Column struct { - Title string - Width int -} - -// Row represents a single table row (array of cell values). -type Row []string - -// NewDataTable creates a new DataTable with the given columns and rows. -// -// The table is initialized with profile-themed styles and default dimensions. -func NewDataTable(columns []Column, rows []Row, theme *themes.Theme) *DataTable { - // Convert our columns to bubbles table columns - bubbleColumns := make([]bubbletable.Column, len(columns)) - for i, col := range columns { - bubbleColumns[i] = bubbletable.Column{ - Title: col.Title, - Width: col.Width, - } - } - - // Convert rows to bubbles table rows - bubbleRows := make([]bubbletable.Row, len(rows)) - for i, row := range rows { - bubbleRows[i] = bubbletable.Row(row) - } - - // Create bubbles table - t := bubbletable.New( - bubbletable.WithColumns(bubbleColumns), - bubbletable.WithRows(bubbleRows), - bubbletable.WithFocused(true), - bubbletable.WithHeight(10), - ) - - // Apply theme styles - s := bubbletable.DefaultStyles() - if theme != nil { - colors := theme.Colors - s.Header = s.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(colors.PrimaryColor()). - BorderBottom(true). - Bold(true). - Foreground(colors.PrimaryColor()) - - s.Selected = s.Selected. - Foreground(colors.BackgroundColor()). - Background(colors.PrimaryColor()). - Bold(false) - } - t.SetStyles(s) - - return &DataTable{ - table: t, - columns: columns, - rows: rows, - sortColumn: -1, // No sorting by default - sortAscending: true, - theme: theme, - width: 80, - height: 10, - } -} - -// Init implements tea.Model. -func (dt *DataTable) Init() tea.Cmd { - return nil -} - -// Update implements tea.Model to handle keyboard input. -func (dt *DataTable) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - if keyMsg, ok := msg.(tea.KeyMsg); ok { - switch { - case key.Matches(keyMsg, key.NewBinding(key.WithKeys("s"))): - // Sort by current column (use first column if none selected) - if dt.sortColumn == -1 { - dt.sortColumn = 0 - } - dt.Sort(dt.sortColumn) - return dt, nil - - case key.Matches(keyMsg, key.NewBinding(key.WithKeys("1", "2", "3", "4", "5", "6", "7", "8", "9"))): - // Sort by column number (1-based) - colNum := int(keyMsg.String()[0] - '0') - if colNum > 0 && colNum <= len(dt.columns) { - dt.Sort(colNum - 1) - return dt, nil - } - } - } - - // Pass other messages to underlying table - dt.table, cmd = dt.table.Update(msg) - return dt, cmd -} - -// View implements tea.Model to render the table. -func (dt *DataTable) View() string { - return dt.table.View() -} - -// SetWidth adjusts the table width and redistributes column widths. -func (dt *DataTable) SetWidth(width int) { - dt.width = width - dt.table.SetWidth(width) -} - -// SetHeight adjusts the visible row count. -func (dt *DataTable) SetHeight(height int) { - dt.height = height - dt.table.SetHeight(height) -} - -// Sort sorts the table by the specified column index. -// -// Toggles between ascending and descending if sorting the same column again. -func (dt *DataTable) Sort(columnIndex int) { - if columnIndex < 0 || columnIndex >= len(dt.columns) { - return - } - - // Toggle sort direction if same column - if dt.sortColumn == columnIndex { - dt.sortAscending = !dt.sortAscending - } else { - dt.sortColumn = columnIndex - dt.sortAscending = true - } - - // Sort rows - sortedRows := make([]Row, len(dt.rows)) - copy(sortedRows, dt.rows) - - sort.SliceStable(sortedRows, func(i, j int) bool { - if columnIndex >= len(sortedRows[i]) || columnIndex >= len(sortedRows[j]) { - return false - } - - valI := sortedRows[i][columnIndex] - valJ := sortedRows[j][columnIndex] - - if dt.sortAscending { - return strings.ToLower(valI) < strings.ToLower(valJ) - } - return strings.ToLower(valI) > strings.ToLower(valJ) - }) - - // Update table with sorted rows - bubbleRows := make([]bubbletable.Row, len(sortedRows)) - for i, row := range sortedRows { - bubbleRows[i] = bubbletable.Row(row) - } - dt.table.SetRows(bubbleRows) - dt.rows = sortedRows -} - -// SelectedRow returns the currently selected row, or nil if no selection. -func (dt *DataTable) SelectedRow() Row { - cursor := dt.table.Cursor() - if cursor >= 0 && cursor < len(dt.rows) { - return dt.rows[cursor] - } - return nil -} - -// SetRows updates the table data while preserving sort state. -func (dt *DataTable) SetRows(rows []Row) { - dt.rows = rows - - // Re-apply sort if active - if dt.sortColumn >= 0 { - dt.Sort(dt.sortColumn) - } else { - // Update table with new rows - bubbleRows := make([]bubbletable.Row, len(rows)) - for i, row := range rows { - bubbleRows[i] = bubbletable.Row(row) - } - dt.table.SetRows(bubbleRows) - } -} - -// Cursor returns the current cursor position (0-indexed). -func (dt *DataTable) Cursor() int { - return dt.table.Cursor() -} - -// RowCount returns the total number of rows in the table. -func (dt *DataTable) RowCount() int { - return len(dt.rows) -} - -// Keybindings returns the keyboard shortcuts for the table. -func (dt *DataTable) Keybindings() string { - return "↑/k: up • ↓/j: down • s: sort • 1-9: sort by column" -} diff --git a/pkg/ui/components/table_test.go b/pkg/ui/components/table_test.go deleted file mode 100644 index 4714e8c..0000000 --- a/pkg/ui/components/table_test.go +++ /dev/null @@ -1,688 +0,0 @@ -package components - -import ( - "strings" - "testing" - - "github.com/charmbracelet/bubbles/table" - "github.com/charmbracelet/lipgloss" -) - -func TestDefaultTableStyle(t *testing.T) { - style := DefaultTableStyle() - - if style.HeaderColor == "" { - t.Error("HeaderColor should not be empty") - } - if style.SelectedBG == "" { - t.Error("SelectedBG should not be empty") - } - if style.SelectedFG == "" { - t.Error("SelectedFG should not be empty") - } - if !style.ShowBorder { - t.Error("ShowBorder should be true by default") - } - if !style.HeaderBold { - t.Error("HeaderBold should be true by default") - } - if style.Padding != 1 { - t.Errorf("Padding = %d, want 1", style.Padding) - } -} - -func TestCreateTableStyles(t *testing.T) { - config := &TableStyleConfig{ - HeaderColor: "#FF0000", - SelectedBG: "#00FF00", - SelectedFG: "#0000FF", - BorderColor: "#FFFF00", - ShowBorder: true, - HeaderBold: true, - Padding: 2, - } - - styles := CreateTableStyles(config) - - // Verify styles were created (basic existence check) - if styles.Header.GetBold() != config.HeaderBold { - t.Errorf("Header bold = %v, want %v", styles.Header.GetBold(), config.HeaderBold) - } -} - -func TestCreateTableStyles_NoBorder(t *testing.T) { - config := &TableStyleConfig{ - HeaderColor: "#FF0000", - SelectedBG: "#00FF00", - SelectedFG: "#0000FF", - ShowBorder: false, - HeaderBold: false, - Padding: 1, - } - - styles := CreateTableStyles(config) - - if styles.Header.GetBold() != false { - t.Error("Header should not be bold when HeaderBold is false") - } -} - -func TestAutoSizeColumns(t *testing.T) { - tests := []struct { - name string - headers []string - rows [][]string - maxWidth int - minColWidth int - wantCols int - checkWidths bool - }{ - { - name: "empty headers", - headers: []string{}, - rows: [][]string{}, - maxWidth: 80, - minColWidth: 5, - wantCols: 0, - }, - { - name: "simple table", - headers: []string{"Name", "Age", "City"}, - rows: [][]string{{"Alice", "30", "NYC"}, {"Bob", "25", "LA"}}, - maxWidth: 80, - minColWidth: 5, - wantCols: 3, - checkWidths: true, - }, - { - name: "wide content", - headers: []string{"Short", "VeryLongColumnHeader"}, - rows: [][]string{{"A", "This is very long content"}}, - maxWidth: 0, // No limit - minColWidth: 3, - wantCols: 2, - checkWidths: true, - }, - { - name: "minimum width enforcement", - headers: []string{"A", "B", "C"}, - rows: [][]string{{"1", "2", "3"}}, - maxWidth: 80, - minColWidth: 10, - wantCols: 3, - checkWidths: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - columns := AutoSizeColumns(tt.headers, tt.rows, tt.maxWidth, tt.minColWidth) - - if len(columns) != tt.wantCols { - t.Errorf("AutoSizeColumns() returned %d columns, want %d", len(columns), tt.wantCols) - } - - if tt.checkWidths && len(columns) > 0 { - for i, col := range columns { - if col.Width < tt.minColWidth { - t.Errorf("Column %d width = %d, want >= %d", i, col.Width, tt.minColWidth) - } - if col.Title != tt.headers[i] { - t.Errorf("Column %d title = %q, want %q", i, col.Title, tt.headers[i]) - } - } - } - }) - } -} - -func TestAutoSizeColumns_MaxWidthScaling(t *testing.T) { - headers := []string{"Column1", "Column2", "Column3"} - rows := [][]string{ - {"Very long content here", "More long content", "Even more content"}, - } - maxWidth := 50 - minColWidth := 5 - - columns := AutoSizeColumns(headers, rows, maxWidth, minColWidth) - - totalWidth := 0 - for _, col := range columns { - totalWidth += col.Width - } - // Add separator space - totalWidth += (len(columns) - 1) * 3 - - if totalWidth > maxWidth+10 { // Allow some tolerance - t.Errorf("Total width %d exceeds maxWidth %d", totalWidth, maxWidth) - } -} - -func TestAlignCell(t *testing.T) { - tests := []struct { - name string - text string - width int - alignment ColumnAlignment - want string - }{ - { - name: "left align", - text: "Hello", - width: 10, - alignment: AlignLeft, - want: "Hello ", - }, - { - name: "center align", - text: "Hi", - width: 10, - alignment: AlignCenter, - want: " Hi ", - }, - { - name: "right align", - text: "Right", - width: 10, - alignment: AlignRight, - want: " Right", - }, - { - name: "text longer than width", - text: "VeryLongText", - width: 5, - alignment: AlignLeft, - want: "VeryL", - }, - { - name: "exact width", - text: "Exact", - width: 5, - alignment: AlignLeft, - want: "Exact", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := AlignCell(tt.text, tt.width, tt.alignment) - if got != tt.want { - t.Errorf("AlignCell() = %q, want %q", got, tt.want) - } - if len(got) > tt.width { - t.Errorf("AlignCell() length = %d, exceeds width %d", len(got), tt.width) - } - }) - } -} - -func TestFormatRowsWithAlignment(t *testing.T) { - columns := []ColumnDef{ - {Title: "Name", Width: 10, Alignment: AlignLeft}, - {Title: "Age", Width: 5, Alignment: AlignRight}, - {Title: "City", Width: 8, Alignment: AlignCenter}, - } - - rows := [][]string{ - {"Alice", "30", "NYC"}, - {"Bob", "25", "LA"}, - } - - formatted := FormatRowsWithAlignment(rows, columns) - - if len(formatted) != len(rows) { - t.Errorf("FormatRowsWithAlignment() returned %d rows, want %d", len(formatted), len(rows)) - } - - for i, row := range formatted { - if len(row) != len(columns) { - t.Errorf("Row %d has %d cells, want %d", i, len(row), len(columns)) - } - } -} - -func TestFormatRowsWithAlignment_MissingCells(t *testing.T) { - columns := []ColumnDef{ - {Title: "Col1", Width: 5, Alignment: AlignLeft}, - {Title: "Col2", Width: 5, Alignment: AlignLeft}, - {Title: "Col3", Width: 5, Alignment: AlignLeft}, - } - - rows := [][]string{ - {"A", "B"}, // Missing third column - {"X"}, // Missing second and third columns - } - - formatted := FormatRowsWithAlignment(rows, columns) - - for i, row := range formatted { - if len(row) != len(columns) { - t.Errorf("Row %d has %d cells, want %d", i, len(row), len(columns)) - } - // Check that missing cells are filled with spaces - for j := len(rows[i]); j < len(columns); j++ { - if strings.TrimSpace(row[j]) != "" { - t.Errorf("Row %d, cell %d should be empty, got %q", i, j, row[j]) - } - } - } -} - -func TestNewTable(t *testing.T) { - columns := []table.Column{ - {Title: "Name", Width: 10}, - {Title: "Age", Width: 5}, - } - rows := []table.Row{ - {"Alice", "30"}, - {"Bob", "25"}, - } - - tbl := NewTable(columns, rows) - - if len(tbl.Rows()) != len(rows) { - t.Errorf("Table has %d rows, want %d", len(tbl.Rows()), len(rows)) - } -} - -func TestNewFocusedTable(t *testing.T) { - columns := []table.Column{ - {Title: "Name", Width: 10}, - {Title: "Age", Width: 5}, - } - rows := []table.Row{ - {"Alice", "30"}, - {"Bob", "25"}, - } - height := 5 - - tbl := NewFocusedTable(columns, rows, height) - - if len(tbl.Rows()) != len(rows) { - t.Errorf("Table has %d rows, want %d", len(tbl.Rows()), len(rows)) - } -} - -func TestColumnAlignment_Constants(t *testing.T) { - tests := []struct { - name string - value ColumnAlignment - }{ - {"AlignLeft", AlignLeft}, - {"AlignCenter", AlignCenter}, - {"AlignRight", AlignRight}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Just verify the constants are defined and have different values - if tt.value < 0 { - t.Errorf("%s has negative value", tt.name) - } - }) - } - - // Verify they have different values - if AlignLeft == AlignCenter || AlignCenter == AlignRight || AlignLeft == AlignRight { - t.Error("Alignment constants should have different values") - } -} - -func TestTableWithEmptyData(t *testing.T) { - columns := []table.Column{} - rows := []table.Row{} - - tbl := NewTable(columns, rows) - - if tbl.Rows() == nil { - t.Error("Table with empty data should not have nil rows") - } -} - -func TestTableWithLargeDataset(t *testing.T) { - columns := []table.Column{ - {Title: "ID", Width: 5}, - {Title: "Name", Width: 20}, - {Title: "Value", Width: 10}, - } - - // Create 1000 rows - rows := make([]table.Row, 1000) - for i := 0; i < 1000; i++ { - rows[i] = table.Row{ - string(rune('0' + (i % 10))), - "Item " + string(rune('A'+(i%26))), - "Value", - } - } - - tbl := NewTable(columns, rows) - - if len(tbl.Rows()) != 1000 { - t.Errorf("Table has %d rows, want 1000", len(tbl.Rows())) - } -} - -func TestAutoSizeColumns_EmptyRows(t *testing.T) { - headers := []string{"Column1", "Column2"} - rows := [][]string{} - maxWidth := 80 - minColWidth := 5 - - columns := AutoSizeColumns(headers, rows, maxWidth, minColWidth) - - if len(columns) != len(headers) { - t.Errorf("AutoSizeColumns() returned %d columns, want %d", len(columns), len(headers)) - } - - for i, col := range columns { - if col.Width < minColWidth { - t.Errorf("Column %d width = %d, want >= %d", i, col.Width, minColWidth) - } - } -} - -// ANSI-styled test strings for comprehensive width calculation testing -var ( - styledHeader = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")).Render("Name") - styledBoldHeader = lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF00")).Bold(true).Render("Status") - styledCell = lipgloss.NewStyle().Foreground(lipgloss.Color("#0000FF")).Render("Alice") - styledBoldCell = lipgloss.NewStyle().Bold(true).Render("Active") - longStyledCell = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF00FF")).Render("Very long styled content here") - mixedStyledCell = styledCell + " " + styledBoldCell -) - -func TestAutoSizeColumns_WithANSI(t *testing.T) { - tests := []struct { - name string - headers []string - rows [][]string - maxWidth int - minColWidth int - }{ - { - name: "styled headers", - headers: []string{styledHeader, styledBoldHeader, "Plain"}, - rows: [][]string{{"Alice", "Active", "NYC"}, {"Bob", "Inactive", "LA"}}, - maxWidth: 80, - minColWidth: 5, - }, - { - name: "styled cells", - headers: []string{"Name", "Status", "City"}, - rows: [][]string{{styledCell, styledBoldCell, "NYC"}, {"Bob", "Inactive", "LA"}}, - maxWidth: 80, - minColWidth: 5, - }, - { - name: "mixed styled headers and cells", - headers: []string{styledHeader, styledBoldHeader, "City"}, - rows: [][]string{{styledCell, styledBoldCell, "NYC"}, {styledCell, "Inactive", "LA"}}, - maxWidth: 80, - minColWidth: 5, - }, - { - name: "long styled cells", - headers: []string{"Name", "Description"}, - rows: [][]string{{styledCell, longStyledCell}, {"Bob", "Short"}}, - maxWidth: 80, - minColWidth: 5, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - columns := AutoSizeColumns(tt.headers, tt.rows, tt.maxWidth, tt.minColWidth) - - // Verify correct number of columns - if len(columns) != len(tt.headers) { - t.Errorf("AutoSizeColumns() returned %d columns, want %d", len(columns), len(tt.headers)) - } - - // Verify each column width is based on visual width, not byte length - for i, col := range columns { - headerWidth := lipgloss.Width(tt.headers[i]) - - // Column width should be at least as wide as the header - if col.Width < headerWidth && col.Width >= tt.minColWidth { - // This is acceptable if minColWidth constraint was applied - continue - } - - // Verify minimum width constraint - if col.Width < tt.minColWidth { - t.Errorf("Column %d width = %d, want >= %d", i, col.Width, tt.minColWidth) - } - } - }) - } -} - -func TestAlignCell_WithANSI(t *testing.T) { - tests := []struct { - name string - text string - width int - alignment ColumnAlignment - }{ - { - name: "styled text left align", - text: styledCell, - width: 15, - alignment: AlignLeft, - }, - { - name: "styled text center align", - text: styledBoldCell, - width: 15, - alignment: AlignCenter, - }, - { - name: "styled text right align", - text: styledCell, - width: 15, - alignment: AlignRight, - }, - { - name: "long styled text truncated", - text: longStyledCell, - width: 10, - alignment: AlignLeft, - }, - { - name: "mixed styled text", - text: mixedStyledCell, - width: 20, - alignment: AlignCenter, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := AlignCell(tt.text, tt.width, tt.alignment) - resultWidth := lipgloss.Width(result) - - // Verify visual width matches expected width - if resultWidth > tt.width { - t.Errorf("AlignCell() visual width = %d, want <= %d", resultWidth, tt.width) - } - - // For truncated text, verify it doesn't exceed width - if lipgloss.Width(tt.text) > tt.width { - if resultWidth > tt.width { - t.Errorf("AlignCell() should truncate to visual width %d, got %d", tt.width, resultWidth) - } - } - }) - } -} - -func TestAlignCell_ANSIAlignment(t *testing.T) { - // Test that alignment respects visual width, not byte length - text := styledCell // "Alice" with ANSI codes - width := 15 - - tests := []struct { - name string - alignment ColumnAlignment - validate func(string) bool - }{ - { - name: "left aligned styled text", - alignment: AlignLeft, - validate: func(result string) bool { - // Result should have padding on the right - return lipgloss.Width(result) == width - }, - }, - { - name: "center aligned styled text", - alignment: AlignCenter, - validate: func(result string) bool { - // Result should have padding on both sides - return lipgloss.Width(result) == width - }, - }, - { - name: "right aligned styled text", - alignment: AlignRight, - validate: func(result string) bool { - // Result should have padding on the left - return lipgloss.Width(result) == width - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := AlignCell(text, width, tt.alignment) - if !tt.validate(result) { - t.Errorf("AlignCell() validation failed for %s alignment", tt.name) - } - }) - } -} - -func TestFormatRowsWithAlignment_WithANSI(t *testing.T) { - tests := []struct { - name string - columns []ColumnDef - rows [][]string - }{ - { - name: "styled cells with alignment", - columns: []ColumnDef{ - {Title: "Name", Width: 15, Alignment: AlignLeft}, - {Title: "Status", Width: 10, Alignment: AlignCenter}, - {Title: "City", Width: 10, Alignment: AlignRight}, - }, - rows: [][]string{ - {styledCell, styledBoldCell, "NYC"}, - {"Bob", "Inactive", "LA"}, - }, - }, - { - name: "mixed styled content", - columns: []ColumnDef{ - {Title: styledHeader, Width: 15, Alignment: AlignLeft}, - {Title: styledBoldHeader, Width: 12, Alignment: AlignLeft}, - }, - rows: [][]string{ - {styledCell, styledBoldCell}, - {mixedStyledCell, "Plain"}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - formatted := FormatRowsWithAlignment(tt.rows, tt.columns) - - // Verify row count - if len(formatted) != len(tt.rows) { - t.Errorf("FormatRowsWithAlignment() returned %d rows, want %d", len(formatted), len(tt.rows)) - } - - // Verify each cell respects visual width - for i, row := range formatted { - if len(row) != len(tt.columns) { - t.Errorf("Row %d has %d cells, want %d", i, len(row), len(tt.columns)) - } - - for j, cell := range row { - cellWidth := lipgloss.Width(cell) - expectedWidth := tt.columns[j].Width - - // Cell visual width should match column width (accounting for alignment padding) - if cellWidth > expectedWidth { - t.Errorf("Row %d, cell %d visual width = %d, want <= %d", i, j, cellWidth, expectedWidth) - } - } - } - }) - } -} - -func TestAutoSizeColumns_ANSIEdgeCases(t *testing.T) { - tests := []struct { - name string - headers []string - rows [][]string - maxWidth int - minColWidth int - }{ - { - name: "all styled content", - headers: []string{styledHeader, styledBoldHeader}, - rows: [][]string{{styledCell, styledBoldCell}, {styledCell, styledBoldCell}}, - maxWidth: 80, - minColWidth: 5, - }, - { - name: "empty styled strings", - headers: []string{"", styledHeader}, - rows: [][]string{{"", styledCell}}, - maxWidth: 80, - minColWidth: 5, - }, - { - name: "very long styled content with scaling", - headers: []string{styledHeader, styledBoldHeader}, - rows: [][]string{{longStyledCell, longStyledCell}}, - maxWidth: 40, - minColWidth: 5, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - columns := AutoSizeColumns(tt.headers, tt.rows, tt.maxWidth, tt.minColWidth) - - // Verify columns were created - if len(columns) != len(tt.headers) { - t.Errorf("AutoSizeColumns() returned %d columns, want %d", len(columns), len(tt.headers)) - } - - // Verify width constraints - for i, col := range columns { - if col.Width < tt.minColWidth { - t.Errorf("Column %d width = %d, want >= %d", i, col.Width, tt.minColWidth) - } - } - - // Verify total width respects maximum - if tt.maxWidth > 0 { - totalWidth := 0 - for _, col := range columns { - totalWidth += col.Width - } - totalWidth += (len(columns) - 1) * 3 // separators - - // Allow some tolerance due to rounding - if totalWidth > tt.maxWidth+10 { - t.Errorf("Total width %d exceeds maxWidth %d", totalWidth, tt.maxWidth) - } - } - }) - } -} diff --git a/pkg/ui/components/theme_helpers.go b/pkg/ui/components/theme_helpers.go deleted file mode 100644 index 5bcc32c..0000000 --- a/pkg/ui/components/theme_helpers.go +++ /dev/null @@ -1,17 +0,0 @@ -package components - -import ( - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// getDefaultTheme returns the default theme, with fallback. -// This helper is shared across all components for consistency. -func getDefaultTheme() *themes.Theme { - theme, _ := themes.GetDefault() - if theme == nil { - // Try loading enterprise theme explicitly - loader := themes.NewLoader() - theme, _ = loader.Load("enterprise") - } - return theme -} diff --git a/pkg/ui/components/toast.go b/pkg/ui/components/toast.go deleted file mode 100644 index 8071882..0000000 --- a/pkg/ui/components/toast.go +++ /dev/null @@ -1,473 +0,0 @@ -package components - -import ( - "strings" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// Note: Toast uses the existing Severity type from error.go -// (SeverityError, SeverityWarning, SeverityInfo, SeveritySuccess) - -// Toast represents an overlay notification message. -// Designed for non-blocking status updates in dashboard mode. -// -// Example usage: -// -// toast := NewToast("Operation successful", SeveritySuccess). -// SetDuration(3 * time.Second). -// SetPosition(ToastPositionTopRight) -// fmt.Println(toast.Render(terminalWidth, terminalHeight)) -type Toast struct { - Message string - Severity Severity - Duration time.Duration - Position ToastPosition - Width int // Toast width (0 for auto) - Icon string - ShowIcon bool - ShowBorder bool - Padding int - StartTime time.Time - Dismissed bool - theme *themes.Theme // Optional theme for colors -} - -// ToastPosition indicates where the toast should appear. -type ToastPosition int - -const ( - // ToastPositionTopRight shows toast in top-right corner. - ToastPositionTopRight ToastPosition = iota - // ToastPositionTopCenter shows toast in top-center. - ToastPositionTopCenter - // ToastPositionBottomRight shows toast in bottom-right corner. - ToastPositionBottomRight - // ToastPositionBottomCenter shows toast in bottom-center. - ToastPositionBottomCenter -) - -// NewToast creates a new toast notification with default settings. -// Deprecated: Use NewThemedToast for profile-aware theming. -// This version maintains backward compatibility by using default theme. -func NewToast(message string, severity Severity) *Toast { - return NewThemedToast(message, severity, nil) -} - -// NewThemedToast creates a new toast notification with default settings using theme colors. -// If theme is nil, falls back to default theme. -func NewThemedToast(message string, severity Severity, theme *themes.Theme) *Toast { - return &Toast{ - Message: message, - Severity: severity, - Duration: 3 * time.Second, // Default 3s auto-dismiss - Position: ToastPositionTopRight, - Width: 0, // Auto width - Icon: getDefaultIcon(severity), - ShowIcon: true, - ShowBorder: true, - Padding: 1, - StartTime: time.Now(), - Dismissed: false, - theme: theme, - } -} - -// SetDuration sets the auto-dismiss duration. -func (t *Toast) SetDuration(d time.Duration) *Toast { - t.Duration = d - return t -} - -// SetPosition sets the toast position. -func (t *Toast) SetPosition(pos ToastPosition) *Toast { - t.Position = pos - return t -} - -// SetWidth sets a fixed width (0 for auto). -func (t *Toast) SetWidth(width int) *Toast { - t.Width = width - return t -} - -// SetIcon sets a custom icon. -func (t *Toast) SetIcon(icon string) *Toast { - t.Icon = icon - return t -} - -// Dismiss marks the toast as dismissed. -func (t *Toast) Dismiss() { - t.Dismissed = true -} - -// IsExpired checks if the toast should be auto-dismissed. -func (t *Toast) IsExpired() bool { - return time.Since(t.StartTime) >= t.Duration -} - -// Render returns the toast as a styled string. -func (t *Toast) Render() string { - if t.Dismissed { - return "" - } - - // Build content - var content string - if t.ShowIcon { - content = t.Icon + " " + t.Message - } else { - content = t.Message - } - - // Get severity colors - bgColor, fgColor := t.getSeverityColors() - - // Create style - style := lipgloss.NewStyle(). - Foreground(fgColor). - Background(bgColor). - Padding(t.Padding). - Bold(true) - - if t.ShowBorder { - style = style.Border(lipgloss.RoundedBorder()).BorderForeground(bgColor) - } - - if t.Width > 0 { - style = style.Width(t.Width) - } - - return style.Render(content) -} - -// PlaceOverlay positions the toast on top of base content. -// This is ANSI-aware and handles colored text correctly. -func (t *Toast) PlaceOverlay(baseContent string, termWidth, termHeight int) string { - if t.Dismissed { - return baseContent - } - - toastStr := t.Render() - toastLines := strings.Split(toastStr, "\n") - - // Calculate toast dimensions using ANSI-aware width - toastWidth := 0 - for _, line := range toastLines { - w := lipgloss.Width(line) - if w > toastWidth { - toastWidth = w - } - } - toastHeight := len(toastLines) - - // Split base content into lines - baseLines := strings.Split(baseContent, "\n") - - // Ensure we have enough lines - for len(baseLines) < termHeight { - baseLines = append(baseLines, strings.Repeat(" ", termWidth)) - } - - // Calculate position based on ToastPosition - var startRow, startCol int - switch t.Position { - case ToastPositionTopRight: - startRow = 1 - startCol = termWidth - toastWidth - 2 - - case ToastPositionTopCenter: - startRow = 1 - startCol = (termWidth - toastWidth) / 2 - - case ToastPositionBottomRight: - startRow = termHeight - toastHeight - 2 - startCol = termWidth - toastWidth - 2 - - case ToastPositionBottomCenter: - startRow = termHeight - toastHeight - 2 - startCol = (termWidth - toastWidth) / 2 - } - - // Ensure position is within bounds - if startRow < 0 { - startRow = 0 - } - if startCol < 0 { - startCol = 0 - } - if startRow+toastHeight > len(baseLines) { - startRow = len(baseLines) - toastHeight - } - - // Overlay toast onto base content - for i, toastLine := range toastLines { - rowIdx := startRow + i - if rowIdx >= len(baseLines) { - break - } - - // Get the base line - baseLine := baseLines[rowIdx] - baseWidth := lipgloss.Width(baseLine) - - // Ensure base line is wide enough - if baseWidth < termWidth { - baseLine += strings.Repeat(" ", termWidth-baseWidth) - } - - // Calculate where to insert the toast - // We need to be careful with ANSI codes - overlayedLine := overlayLine(baseLine, toastLine, startCol, termWidth) - baseLines[rowIdx] = overlayedLine - } - - return strings.Join(baseLines, "\n") -} - -// overlayLine overlays toastLine onto baseLine at the given column position. -// This is ANSI-aware and preserves escape codes. -// -//nolint:unparam // maxWidth parameter reserved for future ANSI parsing logic -func overlayLine(baseLine, toastLine string, col, maxWidth int) string { - // Simple approach: split base into visible chars, overlay toast, rejoin - baseRunes := []rune(baseLine) - toastRunes := []rune(toastLine) - - // Calculate actual visible positions (ignoring ANSI codes for simplicity) - // For production, would need proper ANSI parsing - if col+len(toastRunes) > len(baseRunes) { - // Extend base line if needed - baseRunes = append(baseRunes, make([]rune, col+len(toastRunes)-len(baseRunes))...) - } - - // Overlay toast characters - copy(baseRunes[col:], toastRunes) - - return string(baseRunes) -} - -// getSeverityColors returns background and foreground colors for the severity level. -// Uses theme colors if available, otherwise falls back to hardcoded defaults. -func (t *Toast) getSeverityColors() (bgColor, fgColor lipgloss.Color) { - // Get theme colors if available - theme := t.theme - if theme == nil { - theme = getDefaultTheme() - } - - // If theme is available, use theme colors - if theme != nil { - switch t.Severity { - case SeverityError: - return theme.Colors.ErrorColor(), lipgloss.Color("#FFFFFF") - case SeverityWarning: - return theme.Colors.WarningColor(), lipgloss.Color("#000000") - case SeveritySuccess: - return theme.Colors.SuccessColor(), lipgloss.Color("#000000") - case SeverityInfo: - return theme.Colors.InfoColor(), lipgloss.Color("#FFFFFF") - default: - return theme.Colors.MutedColor(), lipgloss.Color("#FFFFFF") - } - } - - // Ultimate fallback to hardcoded colors - switch t.Severity { - case SeverityError: - return lipgloss.Color("#FF4444"), lipgloss.Color("#FFFFFF") - case SeverityWarning: - return lipgloss.Color("#FFB86C"), lipgloss.Color("#000000") - case SeveritySuccess: - return lipgloss.Color("#00E091"), lipgloss.Color("#000000") - case SeverityInfo: - return lipgloss.Color("#00ADD8"), lipgloss.Color("#FFFFFF") - default: - return lipgloss.Color("#6272A4"), lipgloss.Color("#FFFFFF") - } -} - -// getDefaultIcon returns the default icon for a severity level. -func getDefaultIcon(severity Severity) string { - switch severity { - case SeverityError: - return "❌" - case SeverityWarning: - return "⚠️" - case SeveritySuccess: - return "✅" - case SeverityInfo: - return "ℹ️" - default: - return "•" - } -} - -// ToastDismissMsg is a message to dismiss a toast after auto-dismiss timer. -type ToastDismissMsg struct { - ID string -} - -// ToastStack manages multiple toast notifications. -// Maximum 3 visible toasts at once (oldest are hidden). -type ToastStack struct { - Toasts []*Toast - MaxToast int // Default: 3 -} - -// NewToastStack creates a new toast stack. -func NewToastStack() *ToastStack { - return &ToastStack{ - Toasts: []*Toast{}, - MaxToast: 3, - } -} - -// Push adds a new toast to the stack. -func (ts *ToastStack) Push(toast *Toast) { - ts.Toasts = append(ts.Toasts, toast) - - // Keep only the most recent MaxToast toasts - if len(ts.Toasts) > ts.MaxToast { - ts.Toasts = ts.Toasts[len(ts.Toasts)-ts.MaxToast:] - } -} - -// RemoveExpired removes all expired and dismissed toasts. -func (ts *ToastStack) RemoveExpired() { - filtered := []*Toast{} - for _, toast := range ts.Toasts { - if !toast.Dismissed && !toast.IsExpired() { - filtered = append(filtered, toast) - } - } - ts.Toasts = filtered -} - -// Render renders all visible toasts in the stack. -func (ts *ToastStack) Render() []string { - var rendered []string - for _, toast := range ts.Toasts { - if !toast.Dismissed { - rendered = append(rendered, toast.Render()) - } - } - return rendered -} - -// PlaceAllOverlays overlays all visible toasts onto base content. -// Toasts are stacked vertically with spacing. -func (ts *ToastStack) PlaceAllOverlays(baseContent string, termWidth, termHeight int) string { - ts.RemoveExpired() - - result := baseContent - spacing := 1 // Lines between toasts - - // Calculate total height needed - totalHeight := 0 - for _, toast := range ts.Toasts { - if !toast.Dismissed { - toastLines := strings.Split(toast.Render(), "\n") - totalHeight += len(toastLines) + spacing - } - } - - // Position toasts from top-right, stacked vertically - currentY := 1 - for _, toast := range ts.Toasts { - if toast.Dismissed { - continue - } - - // Create a positioned toast for this Y offset - positionedToast := &Toast{ - Message: toast.Message, - Severity: toast.Severity, - Duration: toast.Duration, - Position: ToastPositionTopRight, - Width: toast.Width, - Icon: toast.Icon, - ShowIcon: toast.ShowIcon, - ShowBorder: toast.ShowBorder, - Padding: toast.Padding, - StartTime: toast.StartTime, - Dismissed: toast.Dismissed, - theme: toast.theme, - } - - // Manually position at currentY - result = positionedToast.placeOverlayAt(result, termWidth, termHeight, currentY) - - // Move down for next toast - toastLines := strings.Split(positionedToast.Render(), "\n") - currentY += len(toastLines) + spacing - } - - return result -} - -// placeOverlayAt is a helper that places toast at a specific row. -func (t *Toast) placeOverlayAt(baseContent string, termWidth, termHeight, startRow int) string { - if t.Dismissed { - return baseContent - } - - toastStr := t.Render() - toastLines := strings.Split(toastStr, "\n") - - // Calculate toast width - toastWidth := 0 - for _, line := range toastLines { - w := lipgloss.Width(line) - if w > toastWidth { - toastWidth = w - } - } - - // Split base content - baseLines := strings.Split(baseContent, "\n") - - // Ensure enough lines - for len(baseLines) < termHeight { - baseLines = append(baseLines, strings.Repeat(" ", termWidth)) - } - - // Calculate column (always right-aligned) - startCol := termWidth - toastWidth - 2 - if startCol < 0 { - startCol = 0 - } - - // Overlay toast lines - for i, toastLine := range toastLines { - rowIdx := startRow + i - if rowIdx >= len(baseLines) { - break - } - - baseLine := baseLines[rowIdx] - baseWidth := lipgloss.Width(baseLine) - - // Ensure base line is wide enough - if baseWidth < termWidth { - baseLine += strings.Repeat(" ", termWidth-baseWidth) - } - - overlayedLine := overlayLine(baseLine, toastLine, startCol, termWidth) - baseLines[rowIdx] = overlayedLine - } - - return strings.Join(baseLines, "\n") -} - -// TickDismiss returns a tea.Cmd that sends ToastDismissMsg after duration. -func TickDismiss(duration time.Duration, id string) tea.Cmd { - return tea.Tick(duration, func(time.Time) tea.Msg { - return ToastDismissMsg{ID: id} - }) -} diff --git a/pkg/ui/components/toast_test.go b/pkg/ui/components/toast_test.go deleted file mode 100644 index c005442..0000000 --- a/pkg/ui/components/toast_test.go +++ /dev/null @@ -1,1103 +0,0 @@ -package components - -import ( - "strings" - "testing" - "time" - - "github.com/charmbracelet/lipgloss" -) - -// TestNewToast tests the constructor creates correct defaults for all severity levels. -func TestNewToast(t *testing.T) { - tests := []struct { - name string - message string - severity Severity - wantIcon string - }{ - { - name: "error severity", - message: "An error occurred", - severity: SeverityError, - wantIcon: "❌", - }, - { - name: "warning severity", - message: "Warning message", - severity: SeverityWarning, - wantIcon: "⚠️", - }, - { - name: "info severity", - message: "Info message", - severity: SeverityInfo, - wantIcon: "ℹ️", - }, - { - name: "success severity", - message: "Success message", - severity: SeveritySuccess, - wantIcon: "✅", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - toast := NewToast(tt.message, tt.severity) - - if toast == nil { - t.Fatal("NewToast() returned nil") - } - if toast.Message != tt.message { - t.Errorf("Message = %q, want %q", toast.Message, tt.message) - } - if toast.Severity != tt.severity { - t.Errorf("Severity = %v, want %v", toast.Severity, tt.severity) - } - if toast.Duration != 3*time.Second { - t.Errorf("Duration = %v, want 3s", toast.Duration) - } - if toast.Position != ToastPositionTopRight { - t.Errorf("Position = %v, want ToastPositionTopRight", toast.Position) - } - if toast.Width != 0 { - t.Errorf("Width = %d, want 0", toast.Width) - } - if toast.Icon != tt.wantIcon { - t.Errorf("Icon = %q, want %q", toast.Icon, tt.wantIcon) - } - if !toast.ShowIcon { - t.Error("ShowIcon = false, want true") - } - if !toast.ShowBorder { - t.Error("ShowBorder = false, want true") - } - if toast.Padding != 1 { - t.Errorf("Padding = %d, want 1", toast.Padding) - } - if toast.Dismissed { - t.Error("Dismissed = true, want false") - } - if toast.StartTime.IsZero() { - t.Error("StartTime should be set to current time") - } - }) - } -} - -// TestGetSeverityColors tests color assignment for each severity level. -func TestGetSeverityColors(t *testing.T) { - tests := []struct { - name string - severity Severity - wantBg lipgloss.Color - wantFg lipgloss.Color - }{ - { - name: "error colors", - severity: SeverityError, - wantBg: lipgloss.Color("#FF4444"), - wantFg: lipgloss.Color("#FFFFFF"), - }, - { - name: "warning colors", - severity: SeverityWarning, - wantBg: lipgloss.Color("#FFB86C"), - wantFg: lipgloss.Color("#000000"), - }, - { - name: "success colors", - severity: SeveritySuccess, - wantBg: lipgloss.Color("#00E091"), - wantFg: lipgloss.Color("#000000"), - }, - { - name: "info colors", - severity: SeverityInfo, - wantBg: lipgloss.Color("#BD93F9"), // Theme-based color (enterprise theme) - wantFg: lipgloss.Color("#FFFFFF"), - }, - { - name: "default/unknown severity", - severity: Severity(999), - wantBg: lipgloss.Color("#6272A4"), - wantFg: lipgloss.Color("#FFFFFF"), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - toast := &Toast{Severity: tt.severity} - bg, fg := toast.getSeverityColors() - - if bg != tt.wantBg { - t.Errorf("Background color = %v, want %v", bg, tt.wantBg) - } - if fg != tt.wantFg { - t.Errorf("Foreground color = %v, want %v", fg, tt.wantFg) - } - }) - } -} - -// TestGetDefaultIcon tests icon assignment for each severity level. -func TestGetDefaultIcon(t *testing.T) { - tests := []struct { - name string - severity Severity - want string - }{ - { - name: "error icon", - severity: SeverityError, - want: "❌", - }, - { - name: "warning icon", - severity: SeverityWarning, - want: "⚠️", - }, - { - name: "success icon", - severity: SeveritySuccess, - want: "✅", - }, - { - name: "info icon", - severity: SeverityInfo, - want: "ℹ️", - }, - { - name: "default/unknown severity", - severity: Severity(999), - want: "•", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - icon := getDefaultIcon(tt.severity) - - if icon != tt.want { - t.Errorf("getDefaultIcon(%v) = %q, want %q", tt.severity, icon, tt.want) - } - }) - } -} - -// TestToast_SetDuration tests duration setter with various values. -func TestToast_SetDuration(t *testing.T) { - tests := []struct { - name string - duration time.Duration - }{ - {"1 second", 1 * time.Second}, - {"3 seconds", 3 * time.Second}, - {"5 seconds", 5 * time.Second}, - {"10 seconds", 10 * time.Second}, - {"zero duration", 0}, - {"negative duration", -1 * time.Second}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - toast := NewToast("Test", SeverityInfo) - result := toast.SetDuration(tt.duration) - - if result != toast { - t.Error("SetDuration() should return self for chaining") - } - if toast.Duration != tt.duration { - t.Errorf("Duration = %v, want %v", toast.Duration, tt.duration) - } - }) - } -} - -// TestToast_SetPosition tests position setter for all positions. -func TestToast_SetPosition(t *testing.T) { - tests := []struct { - name string - position ToastPosition - }{ - {"top right", ToastPositionTopRight}, - {"top center", ToastPositionTopCenter}, - {"bottom right", ToastPositionBottomRight}, - {"bottom center", ToastPositionBottomCenter}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - toast := NewToast("Test", SeverityInfo) - result := toast.SetPosition(tt.position) - - if result != toast { - t.Error("SetPosition() should return self for chaining") - } - if toast.Position != tt.position { - t.Errorf("Position = %v, want %v", toast.Position, tt.position) - } - }) - } -} - -// TestToast_SetWidth tests width setter with various values. -func TestToast_SetWidth(t *testing.T) { - tests := []struct { - name string - width int - }{ - {"auto width (0)", 0}, - {"small width", 30}, - {"medium width", 50}, - {"large width", 100}, - {"negative width", -10}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - toast := NewToast("Test", SeverityInfo) - result := toast.SetWidth(tt.width) - - if result != toast { - t.Error("SetWidth() should return self for chaining") - } - if toast.Width != tt.width { - t.Errorf("Width = %d, want %d", toast.Width, tt.width) - } - }) - } -} - -// TestToast_SetIcon tests custom icon setter. -func TestToast_SetIcon(t *testing.T) { - tests := []struct { - name string - icon string - }{ - {"emoji icon", "🎉"}, - {"text icon", "!!!"}, - {"empty icon", ""}, - {"unicode icon", "★"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - toast := NewToast("Test", SeverityInfo) - result := toast.SetIcon(tt.icon) - - if result != toast { - t.Error("SetIcon() should return self for chaining") - } - if toast.Icon != tt.icon { - t.Errorf("Icon = %q, want %q", toast.Icon, tt.icon) - } - }) - } -} - -// TestToast_Dismiss tests dismissal functionality. -func TestToast_Dismiss(t *testing.T) { - toast := NewToast("Test message", SeverityInfo) - - if toast.Dismissed { - t.Error("Toast should not be dismissed initially") - } - - toast.Dismiss() - - if !toast.Dismissed { - t.Error("Toast should be dismissed after calling Dismiss()") - } -} - -// TestToast_IsExpired tests expiration checks. -func TestToast_IsExpired(t *testing.T) { - tests := []struct { - name string - duration time.Duration - delay time.Duration - wantExpired bool - }{ - { - name: "not expired - immediate check", - duration: 3 * time.Second, - delay: 0, - wantExpired: false, - }, - { - name: "not expired - within duration", - duration: 100 * time.Millisecond, - delay: 50 * time.Millisecond, - wantExpired: false, - }, - { - name: "expired - after duration", - duration: 50 * time.Millisecond, - delay: 100 * time.Millisecond, - wantExpired: true, - }, - { - name: "zero duration - immediately expired", - duration: 0, - delay: 1 * time.Millisecond, - wantExpired: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - toast := NewToast("Test", SeverityInfo).SetDuration(tt.duration) - - if tt.delay > 0 { - time.Sleep(tt.delay) - } - - expired := toast.IsExpired() - - if expired != tt.wantExpired { - t.Errorf("IsExpired() = %v, want %v", expired, tt.wantExpired) - } - }) - } -} - -// TestToast_Render tests rendering produces non-empty output. -func TestToast_Render(t *testing.T) { - tests := []struct { - name string - message string - severity Severity - setup func(*Toast) - }{ - { - name: "error toast", - message: "Error occurred", - severity: SeverityError, - setup: nil, - }, - { - name: "warning toast", - message: "Warning message", - severity: SeverityWarning, - setup: nil, - }, - { - name: "info toast", - message: "Info message", - severity: SeverityInfo, - setup: nil, - }, - { - name: "success toast", - message: "Success message", - severity: SeveritySuccess, - setup: nil, - }, - { - name: "toast with custom width", - message: "Custom width", - severity: SeverityInfo, - setup: func(t *Toast) { - t.SetWidth(50) - }, - }, - { - name: "toast without icon", - message: "No icon", - severity: SeverityInfo, - setup: func(t *Toast) { - t.ShowIcon = false - }, - }, - { - name: "toast without border", - message: "No border", - severity: SeverityInfo, - setup: func(t *Toast) { - t.ShowBorder = false - }, - }, - { - name: "toast with custom icon", - message: "Custom icon", - severity: SeverityInfo, - setup: func(t *Toast) { - t.SetIcon("🎉") - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - toast := NewToast(tt.message, tt.severity) - if tt.setup != nil { - tt.setup(toast) - } - - output := toast.Render() - - if len(output) == 0 { - t.Error("Render() returned empty string") - } - if !strings.Contains(output, tt.message) { - t.Errorf("Render() should contain message %q", tt.message) - } - }) - } -} - -// TestToast_Render_Dismissed tests dismissed toast renders nothing. -func TestToast_Render_Dismissed(t *testing.T) { - toast := NewToast("Test message", SeverityInfo) - toast.Dismiss() - - output := toast.Render() - - if output != "" { - t.Errorf("Dismissed toast Render() = %q, want empty string", output) - } -} - -// TestToast_Render_EmptyMessage tests rendering with empty message. -func TestToast_Render_EmptyMessage(t *testing.T) { - toast := NewToast("", SeverityInfo) - output := toast.Render() - - // Should render something (icon + styling) even with empty message - if len(output) == 0 { - t.Error("Render() with empty message should still produce output") - } -} - -// TestToast_PlaceOverlay tests overlay positioning for all positions. -func TestToast_PlaceOverlay(t *testing.T) { - tests := []struct { - name string - position ToastPosition - termWidth int - termHeight int - baseContent string - }{ - { - name: "top right position", - position: ToastPositionTopRight, - termWidth: 80, - termHeight: 24, - baseContent: strings.Repeat("X", 80) + "\n" + strings.Repeat("-", 80), - }, - { - name: "top center position", - position: ToastPositionTopCenter, - termWidth: 80, - termHeight: 24, - baseContent: strings.Repeat("X", 80) + "\n" + strings.Repeat("-", 80), - }, - { - name: "bottom right position", - position: ToastPositionBottomRight, - termWidth: 80, - termHeight: 24, - baseContent: strings.Repeat("X", 80) + "\n" + strings.Repeat("-", 80), - }, - { - name: "bottom center position", - position: ToastPositionBottomCenter, - termWidth: 80, - termHeight: 24, - baseContent: strings.Repeat("X", 80) + "\n" + strings.Repeat("-", 80), - }, - { - name: "narrow terminal", - position: ToastPositionTopRight, - termWidth: 40, - termHeight: 12, - baseContent: strings.Repeat("Y", 40), - }, - { - name: "wide terminal", - position: ToastPositionTopRight, - termWidth: 120, - termHeight: 30, - baseContent: strings.Repeat("Z", 120), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - toast := NewToast("Test toast", SeverityInfo).SetPosition(tt.position) - result := toast.PlaceOverlay(tt.baseContent, tt.termWidth, tt.termHeight) - - if len(result) == 0 { - t.Error("PlaceOverlay() returned empty string") - } - if result == tt.baseContent { - t.Error("PlaceOverlay() should modify base content") - } - }) - } -} - -// TestToast_PlaceOverlay_Dismissed tests dismissed toast doesn't modify base. -func TestToast_PlaceOverlay_Dismissed(t *testing.T) { - toast := NewToast("Test", SeverityInfo) - toast.Dismiss() - - baseContent := "Original content" - result := toast.PlaceOverlay(baseContent, 80, 24) - - if result != baseContent { - t.Error("Dismissed toast PlaceOverlay() should return base content unchanged") - } -} - -// TestToast_PlaceOverlay_ANSIAware tests ANSI-aware width calculations. -func TestToast_PlaceOverlay_ANSIAware(t *testing.T) { - // Create base content with ANSI codes - baseContent := "\x1b[31mRed Text\x1b[0m\n\x1b[32mGreen Text\x1b[0m" - - toast := NewToast("Overlay", SeverityInfo) - result := toast.PlaceOverlay(baseContent, 80, 24) - - if len(result) == 0 { - t.Error("PlaceOverlay() should handle ANSI codes") - } -} - -// TestToast_ChainedOperations tests method chaining. -func TestToast_ChainedOperations(t *testing.T) { - toast := NewToast("Chained", SeverityWarning). - SetDuration(5 * time.Second). - SetPosition(ToastPositionBottomCenter). - SetWidth(60). - SetIcon("⭐") - - if toast.Duration != 5*time.Second { - t.Errorf("Duration = %v, want 5s", toast.Duration) - } - if toast.Position != ToastPositionBottomCenter { - t.Error("Position should be BottomCenter") - } - if toast.Width != 60 { - t.Errorf("Width = %d, want 60", toast.Width) - } - if toast.Icon != "⭐" { - t.Errorf("Icon = %q, want ⭐", toast.Icon) - } - - // Should still render - output := toast.Render() - if len(output) == 0 { - t.Error("Chained toast should render successfully") - } -} - -// TestToastPosition_Constants tests position constant values. -func TestToastPosition_Constants(t *testing.T) { - if ToastPositionTopRight != 0 { - t.Errorf("ToastPositionTopRight = %d, want 0", ToastPositionTopRight) - } - if ToastPositionTopCenter != 1 { - t.Errorf("ToastPositionTopCenter = %d, want 1", ToastPositionTopCenter) - } - if ToastPositionBottomRight != 2 { - t.Errorf("ToastPositionBottomRight = %d, want 2", ToastPositionBottomRight) - } - if ToastPositionBottomCenter != 3 { - t.Errorf("ToastPositionBottomCenter = %d, want 3", ToastPositionBottomCenter) - } -} - -// TestNewToastStack tests stack constructor. -func TestNewToastStack(t *testing.T) { - stack := NewToastStack() - - if stack == nil { - t.Fatal("NewToastStack() returned nil") - } - if stack.Toasts == nil { - t.Error("Toasts slice should be initialized") - } - if len(stack.Toasts) != 0 { - t.Errorf("Toasts length = %d, want 0", len(stack.Toasts)) - } - if stack.MaxToast != 3 { - t.Errorf("MaxToast = %d, want 3", stack.MaxToast) - } -} - -// TestToastStack_Push tests adding toasts to stack. -func TestToastStack_Push(t *testing.T) { - stack := NewToastStack() - - // Push first toast - toast1 := NewToast("First", SeverityInfo) - stack.Push(toast1) - - if len(stack.Toasts) != 1 { - t.Errorf("After 1 push, length = %d, want 1", len(stack.Toasts)) - } - if stack.Toasts[0] != toast1 { - t.Error("First toast should be in stack") - } - - // Push second toast - toast2 := NewToast("Second", SeveritySuccess) - stack.Push(toast2) - - if len(stack.Toasts) != 2 { - t.Errorf("After 2 pushes, length = %d, want 2", len(stack.Toasts)) - } - - // Push third toast - toast3 := NewToast("Third", SeverityWarning) - stack.Push(toast3) - - if len(stack.Toasts) != 3 { - t.Errorf("After 3 pushes, length = %d, want 3", len(stack.Toasts)) - } - - // Push fourth toast - should limit to MaxToast (3) - toast4 := NewToast("Fourth", SeverityError) - stack.Push(toast4) - - if len(stack.Toasts) != 3 { - t.Errorf("After 4 pushes with max 3, length = %d, want 3", len(stack.Toasts)) - } - - // First toast should be removed - if stack.Toasts[0] == toast1 { - t.Error("First toast should have been removed") - } - if stack.Toasts[2] != toast4 { - t.Error("Fourth toast should be at end") - } -} - -// TestToastStack_RemoveExpired tests expired toast removal. -func TestToastStack_RemoveExpired(t *testing.T) { - stack := NewToastStack() - - // Add toast with short duration - expiredToast := NewToast("Expired", SeverityInfo).SetDuration(1 * time.Millisecond) - stack.Push(expiredToast) - - // Add active toast - activeToast := NewToast("Active", SeverityInfo).SetDuration(10 * time.Second) - stack.Push(activeToast) - - // Add dismissed toast - dismissedToast := NewToast("Dismissed", SeverityInfo) - dismissedToast.Dismiss() - stack.Push(dismissedToast) - - if len(stack.Toasts) != 3 { - t.Errorf("Before removal, length = %d, want 3", len(stack.Toasts)) - } - - // Wait for first toast to expire - time.Sleep(10 * time.Millisecond) - - stack.RemoveExpired() - - // Should only have active toast remaining - if len(stack.Toasts) != 1 { - t.Errorf("After RemoveExpired(), length = %d, want 1", len(stack.Toasts)) - } - if len(stack.Toasts) > 0 && stack.Toasts[0] != activeToast { - t.Error("Active toast should remain in stack") - } -} - -// TestToastStack_Render tests rendering all toasts in stack. -func TestToastStack_Render(t *testing.T) { - stack := NewToastStack() - - // Empty stack - rendered := stack.Render() - if len(rendered) != 0 { - t.Error("Empty stack Render() should return empty slice") - } - - // Add toasts - stack.Push(NewToast("First", SeverityInfo)) - stack.Push(NewToast("Second", SeveritySuccess)) - stack.Push(NewToast("Third", SeverityWarning)) - - rendered = stack.Render() - - if len(rendered) != 3 { - t.Errorf("Render() returned %d toasts, want 3", len(rendered)) - } - - // Check each contains message - for i, r := range rendered { - if len(r) == 0 { - t.Errorf("Toast %d rendered empty", i) - } - } - - // Dismiss middle toast - stack.Toasts[1].Dismiss() - rendered = stack.Render() - - if len(rendered) != 2 { - t.Errorf("After dismissing one, Render() returned %d toasts, want 2", len(rendered)) - } -} - -// TestToastStack_PlaceAllOverlays tests stacking multiple toasts. -func TestToastStack_PlaceAllOverlays(t *testing.T) { - stack := NewToastStack() - - baseContent := strings.Repeat("Base content line\n", 20) - - // Test empty stack - result := stack.PlaceAllOverlays(baseContent, 80, 24) - if result == "" { - t.Error("PlaceAllOverlays() should return base content for empty stack") - } - - // Add multiple toasts - stack.Push(NewToast("First toast", SeverityInfo)) - stack.Push(NewToast("Second toast", SeveritySuccess)) - stack.Push(NewToast("Third toast", SeverityWarning)) - - result = stack.PlaceAllOverlays(baseContent, 80, 24) - - if len(result) == 0 { - t.Error("PlaceAllOverlays() returned empty string") - } - if result == baseContent { - t.Error("PlaceAllOverlays() should modify base content") - } -} - -// TestToastStack_PlaceAllOverlays_RemovesExpired tests auto-removal. -func TestToastStack_PlaceAllOverlays_RemovesExpired(t *testing.T) { - stack := NewToastStack() - - // Add expired toast - expiredToast := NewToast("Expired", SeverityInfo).SetDuration(1 * time.Millisecond) - stack.Push(expiredToast) - - // Wait for expiration - time.Sleep(10 * time.Millisecond) - - baseContent := "Base" - stack.PlaceAllOverlays(baseContent, 80, 24) - - // Stack should be empty after rendering - if len(stack.Toasts) != 0 { - t.Errorf("PlaceAllOverlays() should remove expired toasts, got %d toasts", len(stack.Toasts)) - } -} - -// TestToast_placeOverlayAt tests internal positioning helper. -func TestToast_placeOverlayAt(t *testing.T) { - toast := NewToast("Test", SeverityInfo) - baseContent := strings.Repeat("X", 80) + "\n" + strings.Repeat("Y", 80) - - // Test at row 1 - result := toast.placeOverlayAt(baseContent, 80, 24, 1) - if len(result) == 0 { - t.Error("placeOverlayAt() returned empty string") - } - - // Test at row 10 - result = toast.placeOverlayAt(baseContent, 80, 24, 10) - if len(result) == 0 { - t.Error("placeOverlayAt() with row 10 returned empty string") - } - - // Test dismissed toast - toast.Dismiss() - result = toast.placeOverlayAt(baseContent, 80, 24, 1) - if result != baseContent { - t.Error("Dismissed toast placeOverlayAt() should return base unchanged") - } -} - -// TestOverlayLine tests the overlay line helper. -func TestOverlayLine(t *testing.T) { - tests := []struct { - name string - baseLine string - toastLine string - col int - maxWidth int - }{ - { - name: "overlay at beginning", - baseLine: "XXXXXXXXXXXXXXXXXX", - toastLine: "TOAST", - col: 0, - maxWidth: 80, - }, - { - name: "overlay at middle", - baseLine: "XXXXXXXXXXXXXXXXXX", - toastLine: "TOAST", - col: 10, - maxWidth: 80, - }, - { - name: "overlay near end", - baseLine: "XXXXXXXXXXXXXXXXXX", - toastLine: "TOAST", - col: 15, - maxWidth: 80, - }, - { - name: "empty base line", - baseLine: "", - toastLine: "TOAST", - col: 5, - maxWidth: 80, - }, - { - name: "empty toast line", - baseLine: "XXXXXXXXXX", - toastLine: "", - col: 5, - maxWidth: 80, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := overlayLine(tt.baseLine, tt.toastLine, tt.col, tt.maxWidth) - - // Should not panic and should return a string - if len(result) == 0 && len(tt.toastLine) > 0 { - t.Error("overlayLine() returned empty string") - } - }) - } -} - -// TestToastDismissMsg tests the dismiss message type. -func TestToastDismissMsg(t *testing.T) { - msg := ToastDismissMsg{ID: "test-id"} - - if msg.ID != "test-id" { - t.Errorf("ToastDismissMsg.ID = %q, want %q", msg.ID, "test-id") - } -} - -// TestTickDismiss tests the dismiss ticker command. -func TestTickDismiss(t *testing.T) { - cmd := TickDismiss(10*time.Millisecond, "test-id") - - if cmd == nil { - t.Fatal("TickDismiss() returned nil") - } - - // Execute the command - msg := cmd() - - // Should return ToastDismissMsg - dismissMsg, ok := msg.(ToastDismissMsg) - if !ok { - t.Errorf("TickDismiss() returned %T, want ToastDismissMsg", msg) - } - if dismissMsg.ID != "test-id" { - t.Errorf("ToastDismissMsg.ID = %q, want %q", dismissMsg.ID, "test-id") - } -} - -// TestToast_EdgeCases tests various edge cases. -func TestToast_EdgeCases(t *testing.T) { - tests := []struct { - name string - setup func() *Toast - }{ - { - name: "very long message", - setup: func() *Toast { - return NewToast(strings.Repeat("A", 500), SeverityInfo) - }, - }, - { - name: "multiline message", - setup: func() *Toast { - return NewToast("Line 1\nLine 2\nLine 3", SeverityInfo) - }, - }, - { - name: "message with ANSI codes", - setup: func() *Toast { - return NewToast("\x1b[31mRed\x1b[0m Text", SeverityInfo) - }, - }, - { - name: "zero padding", - setup: func() *Toast { - toast := NewToast("Test", SeverityInfo) - toast.Padding = 0 - return toast - }, - }, - { - name: "large padding", - setup: func() *Toast { - toast := NewToast("Test", SeverityInfo) - toast.Padding = 10 - return toast - }, - }, - { - name: "very small width", - setup: func() *Toast { - return NewToast("Test", SeverityInfo).SetWidth(5) - }, - }, - { - name: "very large width", - setup: func() *Toast { - return NewToast("Test", SeverityInfo).SetWidth(500) - }, - }, - { - name: "all options disabled", - setup: func() *Toast { - toast := NewToast("Test", SeverityInfo) - toast.ShowIcon = false - toast.ShowBorder = false - return toast - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - toast := tt.setup() - - // Should not panic - output := toast.Render() - - // Should produce some output - _ = output - }) - } -} - -// TestToast_ANSIWidthCalculations tests ANSI-aware width handling. -func TestToast_ANSIWidthCalculations(t *testing.T) { - // Create toast with styled content - styledMessage := "\x1b[1m\x1b[31mBold Red Text\x1b[0m" - toast := NewToast(styledMessage, SeverityInfo) - - output := toast.Render() - - if len(output) == 0 { - t.Error("Render() should handle ANSI codes") - } - - // Test overlay with ANSI content - baseContent := "\x1b[32mGreen background\x1b[0m" - result := toast.PlaceOverlay(baseContent, 80, 24) - - if len(result) == 0 { - t.Error("PlaceOverlay() should handle ANSI codes in base content") - } -} - -// TestToast_LipglossStyledContent tests pre-styled content. -func TestToast_LipglossStyledContent(t *testing.T) { - style := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF0000")). - Bold(true) - - styledMessage := style.Render("Styled Text") - toast := NewToast(styledMessage, SeverityInfo) - - output := toast.Render() - - if len(output) == 0 { - t.Error("Render() should handle lipgloss styled content") - } - if !strings.Contains(output, "Styled Text") { - t.Error("Render() should preserve styled content") - } -} - -// TestToastStack_MaxToastLimit tests the stack limit behavior. -func TestToastStack_MaxToastLimit(t *testing.T) { - stack := NewToastStack() - stack.MaxToast = 2 // Lower limit for testing - - // Push 5 toasts - for i := 1; i <= 5; i++ { - toast := NewToast(string(rune('A'+i-1)), SeverityInfo) - stack.Push(toast) - } - - // Should only have last 2 - if len(stack.Toasts) != 2 { - t.Errorf("Stack length = %d, want 2", len(stack.Toasts)) - } - - // Should have toasts D and E - if !strings.Contains(stack.Toasts[0].Message, "D") { - t.Error("First toast should be D") - } - if !strings.Contains(stack.Toasts[1].Message, "E") { - t.Error("Second toast should be E") - } -} - -// TestToast_PositionBoundaryChecks tests position calculations stay in bounds. -func TestToast_PositionBoundaryChecks(t *testing.T) { - tests := []struct { - name string - position ToastPosition - termWidth int - termHeight int - message string - }{ - { - name: "very small terminal top right", - position: ToastPositionTopRight, - termWidth: 10, - termHeight: 5, - message: "Test", - }, - { - name: "very small terminal bottom center", - position: ToastPositionBottomCenter, - termWidth: 10, - termHeight: 5, - message: "Test", - }, - { - name: "very large terminal", - position: ToastPositionTopCenter, - termWidth: 200, - termHeight: 100, - message: "Test", - }, - { - name: "long message narrow terminal", - position: ToastPositionTopRight, - termWidth: 40, - termHeight: 10, - message: strings.Repeat("Long message ", 10), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - toast := NewToast(tt.message, SeverityInfo).SetPosition(tt.position) - baseContent := strings.Repeat("X", tt.termWidth) - - // Should not panic with extreme dimensions - result := toast.PlaceOverlay(baseContent, tt.termWidth, tt.termHeight) - - if len(result) == 0 { - t.Error("PlaceOverlay() should handle boundary cases") - } - }) - } -} diff --git a/pkg/ui/components/tree/.gitkeep b/pkg/ui/components/tree/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/pkg/ui/components/tree/tree.go b/pkg/ui/components/tree/tree.go deleted file mode 100644 index 04fb030..0000000 --- a/pkg/ui/components/tree/tree.go +++ /dev/null @@ -1,449 +0,0 @@ -// Package tree provides hierarchical tree display components for the UI engine. -package tree - -import ( - "strings" - - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// Tree is a component for displaying hierarchical data structures. -// -// Features: -// - Unicode box-drawing characters for tree structure (├── └── │) -// - Expand/collapse nodes (optional, for future phases) -// - Profile-themed styling -// - Support for arbitrary depth -// - Node selection/highlighting -// - Value display alongside labels -// -// Usage: -// -// root := &TreeNode{ -// Label: "postgres", -// Children: []*TreeNode{ -// {Label: "Port", Value: "5432"}, -// {Label: "Status", Value: "running"}, -// { -// Label: "Dependencies", -// Children: []*TreeNode{ -// {Label: "redis"}, -// {Label: "mongodb"}, -// }, -// }, -// }, -// } -// -// tree := NewTree(root, theme) -// output := tree.Render(80) -// -// Output: -// -// postgres -// ├── Port: 5432 -// ├── Status: running -// └── Dependencies -// ├── redis -// └── mongodb -type Tree struct { - root *TreeNode - theme *themes.Theme - selectedPath []int // Path to selected node (indices at each level) - showSelection bool // Whether to highlight selected node - labelStyle lipgloss.Style - valueStyle lipgloss.Style - treeLineStyle lipgloss.Style - selectedStyle lipgloss.Style - collapsedNodes map[*TreeNode]bool // Track collapsed state (future use) -} - -// TreeNode represents a node in the hierarchical tree structure. -// -// Each node can have: -// - A label (required) -// - An optional value to display -// - Children nodes for hierarchy -// - Expanded state for collapse/expand functionality -// -// Example nodes: -// -// // Simple leaf node -// &TreeNode{Label: "redis"} -// -// // Node with value -// &TreeNode{Label: "Port", Value: "5432"} -// -// // Node with children -// &TreeNode{ -// Label: "Services", -// Children: []*TreeNode{ -// {Label: "postgres", Value: "running"}, -// {Label: "redis", Value: "stopped"}, -// }, -// } -type TreeNode struct { - Label string // Display label for the node - Value string // Optional value to display after the label - Children []*TreeNode // Child nodes - Expanded bool // Whether the node is expanded (for future collapse/expand) - Metadata any // Optional metadata for application use -} - -// Box drawing characters for tree structure. -const ( - verticalLine = "│" // Vertical line for continued branches - horizontalLine = "──" // Horizontal line to node - tBranch = "├──" // T-branch for intermediate children - lBranch = "└──" // L-branch for last child - spacer = " " // Spacer for continued indentation -) - -// NewTree creates a new Tree component with the given root node. -// -// The tree is initialized with profile-themed styles. If theme is nil, -// default styles are used. -// -// Parameters: -// - root: The root node of the tree (can be nil for empty tree) -// - theme: Theme for styling (can be nil for defaults) -// -// Returns: -// - A new Tree instance ready to render -func NewTree(root *TreeNode, theme *themes.Theme) *Tree { - t := &Tree{ - root: root, - theme: theme, - selectedPath: nil, - showSelection: false, - collapsedNodes: make(map[*TreeNode]bool), - } - - // Initialize styles based on theme - t.applyTheme(theme) - - return t -} - -// NewTreeWithSelection creates a Tree with an initially selected node. -// -// The selectedPath is an array of indices representing the path to the -// selected node. For example, [0, 2, 1] means: -// - First child of root (index 0) -// - Third child of that node (index 2) -// - Second child of that node (index 1) -// -// Parameters: -// - root: The root node of the tree -// - theme: Theme for styling (can be nil) -// - selectedPath: Path to initially selected node -// -// Returns: -// - A new Tree instance with selection enabled -func NewTreeWithSelection(root *TreeNode, theme *themes.Theme, selectedPath []int) *Tree { - t := NewTree(root, theme) - t.selectedPath = selectedPath - t.showSelection = true - return t -} - -// applyTheme configures the tree's styles based on the theme. -func (t *Tree) applyTheme(theme *themes.Theme) { - if theme == nil { - // Default styles when no theme provided - t.labelStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")) - t.valueStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#808080")) - t.treeLineStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#606060")) - t.selectedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#000000")). - Background(lipgloss.Color("#FFFFFF")). - Bold(true) - return - } - - colors := theme.Colors - - // Label uses primary color for emphasis - t.labelStyle = lipgloss.NewStyle(). - Foreground(colors.ForegroundColor()) - - // Value uses muted color for de-emphasis - t.valueStyle = lipgloss.NewStyle(). - Foreground(colors.MutedColor()) - - // Tree lines use border color - t.treeLineStyle = lipgloss.NewStyle(). - Foreground(colors.BorderColor()) - - // Selected node uses inverted primary colors - t.selectedStyle = lipgloss.NewStyle(). - Foreground(colors.BackgroundColor()). - Background(colors.PrimaryColor()). - Bold(true) -} - -// SetTheme updates the tree's theme and re-applies styling. -// -// This allows dynamic theme switching without recreating the tree. -func (t *Tree) SetTheme(theme *themes.Theme) { - t.theme = theme - t.applyTheme(theme) -} - -// SetSelection sets the selected node path and enables selection display. -// -// The path is an array of child indices from root to target node. -// Pass nil to clear the selection. -func (t *Tree) SetSelection(path []int) { - t.selectedPath = path - t.showSelection = path != nil -} - -// ClearSelection removes the selection highlight. -func (t *Tree) ClearSelection() { - t.selectedPath = nil - t.showSelection = false -} - -// Render generates the tree's string representation. -// -// The tree is rendered with proper indentation, box-drawing characters, -// and themed styling. The width parameter is currently reserved for -// future use (e.g., wrapping long labels). -// -// Parameters: -// - width: Maximum width for rendering (currently unused, reserved) -// -// Returns: -// - Rendered tree as a string with ANSI styling -func (t *Tree) Render(width int) string { - if t.root == nil { - return "" - } - - var sb strings.Builder - t.renderNode(&sb, t.root, "", true, []int{}) - return strings.TrimRight(sb.String(), "\n") -} - -// renderNode recursively renders a tree node and its children. -// -// Parameters: -// - sb: String builder to accumulate output -// - node: Current node to render -// - prefix: Prefix string for indentation and tree lines -// - isLast: Whether this is the last child of its parent -// - currentPath: Path from root to this node (for selection) -func (t *Tree) renderNode(sb *strings.Builder, node *TreeNode, prefix string, isLast bool, currentPath []int) { - if node == nil { - return - } - - // Determine if this node is selected - isSelected := t.showSelection && t.pathsEqual(currentPath, t.selectedPath) - - // Build the node line - var line strings.Builder - - // Add tree structure characters (except for root) - if len(currentPath) > 0 { - branch := tBranch - if isLast { - branch = lBranch - } - line.WriteString(t.treeLineStyle.Render(prefix + branch + " ")) - } - - // Add label - label := node.Label - if isSelected { - label = t.selectedStyle.Render(label) - } else { - label = t.labelStyle.Render(label) - } - line.WriteString(label) - - // Add value if present - if node.Value != "" { - valuePart := ": " + node.Value - if isSelected { - valuePart = t.selectedStyle.Render(valuePart) - } else { - valuePart = t.valueStyle.Render(valuePart) - } - line.WriteString(valuePart) - } - - sb.WriteString(line.String()) - sb.WriteString("\n") - - // Render children if node is expanded (currently always true) - if node.Expanded || !t.isCollapsed(node) { - childCount := len(node.Children) - for i, child := range node.Children { - isLastChild := i == childCount-1 - - // Build prefix for child - var childPrefix string - if len(currentPath) == 0 { - // Root level - no prefix - childPrefix = "" - } else if isLast { - // Last child - use spaces - childPrefix = prefix + spacer - } else { - // Not last child - continue vertical line - childPrefix = prefix + t.treeLineStyle.Render(verticalLine) + " " - } - - // Build path to child - childPath := append([]int{}, currentPath...) - childPath = append(childPath, i) - - t.renderNode(sb, child, childPrefix, isLastChild, childPath) - } - } -} - -// isCollapsed checks if a node is marked as collapsed. -// This is for future expand/collapse functionality. -func (t *Tree) isCollapsed(node *TreeNode) bool { - return t.collapsedNodes[node] -} - -// pathsEqual compares two paths for equality. -func (t *Tree) pathsEqual(a, b []int) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - -// ToggleNode expands or collapses a node at the given path. -// -// This is a placeholder for future collapse/expand functionality. -// Currently a no-op as all nodes are expanded by default. -func (t *Tree) ToggleNode(path []int) { - node := t.getNodeAtPath(path) - if node == nil { - return - } - - if t.collapsedNodes[node] { - delete(t.collapsedNodes, node) - } else { - t.collapsedNodes[node] = true - } -} - -// getNodeAtPath retrieves the node at the specified path. -func (t *Tree) getNodeAtPath(path []int) *TreeNode { - if t.root == nil || len(path) == 0 { - return t.root - } - - current := t.root - for _, idx := range path { - if idx < 0 || idx >= len(current.Children) { - return nil - } - current = current.Children[idx] - } - return current -} - -// ExpandAll expands all nodes in the tree. -func (t *Tree) ExpandAll() { - t.collapsedNodes = make(map[*TreeNode]bool) - t.expandNodeRecursive(t.root) -} - -// expandNodeRecursive recursively expands a node and all its descendants. -func (t *Tree) expandNodeRecursive(node *TreeNode) { - if node == nil { - return - } - node.Expanded = true - for _, child := range node.Children { - t.expandNodeRecursive(child) - } -} - -// CollapseAll collapses all nodes except the root. -func (t *Tree) CollapseAll() { - t.collapseNodeRecursive(t.root, true) -} - -// collapseNodeRecursive recursively collapses a node and all its descendants. -func (t *Tree) collapseNodeRecursive(node *TreeNode, isRoot bool) { - if node == nil { - return - } - if !isRoot { - t.collapsedNodes[node] = true - node.Expanded = false - } - for _, child := range node.Children { - t.collapseNodeRecursive(child, false) - } -} - -// SetRoot replaces the tree's root node. -// -// This allows reusing a Tree instance with different data. -func (t *Tree) SetRoot(root *TreeNode) { - t.root = root - t.collapsedNodes = make(map[*TreeNode]bool) - t.ClearSelection() -} - -// GetRoot returns the tree's root node. -func (t *Tree) GetRoot() *TreeNode { - return t.root -} - -// NodeCount returns the total number of nodes in the tree. -func (t *Tree) NodeCount() int { - return t.countNodes(t.root) -} - -// countNodes recursively counts all nodes. -func (t *Tree) countNodes(node *TreeNode) int { - if node == nil { - return 0 - } - count := 1 - for _, child := range node.Children { - count += t.countNodes(child) - } - return count -} - -// Height returns the maximum depth of the tree. -func (t *Tree) Height() int { - return t.heightRecursive(t.root, 0) -} - -// heightRecursive calculates tree height recursively. -func (t *Tree) heightRecursive(node *TreeNode, depth int) int { - if node == nil || len(node.Children) == 0 { - return depth - } - maxHeight := depth - for _, child := range node.Children { - childHeight := t.heightRecursive(child, depth+1) - if childHeight > maxHeight { - maxHeight = childHeight - } - } - return maxHeight -} diff --git a/pkg/ui/components/tree/tree_test.go b/pkg/ui/components/tree/tree_test.go deleted file mode 100644 index 6cd5695..0000000 --- a/pkg/ui/components/tree/tree_test.go +++ /dev/null @@ -1,624 +0,0 @@ -package tree - -import ( - "strings" - "testing" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// TestNewTree verifies basic tree creation. -func TestNewTree(t *testing.T) { - root := &TreeNode{ - Label: "root", - } - - tree := NewTree(root, nil) - - if tree == nil { - t.Fatal("Expected tree to be created, got nil") - } - - if tree.root != root { - t.Error("Expected tree root to match provided root") - } - - if tree.showSelection { - t.Error("Expected selection to be disabled by default") - } -} - -// TestNewTreeWithSelection verifies tree creation with selection. -func TestNewTreeWithSelection(t *testing.T) { - root := &TreeNode{ - Label: "root", - Children: []*TreeNode{ - {Label: "child1"}, - {Label: "child2"}, - }, - } - - path := []int{0} - tree := NewTreeWithSelection(root, nil, path) - - if !tree.showSelection { - t.Error("Expected selection to be enabled") - } - - if !tree.pathsEqual(tree.selectedPath, path) { - t.Error("Expected selected path to match provided path") - } -} - -// TestRenderSimpleTree verifies rendering of a simple tree structure. -func TestRenderSimpleTree(t *testing.T) { - root := &TreeNode{ - Label: "postgres", - Children: []*TreeNode{ - {Label: "Port", Value: "5432"}, - {Label: "Status", Value: "running"}, - {Label: "Host", Value: "localhost"}, - }, - } - - tree := NewTree(root, nil) - output := tree.Render(80) - - // Verify root is present - if !strings.Contains(output, "postgres") { - t.Error("Expected output to contain root label 'postgres'") - } - - // Verify children are present - if !strings.Contains(output, "Port") || !strings.Contains(output, "5432") { - t.Error("Expected output to contain 'Port: 5432'") - } - - if !strings.Contains(output, "Status") || !strings.Contains(output, "running") { - t.Error("Expected output to contain 'Status: running'") - } - - // Verify tree structure characters - if !strings.Contains(output, "├──") { - t.Error("Expected output to contain T-branch character") - } - - if !strings.Contains(output, "└──") { - t.Error("Expected output to contain L-branch character for last child") - } -} - -// TestRenderNestedTree verifies rendering of nested tree structure. -func TestRenderNestedTree(t *testing.T) { - root := &TreeNode{ - Label: "postgres", - Children: []*TreeNode{ - {Label: "Port", Value: "5432"}, - {Label: "Status", Value: "running"}, - { - Label: "Dependencies", - Children: []*TreeNode{ - {Label: "redis"}, - {Label: "mongodb"}, - }, - }, - }, - } - - tree := NewTree(root, nil) - output := tree.Render(80) - - // Verify nested structure - if !strings.Contains(output, "Dependencies") { - t.Error("Expected output to contain 'Dependencies'") - } - - if !strings.Contains(output, "redis") { - t.Error("Expected output to contain nested 'redis'") - } - - if !strings.Contains(output, "mongodb") { - t.Error("Expected output to contain nested 'mongodb'") - } - - // Count lines to verify all nodes are rendered - lines := strings.Split(output, "\n") - expectedLines := 6 // root + 3 children + 2 nested children - if len(lines) != expectedLines { - t.Errorf("Expected %d lines, got %d", expectedLines, len(lines)) - } -} - -// TestRenderDeepTree verifies rendering of deeply nested tree. -func TestRenderDeepTree(t *testing.T) { - root := &TreeNode{ - Label: "Level 0", - Children: []*TreeNode{ - { - Label: "Level 1", - Children: []*TreeNode{ - { - Label: "Level 2", - Children: []*TreeNode{ - {Label: "Level 3"}, - }, - }, - }, - }, - }, - } - - tree := NewTree(root, nil) - output := tree.Render(80) - - // Verify all levels are present - for i := 0; i <= 3; i++ { - label := "Level " + string(rune('0'+i)) - if !strings.Contains(output, label) { - t.Errorf("Expected output to contain '%s'", label) - } - } -} - -// TestRenderEmptyTree verifies rendering of empty tree. -func TestRenderEmptyTree(t *testing.T) { - tree := NewTree(nil, nil) - output := tree.Render(80) - - if output != "" { - t.Error("Expected empty string for nil root") - } -} - -// TestRenderLeafNode verifies rendering of node with no children. -func TestRenderLeafNode(t *testing.T) { - root := &TreeNode{ - Label: "single-node", - Value: "no-children", - } - - tree := NewTree(root, nil) - output := tree.Render(80) - - if !strings.Contains(output, "single-node") { - t.Error("Expected output to contain node label") - } - - if !strings.Contains(output, "no-children") { - t.Error("Expected output to contain node value") - } - - // Should only be one line - lines := strings.Split(output, "\n") - if len(lines) != 1 { - t.Errorf("Expected 1 line for leaf node, got %d", len(lines)) - } -} - -// TestSetTheme verifies theme can be changed. -func TestSetTheme(t *testing.T) { - root := &TreeNode{Label: "test"} - tree := NewTree(root, nil) - - // Create a test theme - theme := &themes.Theme{ - Colors: themes.ColorSet{ - Primary: "#00ADD8", - Foreground: "#FFFFFF", - Muted: "#808080", - Border: "#606060", - Background: "#000000", - }, - } - - tree.SetTheme(theme) - - if tree.theme != theme { - t.Error("Expected theme to be updated") - } - - // Verify it still renders - output := tree.Render(80) - if !strings.Contains(output, "test") { - t.Error("Expected output to still contain node label after theme change") - } -} - -// TestSelection verifies selection highlighting. -func TestSelection(t *testing.T) { - root := &TreeNode{ - Label: "root", - Children: []*TreeNode{ - {Label: "child1"}, - {Label: "child2"}, - }, - } - - tree := NewTree(root, nil) - - // Initially no selection - if tree.showSelection { - t.Error("Expected no selection initially") - } - - // Set selection - tree.SetSelection([]int{1}) - - if !tree.showSelection { - t.Error("Expected selection to be enabled") - } - - // Clear selection - tree.ClearSelection() - - if tree.showSelection { - t.Error("Expected selection to be cleared") - } -} - -// TestNodeCount verifies node counting. -func TestNodeCount(t *testing.T) { - tests := []struct { - name string - root *TreeNode - expected int - }{ - { - name: "nil tree", - root: nil, - expected: 0, - }, - { - name: "single node", - root: &TreeNode{Label: "root"}, - expected: 1, - }, - { - name: "tree with children", - root: &TreeNode{ - Label: "root", - Children: []*TreeNode{ - {Label: "child1"}, - {Label: "child2"}, - {Label: "child3"}, - }, - }, - expected: 4, - }, - { - name: "nested tree", - root: &TreeNode{ - Label: "root", - Children: []*TreeNode{ - { - Label: "child1", - Children: []*TreeNode{ - {Label: "grandchild1"}, - {Label: "grandchild2"}, - }, - }, - {Label: "child2"}, - }, - }, - expected: 5, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tree := NewTree(tt.root, nil) - count := tree.NodeCount() - if count != tt.expected { - t.Errorf("Expected %d nodes, got %d", tt.expected, count) - } - }) - } -} - -// TestHeight verifies tree height calculation. -func TestHeight(t *testing.T) { - tests := []struct { - name string - root *TreeNode - expected int - }{ - { - name: "nil tree", - root: nil, - expected: 0, - }, - { - name: "single node", - root: &TreeNode{Label: "root"}, - expected: 0, - }, - { - name: "one level", - root: &TreeNode{ - Label: "root", - Children: []*TreeNode{ - {Label: "child1"}, - {Label: "child2"}, - }, - }, - expected: 1, - }, - { - name: "two levels", - root: &TreeNode{ - Label: "root", - Children: []*TreeNode{ - { - Label: "child1", - Children: []*TreeNode{ - {Label: "grandchild1"}, - }, - }, - }, - }, - expected: 2, - }, - { - name: "unbalanced tree", - root: &TreeNode{ - Label: "root", - Children: []*TreeNode{ - {Label: "child1"}, - { - Label: "child2", - Children: []*TreeNode{ - { - Label: "grandchild1", - Children: []*TreeNode{ - {Label: "great-grandchild1"}, - }, - }, - }, - }, - }, - }, - expected: 3, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tree := NewTree(tt.root, nil) - height := tree.Height() - if height != tt.expected { - t.Errorf("Expected height %d, got %d", tt.expected, height) - } - }) - } -} - -// TestSetRoot verifies root replacement. -func TestSetRoot(t *testing.T) { - root1 := &TreeNode{Label: "root1"} - root2 := &TreeNode{Label: "root2"} - - tree := NewTree(root1, nil) - - if tree.root != root1 { - t.Error("Expected initial root to be root1") - } - - tree.SetRoot(root2) - - if tree.root != root2 { - t.Error("Expected root to be updated to root2") - } - - // Verify selection is cleared - if tree.showSelection { - t.Error("Expected selection to be cleared after SetRoot") - } -} - -// TestGetNodeAtPath verifies node retrieval by path. -func TestGetNodeAtPath(t *testing.T) { - child1 := &TreeNode{Label: "child1"} - child2 := &TreeNode{Label: "child2"} - grandchild := &TreeNode{Label: "grandchild"} - child1.Children = []*TreeNode{grandchild} - - root := &TreeNode{ - Label: "root", - Children: []*TreeNode{child1, child2}, - } - - tree := NewTree(root, nil) - - tests := []struct { - name string - path []int - expected *TreeNode - }{ - { - name: "empty path returns root", - path: []int{}, - expected: root, - }, - { - name: "first child", - path: []int{0}, - expected: child1, - }, - { - name: "second child", - path: []int{1}, - expected: child2, - }, - { - name: "grandchild", - path: []int{0, 0}, - expected: grandchild, - }, - { - name: "invalid path", - path: []int{5}, - expected: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - node := tree.getNodeAtPath(tt.path) - if node != tt.expected { - t.Errorf("Expected node %v, got %v", tt.expected, node) - } - }) - } -} - -// TestExpandCollapseAll verifies expand/collapse all functionality. -func TestExpandCollapseAll(t *testing.T) { - root := &TreeNode{ - Label: "root", - Children: []*TreeNode{ - { - Label: "child1", - Children: []*TreeNode{ - {Label: "grandchild1"}, - }, - }, - }, - } - - tree := NewTree(root, nil) - - // Initially all expanded - tree.ExpandAll() - if !root.Expanded { - t.Error("Expected root to be expanded") - } - if !root.Children[0].Expanded { - t.Error("Expected child to be expanded") - } - - // Collapse all - tree.CollapseAll() - if !root.Expanded { - t.Error("Expected root to remain expanded") - } - // Note: Children should be marked collapsed in collapsedNodes map - if len(tree.collapsedNodes) == 0 { - t.Error("Expected collapsed nodes map to have entries") - } -} - -// TestPathsEqual verifies path comparison. -func TestPathsEqual(t *testing.T) { - tree := NewTree(nil, nil) - - tests := []struct { - name string - a []int - b []int - expected bool - }{ - { - name: "equal paths", - a: []int{0, 1, 2}, - b: []int{0, 1, 2}, - expected: true, - }, - { - name: "different paths", - a: []int{0, 1, 2}, - b: []int{0, 2, 1}, - expected: false, - }, - { - name: "different lengths", - a: []int{0, 1}, - b: []int{0, 1, 2}, - expected: false, - }, - { - name: "empty paths", - a: []int{}, - b: []int{}, - expected: true, - }, - { - name: "nil vs empty", - a: nil, - b: []int{}, - expected: true, // nil and empty slices are treated as equal for paths - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tree.pathsEqual(tt.a, tt.b) - if result != tt.expected { - t.Errorf("Expected %v, got %v", tt.expected, result) - } - }) - } -} - -// TestRenderWithTheme verifies rendering with a theme applied. -func TestRenderWithTheme(t *testing.T) { - root := &TreeNode{ - Label: "postgres", - Children: []*TreeNode{ - {Label: "Port", Value: "5432"}, - }, - } - - theme := &themes.Theme{ - Colors: themes.ColorSet{ - Primary: "#00ADD8", - Foreground: "#FFFFFF", - Muted: "#808080", - Border: "#606060", - Background: "#000000", - }, - } - - tree := NewTree(root, theme) - output := tree.Render(80) - - // Should still render correctly with theme - if !strings.Contains(output, "postgres") { - t.Error("Expected output to contain root label") - } - - if !strings.Contains(output, "Port") || !strings.Contains(output, "5432") { - t.Error("Expected output to contain child with value") - } -} - -// TestMultipleSiblingsRendering verifies correct branch characters for siblings. -func TestMultipleSiblingsRendering(t *testing.T) { - root := &TreeNode{ - Label: "root", - Children: []*TreeNode{ - {Label: "first"}, - {Label: "middle"}, - {Label: "last"}, - }, - } - - tree := NewTree(root, nil) - output := tree.Render(80) - - lines := strings.Split(output, "\n") - - // First child should have T-branch - if !strings.Contains(lines[1], "├──") { - t.Error("Expected first child to have T-branch") - } - - // Middle child should have T-branch - if !strings.Contains(lines[2], "├──") { - t.Error("Expected middle child to have T-branch") - } - - // Last child should have L-branch - if !strings.Contains(lines[3], "└──") { - t.Error("Expected last child to have L-branch") - } -} diff --git a/pkg/ui/components/wizard/wizard.go b/pkg/ui/components/wizard/wizard.go deleted file mode 100644 index 0c95f64..0000000 --- a/pkg/ui/components/wizard/wizard.go +++ /dev/null @@ -1,368 +0,0 @@ -// Package wizard provides multi-step form components for the UI engine. -package wizard - -import ( - "fmt" - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// WizardStep represents a single step in a multi-step wizard. -// -// Each step contains: -// - Title: The name of the step (e.g., "Profile Selection") -// - Description: Optional help text explaining the step -// - Form: The actual huh.Form for user input -type WizardStep struct { - Title string - Description string - Form *huh.Form // Uses huh for actual form rendering -} - -// Wizard wraps charmbracelet/huh forms to provide multi-step guided setup flows. -// -// Features: -// - Multi-step form display -// - Progress indicator (Step 1 of 4) -// - Theme-aware styling -// - Step validation -// - Navigation (Next/Back) -// -// Usage: -// -// steps := []WizardStep{ -// { -// Title: "Select Profile", -// Description: "Choose your workspace profile", -// Form: huh.NewForm(huh.NewGroup(huh.NewSelect[string]()...)), -// }, -// { -// Title: "Configure Options", -// Description: "Set additional preferences", -// Form: huh.NewForm(huh.NewGroup(huh.NewInput()...)), -// }, -// } -// wizard := NewWizard(steps, theme) -// -// The wizard is a tea.Model and can be used in Bubble Tea applications: -// -// program := tea.NewProgram(wizard) -// finalModel, err := program.Run() -type Wizard struct { - steps []WizardStep - currentStep int - theme *themes.Theme - complete bool - width int - height int - - // Cached styles - titleStyle lipgloss.Style - descriptionStyle lipgloss.Style - progressStyle lipgloss.Style - containerStyle lipgloss.Style -} - -// NewWizard creates a new multi-step wizard with the given steps and theme. -// -// The wizard is initialized with: -// - Profile-themed colors for progress indicators -// - First step selected by default -// - All forms configured with theme styling -// -// Example: -// -// theme := &themes.Theme{ -// Colors: themes.ColorSet{ -// Primary: "#00ADD8", -// Muted: "#666666", -// }, -// } -// steps := []WizardStep{ -// {Title: "Step 1", Form: huh.NewForm(...)}, -// } -// wizard := NewWizard(steps, theme) -func NewWizard(steps []WizardStep, theme *themes.Theme) *Wizard { - w := &Wizard{ - steps: steps, - currentStep: 0, - theme: theme, - complete: false, - width: 80, - height: 24, - } - - w.updateStyles() - w.applyThemeToForms() - - return w -} - -// updateStyles rebuilds the cached styles based on current theme. -func (w *Wizard) updateStyles() { - if w.theme == nil { - // Fallback styles - w.titleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#00ADD8")). - Bold(true). - MarginBottom(1) - - w.descriptionStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#666666")). - MarginBottom(2) - - w.progressStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#00ADD8")). - Bold(true). - MarginBottom(2) - - w.containerStyle = lipgloss.NewStyle(). - Padding(1, 2) - } else { - colors := w.theme.Colors - - w.titleStyle = lipgloss.NewStyle(). - Foreground(colors.PrimaryColor()). - Bold(true). - MarginBottom(1) - - w.descriptionStyle = lipgloss.NewStyle(). - Foreground(colors.MutedColor()). - MarginBottom(2) - - w.progressStyle = lipgloss.NewStyle(). - Foreground(colors.PrimaryColor()). - Bold(true). - MarginBottom(2) - - w.containerStyle = lipgloss.NewStyle(). - Padding(1, 2) - } -} - -// applyThemeToForms applies theme styling to all huh forms. -func (w *Wizard) applyThemeToForms() { - if w.theme == nil { - return - } - - // Create huh theme from our theme colors - huhTheme := huh.ThemeBase() - - // Apply our theme colors to the huh theme - colors := w.theme.Colors - huhTheme.Focused.Base = huhTheme.Focused.Base. - BorderForeground(lipgloss.Color(colors.PrimaryColor())) - huhTheme.Focused.Title = huhTheme.Focused.Title. - Foreground(lipgloss.Color(colors.PrimaryColor())) - huhTheme.Focused.SelectedOption = huhTheme.Focused.SelectedOption. - Foreground(lipgloss.Color(colors.PrimaryColor())) - - // Apply theme to all forms - for i := range w.steps { - if w.steps[i].Form != nil { - w.steps[i].Form.WithTheme(huhTheme) - } - } -} - -// Init implements tea.Model for Bubble Tea integration. -// -// Initializes the first step's form and returns its init command. -func (w *Wizard) Init() tea.Cmd { - if len(w.steps) == 0 { - return nil - } - - // Initialize the first step's form - if w.steps[w.currentStep].Form != nil { - return w.steps[w.currentStep].Form.Init() - } - - return nil -} - -// Update implements tea.Model to handle wizard navigation and form updates. -// -// Key handling: -// - Forms handle their own input via huh -// - Wizard detects form completion and advances steps -// - Automatic progression to next step on completion -func (w *Wizard) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - // Handle window size messages - if windowMsg, ok := msg.(tea.WindowSizeMsg); ok { - w.width = windowMsg.Width - w.height = windowMsg.Height - // Propagate to current form - if w.currentStep < len(w.steps) && w.steps[w.currentStep].Form != nil { - form, cmd := w.steps[w.currentStep].Form.Update(msg) - w.steps[w.currentStep].Form = form.(*huh.Form) - return w, cmd - } - return w, nil - } - - // If wizard is complete, don't process further updates - if w.complete { - return w, nil - } - - // No steps to process - if len(w.steps) == 0 { - w.complete = true - return w, nil - } - - // Update current step's form - currentForm := w.steps[w.currentStep].Form - if currentForm != nil { - form, cmd := currentForm.Update(msg) - w.steps[w.currentStep].Form = form.(*huh.Form) - - // Check if current step is complete - if w.steps[w.currentStep].Form.State == huh.StateCompleted { - // Move to next step - if w.currentStep < len(w.steps)-1 { - w.currentStep++ - // Initialize next step's form - return w, w.steps[w.currentStep].Form.Init() - } - // All steps complete - w.complete = true - return w, tea.Quit - } - - return w, cmd - } - - return w, nil -} - -// View implements tea.Model to render the wizard UI. -// -// The wizard renders: -// - Progress indicator (e.g., "Step 2 of 4") -// - Current step title -// - Optional description text -// - The huh.Form for the current step -// -// Example output: -// -// Step 2 of 4 -// -// Configure Options -// Set additional preferences -// -// [Form fields rendered here by huh] -func (w *Wizard) View() string { - if len(w.steps) == 0 { - return w.containerStyle.Render("No steps configured") - } - - if w.complete { - return w.containerStyle.Render( - w.titleStyle.Render("Setup Complete!") + "\n" + - w.descriptionStyle.Render("Your wizard has finished successfully."), - ) - } - - // Build progress indicator - progress := fmt.Sprintf("Step %d of %d", w.currentStep+1, len(w.steps)) - progressLine := w.progressStyle.Render(progress) - - // Current step details - step := w.steps[w.currentStep] - titleLine := w.titleStyle.Render(step.Title) - - var descLine string - if step.Description != "" { - descLine = w.descriptionStyle.Render(step.Description) - } - - // Render current form - var formView string - if step.Form != nil { - formView = step.Form.View() - } - - // Combine all elements - parts := []string{progressLine, titleLine} - if descLine != "" { - parts = append(parts, descLine) - } - parts = append(parts, formView) - - content := strings.Join(parts, "\n") - - return w.containerStyle.Render(content) -} - -// CurrentStep returns the zero-based index of the current step. -// -// Returns 0 for the first step, 1 for the second, etc. -// Returns -1 if no steps exist. -func (w *Wizard) CurrentStep() int { - if len(w.steps) == 0 { - return -1 - } - return w.currentStep -} - -// IsComplete returns true if all wizard steps have been completed. -// -// The wizard is complete when: -// - All steps have been processed -// - The final step's form has been submitted -// - Or there are no steps configured -func (w *Wizard) IsComplete() bool { - return w.complete -} - -// SetSize sets the wizard's display dimensions. -// -// This affects the layout of the wizard container and is propagated -// to child forms as window size messages. -func (w *Wizard) SetSize(width, height int) { - w.width = width - w.height = height -} - -// TotalSteps returns the total number of steps in the wizard. -func (w *Wizard) TotalSteps() int { - return len(w.steps) -} - -// SetTheme updates the theme for the wizard and all its forms. -// -// This allows dynamic theme switching without recreating the component. -func (w *Wizard) SetTheme(theme *themes.Theme) { - w.theme = theme - w.updateStyles() - w.applyThemeToForms() -} - -// GetStepForm returns the huh.Form for a specific step index. -// -// Returns nil if the index is out of bounds. -// This is useful for extracting form values after completion. -func (w *Wizard) GetStepForm(index int) *huh.Form { - if index < 0 || index >= len(w.steps) { - return nil - } - return w.steps[index].Form -} - -// CurrentStepForm returns the huh.Form for the current step. -// -// Returns nil if no steps exist. -func (w *Wizard) CurrentStepForm() *huh.Form { - if w.currentStep < 0 || w.currentStep >= len(w.steps) { - return nil - } - return w.steps[w.currentStep].Form -} diff --git a/pkg/ui/components/wizard/wizard_test.go b/pkg/ui/components/wizard/wizard_test.go deleted file mode 100644 index 7c70e78..0000000 --- a/pkg/ui/components/wizard/wizard_test.go +++ /dev/null @@ -1,527 +0,0 @@ -package wizard - -import ( - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/huh" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// mockTheme creates a test theme for wizard testing. -func mockTheme() *themes.Theme { - return &themes.Theme{ - Name: "test-theme", - Colors: themes.ColorSet{ - Primary: "#00ADD8", - Secondary: "#FF6B6B", - Background: "#282A36", - Foreground: "#F8F8F2", - Muted: "#6272A4", - Border: "#44475A", - Success: "#50FA7B", - Warning: "#FFB86C", - Error: "#FF5555", - Info: "#8BE9FD", - }, - } -} - -// createSimpleForm creates a basic huh form for testing. -func createSimpleForm(title string) *huh.Form { - var value string - return huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title(title). - Value(&value), - ), - ) -} - -func TestNewWizard(t *testing.T) { - theme := mockTheme() - steps := []WizardStep{ - { - Title: "Step 1", - Description: "First step", - Form: createSimpleForm("Name"), - }, - { - Title: "Step 2", - Description: "Second step", - Form: createSimpleForm("Email"), - }, - } - - wizard := NewWizard(steps, theme) - - if wizard == nil { - t.Fatal("Expected wizard to be created, got nil") - } - - if wizard.currentStep != 0 { - t.Errorf("Expected currentStep to be 0, got %d", wizard.currentStep) - } - - if wizard.complete { - t.Error("Expected wizard to not be complete initially") - } - - if wizard.theme != theme { - t.Error("Expected wizard theme to match provided theme") - } - - if len(wizard.steps) != 2 { - t.Errorf("Expected 2 steps, got %d", len(wizard.steps)) - } -} - -func TestNewWizardWithNilTheme(t *testing.T) { - steps := []WizardStep{ - {Title: "Step 1", Form: createSimpleForm("Name")}, - } - - wizard := NewWizard(steps, nil) - - if wizard == nil { - t.Fatal("Expected wizard to be created even with nil theme") - } - - // Should use fallback styles - view := wizard.View() - if view == "" { - t.Error("Expected view to render with fallback styles") - } -} - -func TestNewWizardEmpty(t *testing.T) { - wizard := NewWizard([]WizardStep{}, mockTheme()) - - if wizard == nil { - t.Fatal("Expected wizard to be created with empty steps") - } - - if wizard.CurrentStep() != -1 { - t.Errorf("Expected CurrentStep to be -1 for empty wizard, got %d", wizard.CurrentStep()) - } -} - -func TestWizardInit(t *testing.T) { - steps := []WizardStep{ - {Title: "Step 1", Form: createSimpleForm("Name")}, - } - wizard := NewWizard(steps, mockTheme()) - - cmd := wizard.Init() - - // Should return the first form's init command - if cmd == nil { - t.Error("Expected Init to return a command") - } -} - -func TestWizardInitEmpty(t *testing.T) { - wizard := NewWizard([]WizardStep{}, mockTheme()) - - cmd := wizard.Init() - - // Should return nil for empty wizard - if cmd != nil { - t.Error("Expected Init to return nil for empty wizard") - } -} - -func TestWizardCurrentStep(t *testing.T) { - steps := []WizardStep{ - {Title: "Step 1", Form: createSimpleForm("Name")}, - {Title: "Step 2", Form: createSimpleForm("Email")}, - {Title: "Step 3", Form: createSimpleForm("Phone")}, - } - wizard := NewWizard(steps, mockTheme()) - - if wizard.CurrentStep() != 0 { - t.Errorf("Expected CurrentStep to be 0, got %d", wizard.CurrentStep()) - } - - // Simulate advancing to next step - wizard.currentStep = 1 - if wizard.CurrentStep() != 1 { - t.Errorf("Expected CurrentStep to be 1, got %d", wizard.CurrentStep()) - } - - wizard.currentStep = 2 - if wizard.CurrentStep() != 2 { - t.Errorf("Expected CurrentStep to be 2, got %d", wizard.CurrentStep()) - } -} - -func TestWizardIsComplete(t *testing.T) { - steps := []WizardStep{ - {Title: "Step 1", Form: createSimpleForm("Name")}, - } - wizard := NewWizard(steps, mockTheme()) - - if wizard.IsComplete() { - t.Error("Expected wizard to not be complete initially") - } - - wizard.complete = true - - if !wizard.IsComplete() { - t.Error("Expected wizard to be complete after setting flag") - } -} - -func TestWizardTotalSteps(t *testing.T) { - tests := []struct { - name string - numSteps int - }{ - {"no steps", 0}, - {"one step", 1}, - {"three steps", 3}, - {"many steps", 10}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - steps := make([]WizardStep, tt.numSteps) - for i := 0; i < tt.numSteps; i++ { - steps[i] = WizardStep{ - Title: "Step", - Form: createSimpleForm("Input"), - } - } - - wizard := NewWizard(steps, mockTheme()) - - if wizard.TotalSteps() != tt.numSteps { - t.Errorf("Expected TotalSteps to be %d, got %d", tt.numSteps, wizard.TotalSteps()) - } - }) - } -} - -func TestWizardView(t *testing.T) { - steps := []WizardStep{ - { - Title: "Profile Selection", - Description: "Choose your workspace profile", - Form: createSimpleForm("Profile"), - }, - { - Title: "Configuration", - Description: "Set additional options", - Form: createSimpleForm("Options"), - }, - } - wizard := NewWizard(steps, mockTheme()) - - view := wizard.View() - - // Check that view contains expected elements - if !strings.Contains(view, "Step 1 of 2") { - t.Error("Expected view to contain progress indicator 'Step 1 of 2'") - } - - if !strings.Contains(view, "Profile Selection") { - t.Error("Expected view to contain step title 'Profile Selection'") - } - - if !strings.Contains(view, "Choose your workspace profile") { - t.Error("Expected view to contain step description") - } -} - -func TestWizardViewNoDescription(t *testing.T) { - steps := []WizardStep{ - { - Title: "Step 1", - // No description - Form: createSimpleForm("Input"), - }, - } - wizard := NewWizard(steps, mockTheme()) - - view := wizard.View() - - // Should still render without description - if !strings.Contains(view, "Step 1") { - t.Error("Expected view to contain step title") - } - - if !strings.Contains(view, "Step 1 of 1") { - t.Error("Expected view to contain progress indicator") - } -} - -func TestWizardViewComplete(t *testing.T) { - steps := []WizardStep{ - {Title: "Step 1", Form: createSimpleForm("Input")}, - } - wizard := NewWizard(steps, mockTheme()) - wizard.complete = true - - view := wizard.View() - - // Should show completion message - if !strings.Contains(view, "Setup Complete") { - t.Error("Expected view to contain completion message") - } -} - -func TestWizardViewEmpty(t *testing.T) { - wizard := NewWizard([]WizardStep{}, mockTheme()) - - view := wizard.View() - - // Should show empty state message - if !strings.Contains(view, "No steps configured") { - t.Error("Expected view to show 'No steps configured' message") - } -} - -func TestWizardSetSize(t *testing.T) { - wizard := NewWizard([]WizardStep{{Title: "Step 1", Form: createSimpleForm("Input")}}, mockTheme()) - - wizard.SetSize(100, 30) - - if wizard.width != 100 { - t.Errorf("Expected width to be 100, got %d", wizard.width) - } - - if wizard.height != 30 { - t.Errorf("Expected height to be 30, got %d", wizard.height) - } -} - -func TestWizardSetTheme(t *testing.T) { - wizard := NewWizard([]WizardStep{{Title: "Step 1", Form: createSimpleForm("Input")}}, mockTheme()) - - newTheme := &themes.Theme{ - Name: "new-theme", - Colors: themes.ColorSet{ - Primary: "#FF0000", - }, - } - - wizard.SetTheme(newTheme) - - if wizard.theme != newTheme { - t.Error("Expected wizard theme to be updated") - } -} - -func TestWizardGetStepForm(t *testing.T) { - form1 := createSimpleForm("Input1") - form2 := createSimpleForm("Input2") - - steps := []WizardStep{ - {Title: "Step 1", Form: form1}, - {Title: "Step 2", Form: form2}, - } - wizard := NewWizard(steps, mockTheme()) - - // Valid indices - if wizard.GetStepForm(0) != form1 { - t.Error("Expected GetStepForm(0) to return first form") - } - - if wizard.GetStepForm(1) != form2 { - t.Error("Expected GetStepForm(1) to return second form") - } - - // Invalid indices - if wizard.GetStepForm(-1) != nil { - t.Error("Expected GetStepForm(-1) to return nil") - } - - if wizard.GetStepForm(2) != nil { - t.Error("Expected GetStepForm(2) to return nil for out of bounds") - } -} - -func TestWizardCurrentStepForm(t *testing.T) { - form1 := createSimpleForm("Input1") - form2 := createSimpleForm("Input2") - - steps := []WizardStep{ - {Title: "Step 1", Form: form1}, - {Title: "Step 2", Form: form2}, - } - wizard := NewWizard(steps, mockTheme()) - - // First step - if wizard.CurrentStepForm() != form1 { - t.Error("Expected CurrentStepForm to return first form initially") - } - - // Advance to second step - wizard.currentStep = 1 - if wizard.CurrentStepForm() != form2 { - t.Error("Expected CurrentStepForm to return second form after advancing") - } -} - -func TestWizardCurrentStepFormEmpty(t *testing.T) { - wizard := NewWizard([]WizardStep{}, mockTheme()) - - if wizard.CurrentStepForm() != nil { - t.Error("Expected CurrentStepForm to return nil for empty wizard") - } -} - -func TestWizardUpdateWindowSize(t *testing.T) { - wizard := NewWizard([]WizardStep{{Title: "Step 1", Form: createSimpleForm("Input")}}, mockTheme()) - - msg := tea.WindowSizeMsg{Width: 120, Height: 40} - model, _ := wizard.Update(msg) - - updatedWizard := model.(*Wizard) - if updatedWizard.width != 120 { - t.Errorf("Expected width to be 120, got %d", updatedWizard.width) - } - - if updatedWizard.height != 40 { - t.Errorf("Expected height to be 40, got %d", updatedWizard.height) - } -} - -func TestWizardUpdateComplete(t *testing.T) { - wizard := NewWizard([]WizardStep{{Title: "Step 1", Form: createSimpleForm("Input")}}, mockTheme()) - wizard.complete = true - - // Should not process updates when complete - msg := tea.KeyMsg{Type: tea.KeyEnter} - model, cmd := wizard.Update(msg) - - if cmd != nil { - t.Error("Expected no command when wizard is complete") - } - - updatedWizard := model.(*Wizard) - if !updatedWizard.complete { - t.Error("Expected wizard to remain complete") - } -} - -func TestWizardUpdateEmpty(t *testing.T) { - wizard := NewWizard([]WizardStep{}, mockTheme()) - - msg := tea.KeyMsg{Type: tea.KeyEnter} - model, _ := wizard.Update(msg) - - updatedWizard := model.(*Wizard) - if !updatedWizard.complete { - t.Error("Expected empty wizard to be marked complete after update") - } -} - -func TestWizardStepStruct(t *testing.T) { - form := createSimpleForm("Test") - step := WizardStep{ - Title: "Test Step", - Description: "Test description", - Form: form, - } - - if step.Title != "Test Step" { - t.Errorf("Expected title 'Test Step', got '%s'", step.Title) - } - - if step.Description != "Test description" { - t.Errorf("Expected description 'Test description', got '%s'", step.Description) - } - - if step.Form != form { - t.Error("Expected form to match") - } -} - -func TestWizardMultipleStepsProgression(t *testing.T) { - steps := []WizardStep{ - {Title: "Step 1", Form: createSimpleForm("Input1")}, - {Title: "Step 2", Form: createSimpleForm("Input2")}, - {Title: "Step 3", Form: createSimpleForm("Input3")}, - } - wizard := NewWizard(steps, mockTheme()) - - // Initial state - if wizard.CurrentStep() != 0 { - t.Error("Expected to start at step 0") - } - - // Simulate step progression by manually advancing - wizard.currentStep = 1 - if wizard.CurrentStep() != 1 { - t.Error("Expected to be at step 1") - } - - wizard.currentStep = 2 - if wizard.CurrentStep() != 2 { - t.Error("Expected to be at step 2") - } - - // Mark as complete - wizard.complete = true - if !wizard.IsComplete() { - t.Error("Expected wizard to be complete") - } -} - -func TestWizardViewProgressIndicator(t *testing.T) { - tests := []struct { - name string - currentStep int - totalSteps int - expectedText string - }{ - {"first of one", 0, 1, "Step 1 of 1"}, - {"first of three", 0, 3, "Step 1 of 3"}, - {"second of three", 1, 3, "Step 2 of 3"}, - {"last of five", 4, 5, "Step 5 of 5"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - steps := make([]WizardStep, tt.totalSteps) - for i := 0; i < tt.totalSteps; i++ { - steps[i] = WizardStep{ - Title: "Step", - Form: createSimpleForm("Input"), - } - } - - wizard := NewWizard(steps, mockTheme()) - wizard.currentStep = tt.currentStep - - view := wizard.View() - if !strings.Contains(view, tt.expectedText) { - t.Errorf("Expected view to contain '%s', but it didn't.\nView: %s", tt.expectedText, view) - } - }) - } -} - -func TestWizardThemeApplication(t *testing.T) { - theme := mockTheme() - steps := []WizardStep{ - {Title: "Step 1", Form: createSimpleForm("Input")}, - } - - wizard := NewWizard(steps, theme) - - // Verify theme is applied - if wizard.theme != theme { - t.Error("Expected theme to be set") - } - - // Verify the wizard renders correctly with theme - view := wizard.View() - if view == "" { - t.Error("Expected wizard to render with theme applied") - } -} diff --git a/pkg/ui/engine/.gitkeep b/pkg/ui/engine/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/pkg/ui/engine/README.md b/pkg/ui/engine/README.md deleted file mode 100644 index 165c513..0000000 --- a/pkg/ui/engine/README.md +++ /dev/null @@ -1,402 +0,0 @@ -# UI Engine Package - -**Package**: `pkg/ui/engine` -**Purpose**: Core UI rendering engine for ARC CLI -**Feature**: 017-ui-engine (UI Engine Redesign) - -## Overview - -The UI Engine package provides the foundational infrastructure for the ARC CLI's modern TUI (Text User Interface). It implements a view-based architecture inspired by gh-dash, with support for navigation, routing, and multiple rendering modes. - -## Core Components - -### View Interface - -The `View` interface defines the contract for all UI screens in the application: - -```go -type View interface { - // Bubble Tea lifecycle methods - Init() tea.Cmd - Update(tea.Msg) (tea.Model, tea.Cmd) - View() string - - // View-specific lifecycle - OnEnter(ctx *ViewContext) tea.Cmd // Called when view becomes active - OnExit() tea.Cmd // Called when view is replaced - - // Metadata - Name() string // Unique identifier for routing - Keybindings() []KeyBinding // View-specific keyboard shortcuts -} -``` - -**Purpose**: Standardizes how views are initialized, updated, rendered, and navigated. - -### Router - -The `Router` manages navigation between views with history tracking: - -```go -type Router struct { - current View - views map[string]View - history []string - context *ViewContext -} -``` - -**Key Features**: -- Named route registration -- Navigation history stack (max 10 levels) -- Back navigation with state preservation -- View lifecycle management (OnEnter/OnExit hooks) - -**Methods**: -- `Register(view View)` - Add a view to the router -- `Navigate(name string, args map[string]any)` - Switch to a view -- `Back()` - Return to previous view -- `Current() View` - Get active view - -### ViewContext - -State container passed to views during lifecycle events: - -```go -type ViewContext struct { - Profile *profiles.Profile - Theme *themes.Theme - Width int - Height int - Args map[string]any // Route-specific parameters -} -``` - -**Purpose**: Provides views with access to global state (profile, theme, terminal dimensions) and route parameters. - -### Render System - -Unified rendering function supporting multiple output modes: - -```go -func Render(config RenderConfig) error -``` - -**Rendering Modes**: -1. **TUI Mode** (default): Full interactive Bubble Tea program -2. **JSON Mode** (`--json` flag): Structured data output -3. **Static Mode** (`--no-animation` flag): Non-interactive text output - -**Configuration**: -```go -type RenderConfig struct { - View View - Mode RenderMode // TUI, JSON, Static - JSONData any // Data for JSON mode - StaticData string // Content for static mode -} -``` - -## Architecture Patterns - -### CRUD Framework - -The UI Engine treats CLI commands as CRUD operations: - -- **Create**: Wizards for workspace init, config setup -- **Read**: List views (services, workspaces), detail views -- **Update**: Config editors, profile selection -- **Delete**: Confirmation dialogs (not yet implemented) - -### Component Composition - -Views compose reusable components from `pkg/ui/components/*`: - -```go -// Example: ServicesListView -type ServicesListView struct { - searchBar *search.SearchBar - dataTable *table.DataTable - statusBar *status.StatusBar -} -``` - -### State Preservation - -Router preserves view state during navigation: - -- Scroll position in tables -- Search query terms -- Selected items -- Focus state - -State is restored when navigating back to a view. - -## Performance Features - -### Component Caching - -The engine caches rendered components using an LRU cache (max 50 entries): - -**Cached Components**: -- Hero sections (all 10 profiles) -- Sidebar layouts -- DataTable headers - -**Benefits**: -- Reduces re-render time -- Improves navigation latency (<16ms target) -- Lowers memory churn - -### Lazy Initialization - -Views and components are initialized only when needed: - -1. View registration does not instantiate views -2. Components are created on first render -3. Cache entries are populated on-demand - -## Integration with Existing Code - -### ComponentFactory Extension - -The engine extends the existing `ComponentFactory` pattern: - -```go -// Existing factory (pkg/ui/factory.go) -factory := ui.NewComponentFactory(profileCtx) - -// New engine methods -router := engine.NewRouter(factory) -router.Register(views.NewHomeView(factory)) -router.Register(views.NewServicesListView(factory)) -``` - -**No Breaking Changes**: Existing component code remains unchanged. - -### Profile System Integration - -The engine integrates with the existing profile system: - -- Profile loading: `pkg/ui/profiles/embedded/*.yaml` -- Theme caching: `pkg/ui/themes/embedded/*.yaml` -- ProfileContext: Lazy-loaded in `app.Context` - -**Enterprise Profile Fallback**: Used when no profile is selected. - -## Testing Strategy - -### Unit Tests - -Test individual engine components: - -- View interface implementations -- Router navigation logic -- Render mode switching -- Context propagation - -**Target Coverage**: 75%+ for engine package - -### Visual Regression Tests - -Golden file tests for rendered output: - -``` -tests/visual/golden/ -├── home_enterprise.txt -├── home_saiyan.txt -├── services_list_enterprise.txt -└── ... -``` - -**Test Matrix**: All 10 profiles × 6+ views = 60+ golden files - -### Performance Benchmarks - -Measure and enforce performance targets: - -```go -// tests/performance/engine_bench_test.go -BenchmarkNavigationLatency // Target: <16ms -BenchmarkStartupTime // Target: <100ms -BenchmarkSearchFilter // Target: <100ms for 100 items -``` - -## Usage Examples - -### Basic View Implementation - -```go -package views - -type HomeView struct { - hero *hero.Hero - footer *footer.Footer -} - -func (v *HomeView) Init() tea.Cmd { - return nil -} - -func (v *HomeView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - if msg.String() == "q" { - return v, tea.Quit - } - } - return v, nil -} - -func (v *HomeView) View() string { - return lipgloss.JoinVertical( - lipgloss.Left, - v.hero.Render(), - v.footer.Render(), - ) -} - -func (v *HomeView) OnEnter(ctx *ViewContext) tea.Cmd { - v.hero.SetProfile(ctx.Profile) - return nil -} - -func (v *HomeView) OnExit() tea.Cmd { - return nil -} - -func (v *HomeView) Name() string { - return "home" -} - -func (v *HomeView) Keybindings() []KeyBinding { - return []KeyBinding{ - {Key: "q", Description: "Quit"}, - } -} -``` - -### Router Setup in Command - -```go -package cmd - -func homeCmd(ctx *app.Context) *cobra.Command { - return &cobra.Command{ - Use: "home", - Short: "Display ARC homepage with profile branding", - Run: func(cmd *cobra.Command, args []string) { - // Setup router - factory := ui.NewComponentFactory(ctx.ProfileContext()) - router := engine.NewRouter(factory) - - // Register views - router.Register(views.NewHomeView(factory)) - - // Navigate to initial view - router.Navigate("home", nil) - - // Render with appropriate mode - mode := engine.TUIMode - if jsonFlag { - mode = engine.JSONMode - } - - engine.Render(engine.RenderConfig{ - View: router.Current(), - Mode: mode, - }) - }, - } -} -``` - -### Multi-View Navigation - -```go -// Dashboard with sidebar navigation -router := engine.NewRouter(factory) -router.Register(views.NewDashboardView(factory)) -router.Register(views.NewServicesListView(factory)) -router.Register(views.NewServiceDetailView(factory)) - -// Navigate: Dashboard → Services List -router.Navigate("dashboard", nil) -router.Navigate("services", nil) - -// Navigate: Services List → Service Detail -router.Navigate("service-detail", map[string]any{ - "serviceName": "postgres", -}) - -// Back navigation: Service Detail → Services List -router.Back() -``` - -## Migration from Legacy UI - -### Gradual Rollout - -Commands check `ARC_USE_LEGACY_UI` environment variable: - -```go -func homeCmd(ctx *app.Context) *cobra.Command { - return &cobra.Command{ - Use: "home", - Run: func(cmd *cobra.Command, args []string) { - if os.Getenv("ARC_USE_LEGACY_UI") == "1" { - // Old UI code (pkg/ui/legacy/) - legacyHome(ctx) - return - } - - // New UI engine - renderHomeView(ctx) - }, - } -} -``` - -### Border Rendering Fix - -The engine uses `lipgloss.Width()` instead of `len()` for ANSI/Unicode strings: - -```go -// OLD (broken with ANSI codes) -width := len(content) - -// NEW (correct) -width := lipgloss.Width(content) -``` - -**Affected Areas**: Border rendering in tables, panels, hero sections. - -## Dependencies - -- `github.com/charmbracelet/bubbletea@v1.3.4` - TUI framework -- `github.com/charmbracelet/lipgloss@v1.1.1` - Styling -- `github.com/charmbracelet/bubbles@v0.21.0` - Components - -## Related Packages - -- `pkg/ui/components/*` - Reusable UI components (Hero, Sidebar, DataTable, etc.) -- `pkg/ui/views/*` - Command-specific view implementations -- `pkg/ui/layouts/*` - Layout containers (HeroLayout, SidebarLayout, CompactLayout, WizardLayout) -- `pkg/ui/profiles/` - Profile system integration -- `pkg/ui/themes/` - Theme system integration - -## Implementation Status - -**Phase 1**: ✅ Package structure created -**Phase 2**: ⏳ Foundation (View, Router, Render) - In Progress -**Phase 3+**: ⏳ View implementations - Pending - -See `specs/017-ui-engine/tasks.md` for full implementation plan. - -## References - -- Feature Specification: `specs/017-ui-engine/spec.md` -- Implementation Plan: `specs/017-ui-engine/plan.md` -- Design Decisions: `specs/017-ui-engine/research.md` -- Task Breakdown: `specs/017-ui-engine/tasks.md` -- Workflow Guide: `specs/017-ui-engine/IMPLEMENTATION_WORKFLOW.md` diff --git a/pkg/ui/engine/cache.go b/pkg/ui/engine/cache.go deleted file mode 100644 index 98b85d0..0000000 --- a/pkg/ui/engine/cache.go +++ /dev/null @@ -1,103 +0,0 @@ -// Package engine provides the UI engine for the A.R.C. CLI. -package engine - -import ( - "sync" -) - -// ComponentCache provides LRU caching for UI components. -// It stores components by string key, evicting the least-recently-used -// entry when the cache reaches maxSize (50 entries). -// -// Usage: -// -// cache := NewComponentCache() -// hero := cache.GetOrCreate("enterprise", func() any { -// return hero.NewHero(theme, logo) -// }) -type ComponentCache struct { - mu sync.Mutex - items map[string]*cacheEntry - order []string // LRU order: oldest at index 0 - maxSize int -} - -type cacheEntry struct { - value any -} - -// NewComponentCache creates a new ComponentCache with max 50 entries. -func NewComponentCache() *ComponentCache { - return &ComponentCache{ - items: make(map[string]*cacheEntry), - order: make([]string, 0, 50), - maxSize: 50, - } -} - -// GetOrCreate returns a cached component or creates it with factory if not cached. -// Thread-safe via a single mutex protecting the entire operation. -func (c *ComponentCache) GetOrCreate(key string, factory func() any) any { - c.mu.Lock() - defer c.mu.Unlock() - - // Fast path: already cached — promote to MRU position and return. - if entry, ok := c.items[key]; ok { - c.updateLRU(key) - return entry.value - } - - // Evict LRU entry if at capacity. - if len(c.items) >= c.maxSize { - c.evictOldest() - } - - // Create, cache and record insertion order. - value := factory() - c.items[key] = &cacheEntry{value: value} - c.order = append(c.order, key) - - return value -} - -// Size returns the current number of cached items. -func (c *ComponentCache) Size() int { - c.mu.Lock() - defer c.mu.Unlock() - - return len(c.items) -} - -// Clear removes all items from the cache. -func (c *ComponentCache) Clear() { - c.mu.Lock() - defer c.mu.Unlock() - - c.items = make(map[string]*cacheEntry) - c.order = make([]string, 0, c.maxSize) -} - -// updateLRU moves key to end of order slice (most recently used). -// Must be called with mu held. -func (c *ComponentCache) updateLRU(key string) { - for i, k := range c.order { - if k == key { - c.order = append(c.order[:i], c.order[i+1:]...) - c.order = append(c.order, key) - - return - } - } -} - -// evictOldest removes the least-recently-used entry. -// Must be called with mu held. -func (c *ComponentCache) evictOldest() { - if len(c.order) == 0 { - return - } - - oldest := c.order[0] - c.order = c.order[1:] - delete(c.items, oldest) -} diff --git a/pkg/ui/engine/cache_test.go b/pkg/ui/engine/cache_test.go deleted file mode 100644 index 6cc2388..0000000 --- a/pkg/ui/engine/cache_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package engine - -import ( - "fmt" - "sync" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNewComponentCache(t *testing.T) { - cache := NewComponentCache() - - require.NotNil(t, cache) - assert.Equal(t, 0, cache.Size()) - assert.Equal(t, 50, cache.maxSize) -} - -func TestComponentCache_GetOrCreate_Miss(t *testing.T) { - cache := NewComponentCache() - - callCount := 0 - factory := func() any { - callCount++ - return "component-value" - } - - result := cache.GetOrCreate("missing-key", factory) - - assert.Equal(t, "component-value", result) - assert.Equal(t, 1, callCount, "factory must be called on cache miss") - assert.Equal(t, 1, cache.Size()) -} - -func TestComponentCache_GetOrCreate_Hit(t *testing.T) { - cache := NewComponentCache() - - callCount := 0 - factory := func() any { - callCount++ - return "cached-value" - } - - // Populate the cache. - first := cache.GetOrCreate("my-key", factory) - - // Second call must return the cached value without invoking factory again. - second := cache.GetOrCreate("my-key", factory) - - assert.Equal(t, "cached-value", first) - assert.Equal(t, "cached-value", second) - assert.Equal(t, 1, callCount, "factory must NOT be called on cache hit") - assert.Equal(t, 1, cache.Size()) -} - -func TestComponentCache_LRU_Eviction(t *testing.T) { - cache := NewComponentCache() - - // Fill cache to capacity (50 items). - for i := range 50 { - key := fmt.Sprintf("key-%d", i) - cache.GetOrCreate(key, func() any { return key }) - } - - require.Equal(t, 50, cache.Size()) - - // Adding a 51st item must evict the oldest ("key-0"). - cache.GetOrCreate("key-50", func() any { return "key-50" }) - - assert.Equal(t, 50, cache.Size(), "size must remain at maxSize after eviction") - - // The oldest entry must have been evicted. - callCount := 0 - cache.GetOrCreate("key-0", func() any { - callCount++ - return "re-created" - }) - assert.Equal(t, 1, callCount, "key-0 must have been evicted and factory re-invoked") - - // The newly inserted item must still be present. - callCount = 0 - cache.GetOrCreate("key-50", func() any { - callCount++ - return "should-not-be-called" - }) - assert.Equal(t, 0, callCount, "key-50 must still be cached") -} - -func TestComponentCache_GetOrCreate_UpdatesLRU(t *testing.T) { - cache := NewComponentCache() - - // Fill cache to capacity. - for i := range 50 { - key := fmt.Sprintf("key-%d", i) - cache.GetOrCreate(key, func() any { return key }) - } - - // Access the oldest entry ("key-0") to promote it to MRU. - cache.GetOrCreate("key-0", func() any { return "key-0" }) - - // Now "key-1" is the new LRU. Adding one more item must evict "key-1". - cache.GetOrCreate("key-51", func() any { return "key-51" }) - - assert.Equal(t, 50, cache.Size()) - - // key-0 must still be cached (it was promoted). - callCount := 0 - cache.GetOrCreate("key-0", func() any { - callCount++ - return "should-not-be-called" - }) - assert.Equal(t, 0, callCount, "key-0 must still be cached after LRU promotion") - - // key-1 must have been evicted (it became the oldest after key-0 was promoted). - callCount = 0 - cache.GetOrCreate("key-1", func() any { - callCount++ - return "re-created" - }) - assert.Equal(t, 1, callCount, "key-1 must have been evicted as LRU") -} - -func TestComponentCache_Size(t *testing.T) { - cache := NewComponentCache() - - assert.Equal(t, 0, cache.Size()) - - cache.GetOrCreate("a", func() any { return 1 }) - assert.Equal(t, 1, cache.Size()) - - cache.GetOrCreate("b", func() any { return 2 }) - assert.Equal(t, 2, cache.Size()) - - // Accessing an existing key must not change the size. - cache.GetOrCreate("a", func() any { return 99 }) - assert.Equal(t, 2, cache.Size()) -} - -func TestComponentCache_Clear(t *testing.T) { - cache := NewComponentCache() - - cache.GetOrCreate("x", func() any { return "x" }) - cache.GetOrCreate("y", func() any { return "y" }) - require.Equal(t, 2, cache.Size()) - - cache.Clear() - - assert.Equal(t, 0, cache.Size()) - - // After clear, factory must be called again for previously cached keys. - callCount := 0 - cache.GetOrCreate("x", func() any { - callCount++ - return "x-new" - }) - assert.Equal(t, 1, callCount, "factory must be called after Clear") -} - -func TestComponentCache_Concurrent(t *testing.T) { - t.Parallel() - - cache := NewComponentCache() - - const goroutines = 20 - const keysPerGoroutine = 10 - - var wg sync.WaitGroup - wg.Add(goroutines) - - for g := range goroutines { - go func(gID int) { - defer wg.Done() - - for k := range keysPerGoroutine { - key := fmt.Sprintf("g%d-k%d", gID, k) - cache.GetOrCreate(key, func() any { return key }) - } - - // Also read keys written by this goroutine. - for k := range keysPerGoroutine { - key := fmt.Sprintf("g%d-k%d", gID, k) - cache.GetOrCreate(key, func() any { return key + "-dup" }) - } - }(g) - } - - wg.Wait() - - // Cache is bounded at 50, so size must not exceed maxSize. - assert.LessOrEqual(t, cache.Size(), 50) -} - -func TestComponentCache_MaxSize_Is_50(t *testing.T) { - cache := NewComponentCache() - - assert.Equal(t, 50, cache.maxSize, "maxSize must be exactly 50 per T269") - - // Confirm that exactly 50 items can be stored before eviction begins. - for i := range 50 { - cache.GetOrCreate(fmt.Sprintf("k%d", i), func() any { return i }) - } - - assert.Equal(t, 50, cache.Size()) - - // The 51st item triggers eviction — size must remain at 50. - cache.GetOrCreate("k50", func() any { return 50 }) - - assert.Equal(t, 50, cache.Size(), "size must not exceed maxSize of 50") -} diff --git a/pkg/ui/engine/context.go b/pkg/ui/engine/context.go index ce11364..e823ae1 100644 --- a/pkg/ui/engine/context.go +++ b/pkg/ui/engine/context.go @@ -1,79 +1,45 @@ package engine import ( - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" + "github.com/arc-framework/arc-cli/pkg/catalog" + "github.com/arc-framework/arc-cli/pkg/store" + "github.com/arc-framework/arc-cli/pkg/ui/theme" ) -// ViewContext contains state passed to views during lifecycle events. -// -// The context provides views with access to: -// - Active user profile (logo, colors, tagline) -// - Active theme (color scheme) -// - Terminal dimensions (for responsive layouts) -// - Route-specific parameters (e.g., service name, workspace ID) -// -// ViewContext is created by the Router and passed to View.OnEnter() when -// navigating to a new view. -type ViewContext struct { - // Profile is the active user profile containing branding information. - // This is loaded from pkg/ui/profiles/embedded/*.yaml. - // - // If no profile is selected, this defaults to the Enterprise profile. - Profile *profiles.Profile - - // Theme is the active color theme. - // This is loaded from pkg/ui/themes/embedded/*.yaml and matches the profile. - Theme *themes.Theme +// Backend holds the backend services that views may access. +// The engine never calls these directly - they are passed to views via ViewContext. +type Backend struct { + Catalog catalog.Catalog + Store *store.Store +} - // Width is the current terminal width in columns. - // Views should use this for responsive layout decisions. - // - // Minimum supported width: 80 columns - Width int +// ViewContext is passed to every View on OnEnter. +// It bundles theme, dimensions, and backend services so views +// never need to reach outside their package. +type ViewContext struct { + // Theme is the unified style context (colors, profile, skin). + Theme *theme.Context - // Height is the current terminal height in rows. - // Views should use this to determine how much content can be displayed. - // - // Typical range: 24-60 rows + // Width and Height are the current terminal dimensions. + Width int Height int - // Args contains route-specific parameters passed during navigation. - // - // Example for service detail view: - // router.Navigate("service-detail", map[string]any{ - // "serviceName": "postgres", - // "showConfig": true, - // }) - // - // In the view: - // func (v *ServiceDetailView) OnEnter(ctx *ViewContext) tea.Cmd { - // serviceName := ctx.Args["serviceName"].(string) - // showConfig := ctx.Args["showConfig"].(bool) - // // ... use parameters to load data - // } - Args map[string]any + // Backend provides access to catalog and store services. + Backend Backend + + // Args holds any view-specific arguments (e.g., selected service ID). + Args map[string]string } -// NewViewContext creates a new ViewContext with the given parameters. -// -// This is typically called by the Router, but can also be used in tests. -// -// Example: -// -// ctx := engine.NewViewContext(profile, theme, 120, 40, map[string]any{ -// "serviceName": "postgres", -// }) -func NewViewContext(profile *profiles.Profile, theme *themes.Theme, width, height int, args map[string]any) *ViewContext { - if args == nil { - args = make(map[string]any) - } +// WithSize returns a copy of the ViewContext with updated dimensions. +func (c ViewContext) WithSize(w, h int) ViewContext { + c.Width = w + c.Height = h + return c +} - return &ViewContext{ - Profile: profile, - Theme: theme, - Width: width, - Height: height, - Args: args, - } +// WithArgs returns a copy of the ViewContext with the provided args. +func (c ViewContext) WithArgs(args map[string]string) ViewContext { + c.Args = args + return c } diff --git a/pkg/ui/engine/context_test.go b/pkg/ui/engine/context_test.go deleted file mode 100644 index 0298c7d..0000000 --- a/pkg/ui/engine/context_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package engine - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -func TestNewViewContext(t *testing.T) { - t.Run("with all parameters", func(t *testing.T) { - profile := &profiles.Profile{Name: "enterprise"} - theme := &themes.Theme{Name: "enterprise-dark"} - args := map[string]any{ - "serviceName": "postgres", - "showConfig": true, - } - - ctx := NewViewContext(profile, theme, 120, 40, args) - - assert.Equal(t, profile, ctx.Profile) - assert.Equal(t, theme, ctx.Theme) - assert.Equal(t, 120, ctx.Width) - assert.Equal(t, 40, ctx.Height) - assert.Equal(t, args, ctx.Args) - }) - - t.Run("with nil args", func(t *testing.T) { - profile := &profiles.Profile{Name: "saiyan"} - theme := &themes.Theme{Name: "saiyan-dark"} - - ctx := NewViewContext(profile, theme, 80, 24, nil) - - assert.Equal(t, profile, ctx.Profile) - assert.Equal(t, theme, ctx.Theme) - assert.Equal(t, 80, ctx.Width) - assert.Equal(t, 24, ctx.Height) - require.NotNil(t, ctx.Args) - assert.Empty(t, ctx.Args) - }) - - t.Run("with empty args", func(t *testing.T) { - profile := &profiles.Profile{Name: "jedi"} - theme := &themes.Theme{Name: "jedi-light"} - args := make(map[string]any) - - ctx := NewViewContext(profile, theme, 160, 60, args) - - assert.Equal(t, profile, ctx.Profile) - assert.Equal(t, theme, ctx.Theme) - assert.Equal(t, 160, ctx.Width) - assert.Equal(t, 60, ctx.Height) - assert.Equal(t, args, ctx.Args) - }) - - t.Run("args are not modified externally", func(t *testing.T) { - profile := &profiles.Profile{Name: "pirate"} - theme := &themes.Theme{Name: "pirate-dark"} - args := map[string]any{ - "original": "value", - } - - ctx := NewViewContext(profile, theme, 100, 30, args) - - // Modify original args - args["new"] = "data" - - // Context args should also have the new data (same map reference) - assert.Equal(t, "data", ctx.Args["new"]) - }) -} - -func TestViewContextFields(t *testing.T) { - t.Run("standard dimensions", func(t *testing.T) { - ctx := NewViewContext(nil, nil, 120, 40, nil) - - assert.Equal(t, 120, ctx.Width, "Width should match terminal columns") - assert.Equal(t, 40, ctx.Height, "Height should match terminal rows") - }) - - t.Run("minimum dimensions", func(t *testing.T) { - ctx := NewViewContext(nil, nil, 80, 24, nil) - - assert.Equal(t, 80, ctx.Width, "Minimum width is 80 columns") - assert.Equal(t, 24, ctx.Height, "Minimum height is 24 rows") - }) - - t.Run("large dimensions", func(t *testing.T) { - ctx := NewViewContext(nil, nil, 200, 60, nil) - - assert.Equal(t, 200, ctx.Width, "Should support wide terminals") - assert.Equal(t, 60, ctx.Height, "Should support tall terminals") - }) -} diff --git a/pkg/ui/engine/factory.go b/pkg/ui/engine/factory.go deleted file mode 100644 index d2a0bbb..0000000 --- a/pkg/ui/engine/factory.go +++ /dev/null @@ -1,75 +0,0 @@ -package engine - -import ( - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -// NewRouterFromFactory creates a Router initialized with profile and theme from ComponentFactory. -// -// This is a convenience function for command implementations that already have a -// ComponentFactory instance. -// -// Example: -// -// func homeCmd(ctx *app.Context) *cobra.Command { -// return &cobra.Command{ -// Use: "home", -// Run: func(cmd *cobra.Command, args []string) { -// factory := ui.NewComponentFactory(ctx.ProfileContext()) -// router := engine.NewRouterFromFactory(factory) -// -// router.Register(views.NewHomeView(factory)) -// router.Navigate("home", nil) -// -// engine.Render(engine.RenderConfig{ -// View: router.Current(), -// Mode: engine.TUIMode, -// }) -// }, -// } -// } -func NewRouterFromFactory(factory ui.ComponentFactory) *Router { - profileCtx := factory.ProfileContext() - theme := factory.Theme() - - return NewRouter(profileCtx.Profile(), theme) -} - -// NewRouterFromContext creates a Router initialized directly from ProfileContext. -// -// This is useful when you don't have a ComponentFactory yet. -// -// Example: -// -// router := engine.NewRouterFromContext(ctx.ProfileContext()) -func NewRouterFromContext(profileCtx *profiles.ProfileContext) *Router { - // Nil-safe fallback to enterprise profile - if profileCtx == nil { - profileCtx = profiles.GetDefaultProfileContext() - } - - return NewRouter(profileCtx.Profile(), profileCtx.Theme()) -} - -// ViewWithFactory is a helper interface for views that need a ComponentFactory. -// -// This is not required but provides a standard pattern for view constructors. -// -// Example: -// -// type HomeView struct { -// factory ui.ComponentFactory -// hero *hero.Hero -// } -// -// func NewHomeView(factory ui.ComponentFactory) *HomeView { -// return &HomeView{ -// factory: factory, -// hero: hero.New(factory), -// } -// } -type ViewWithFactory interface { - View - Factory() ui.ComponentFactory -} diff --git a/pkg/ui/engine/keys.go b/pkg/ui/engine/keys.go new file mode 100644 index 0000000..53dc0fc --- /dev/null +++ b/pkg/ui/engine/keys.go @@ -0,0 +1,50 @@ +package engine + +import ( + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" +) + +// GlobalKeys defines the application-wide key bindings. +type GlobalKeys struct { + Quit key.Binding + Tab key.Binding + ShiftTab key.Binding + Help key.Binding +} + +// DefaultGlobalKeys returns the default global key map. +func DefaultGlobalKeys() GlobalKeys { + return GlobalKeys{ + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "next tab"), + ), + ShiftTab: key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("shift+tab", "prev tab"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "help"), + ), + } +} + +// GlobalKeyCmds processes a key message against global bindings. +// Returns non-nil tea.Cmd if a global binding matched. +func GlobalKeyCmds(keys GlobalKeys, msg tea.KeyMsg) tea.Cmd { + switch { + case key.Matches(msg, keys.Quit): + return tea.Quit + case key.Matches(msg, keys.Tab): + return func() tea.Msg { return NavigateMsg{ViewName: "next"} } + case key.Matches(msg, keys.ShiftTab): + return func() tea.Msg { return NavigateMsg{ViewName: "prev"} } + } + return nil +} diff --git a/pkg/ui/engine/launch.go b/pkg/ui/engine/launch.go new file mode 100644 index 0000000..34e497d --- /dev/null +++ b/pkg/ui/engine/launch.go @@ -0,0 +1,138 @@ +package engine + +import ( + "encoding/json" + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/arc-framework/arc-cli/internal/preferences" + "github.com/arc-framework/arc-cli/pkg/ui/theme" +) + +// Mode controls how the engine launches the UI. +type Mode int + +const ( + // ModeDashboard launches the full-screen multi-view dashboard. + ModeDashboard Mode = iota + + // ModeFocused launches with a single focused view (no navigation bar). + ModeFocused + + // ModeJSON renders the initial view state as JSON and exits. + // Useful for scripting and piping. + ModeJSON +) + +// Config holds all parameters needed to start the engine. +type Config struct { + // Mode selects the launch mode (dashboard, focused, JSON). + Mode Mode + + // Views is the ordered list of views to register with the router. + // At least one view is required for dashboard/focused mode. + Views []View + + // InitialView is the name of the view to display first. + // If empty, the first view in Views is used. + InitialView string + + // InitialArgs are pre-seeded ViewContext.Args passed to the first view's + // OnEnter call. Useful for focused-mode commands like `arc services info `. + InitialArgs map[string]string + + // Title and Subtitle are rendered in the header. + Title string + Subtitle string + + // Backend provides the backend services available to views. + Backend Backend + + // Prefs is the user preferences store (for profile/theme resolution). + Prefs *preferences.Preferences + + // Loader is the theme file loader. + Loader *theme.Loader +} + +// Start launches the engine with the provided configuration. +// It blocks until the user quits (TUI modes) or returns immediately (JSON mode). +func Start(cfg Config) error { + // Build the state manager. + sm, err := NewStateManager(cfg.Loader, cfg.Prefs, cfg.Backend) + if err != nil { + return fmt.Errorf("engine: failed to build state manager: %w", err) + } + + // JSON mode: render the first view's content as JSON and exit. + if cfg.Mode == ModeJSON { + return runJSON(cfg, sm) + } + + if len(cfg.Views) == 0 { + return fmt.Errorf("engine: at least one view is required") + } + + // Build router. + router := NewRouter(cfg.Views) + + // Navigate to initial view if specified. + if cfg.InitialView != "" { + idx, ok := router.byName[cfg.InitialView] + if ok { + router.current = idx + } + } + + // Focused mode: single view, no navigation bar. + if cfg.Mode == ModeFocused { + return runFocused(router, sm, cfg) + } + + // Dashboard mode: full shell with navigation. + return runDashboard(router, sm, cfg) +} + +// runDashboard runs the full-screen multi-tab shell. +func runDashboard(router *Router, sm *StateManager, cfg Config) error { + var shell *Shell + if len(cfg.InitialArgs) > 0 { + shell = NewShellWithArgs(router, sm, cfg.InitialArgs) + } else { + shell = NewShell(router, sm) + } + p := tea.NewProgram(shell, + tea.WithAltScreen(), + tea.WithMouseCellMotion(), + ) + _, err := p.Run() + return err +} + +// runFocused runs a single view without the tab navigation bar. +// It wraps the view in a minimal model that only shows header + view + controlbar. +func runFocused(router *Router, sm *StateManager, cfg Config) error { + // For Phase 1, focused mode falls back to dashboard mode with a single view. + // A dedicated focused shell can be added in Phase 2. + return runDashboard(router, sm, cfg) +} + +// runJSON renders the active view's string output as a JSON-encoded string +// and writes it to stdout. Useful for CI and scripting. +func runJSON(cfg Config, sm *StateManager) error { + if len(cfg.Views) == 0 { + return fmt.Errorf("engine: at least one view is required for JSON mode") + } + + v := cfg.Views[0] + ctx := sm.BuildViewContext(120, 40) // sensible default for non-interactive + _ = v.Init() + _ = v.OnEnter(ctx) + content := v.View() + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(map[string]string{"output": content}) +} diff --git a/pkg/ui/engine/messages.go b/pkg/ui/engine/messages.go new file mode 100644 index 0000000..0e5e8b8 --- /dev/null +++ b/pkg/ui/engine/messages.go @@ -0,0 +1,39 @@ +package engine + +import "time" + +// ErrorMsg is dispatched when any error occurs during view execution. +type ErrorMsg struct { + Title string + Err error + Severity string // "info" | "warning" | "error" | "fatal" + Timestamp time.Time + // Dismissible indicates whether the user can press 'd' to dismiss. + Dismissible bool +} + +// StateChangedMsg is dispatched when profile, theme, or skin changes. +// The Shell handles this by rebuilding the ViewContext and re-rendering. +type StateChangedMsg struct { + ProfileID string + ThemeID string + SkinID string +} + +// NavigateMsg instructs the Router to navigate to a named view. +type NavigateMsg struct { + ViewName string + Args map[string]string +} + +// BackMsg instructs the Router to return to the previous view. +type BackMsg struct{} + +// ResizeMsg is dispatched when the terminal window is resized. +type ResizeMsg struct { + Width int + Height int +} + +// DismissErrorMsg clears the current error display. +type DismissErrorMsg struct{} diff --git a/pkg/ui/engine/render.go b/pkg/ui/engine/render.go deleted file mode 100644 index 1ecc4c3..0000000 --- a/pkg/ui/engine/render.go +++ /dev/null @@ -1,239 +0,0 @@ -package engine - -import ( - "encoding/json" - "fmt" - "os" - - tea "github.com/charmbracelet/bubbletea" -) - -// RenderMode defines the output format for the Render function. -type RenderMode int - -const ( - // TUIMode renders a full interactive Bubble Tea program. - // This is the default mode for interactive terminal usage. - TUIMode RenderMode = iota - - // JSONMode outputs structured JSON data instead of rendering TUI. - // Used with the --json flag for programmatic consumption. - JSONMode - - // StaticMode outputs static text without TUI interactivity. - // Used with the --no-animation flag for simple output. - StaticMode -) - -// RenderConfig contains configuration for the Render function. -// -// Different modes require different configuration: -// - TUIMode: Only View is required -// - JSONMode: View and JSONData are required -// - StaticMode: View and StaticData are required -type RenderConfig struct { - // View is the view to render (required for all modes) - View View - - // Mode determines the output format (default: TUIMode) - Mode RenderMode - - // JSONData is the data to output as JSON (required for JSONMode) - // This should be a struct or map that can be marshaled to JSON. - // - // Example: - // JSONData: map[string]any{ - // "services": []Service{...}, - // "total": 5, - // } - JSONData any - - // StaticData is the text to output directly (required for StaticMode) - // This is typically a pre-rendered string without ANSI codes. - // - // Example: - // StaticData: "Service: postgres\nStatus: running\nPort: 5432" - StaticData string - - // JSONIndent enables pretty-printing for JSON output (default: false) - // When true, JSON is formatted with 2-space indentation. - JSONIndent bool -} - -// Render executes the view rendering based on the configured mode. -// -// This is the unified rendering function for all CLI commands. It supports -// three output modes: -// -// 1. TUI Mode (default): Full interactive Bubble Tea program -// 2. JSON Mode (--json flag): Structured data output -// 3. Static Mode (--no-animation flag): Simple text output -// -// Example Usage: -// -// // TUI mode (interactive) -// err := engine.Render(engine.RenderConfig{ -// View: homeView, -// Mode: engine.TUIMode, -// }) -// -// // JSON mode (--json flag) -// err := engine.Render(engine.RenderConfig{ -// View: servicesView, -// Mode: engine.JSONMode, -// JSONData: map[string]any{ -// "services": services, -// "count": len(services), -// }, -// JSONIndent: true, -// }) -// -// // Static mode (--no-animation flag) -// err := engine.Render(engine.RenderConfig{ -// View: versionView, -// Mode: engine.StaticMode, -// StaticData: "ARC CLI v1.0.0\nGo version: 1.24.2", -// }) -func Render(config RenderConfig) error { - if config.View == nil { - return fmt.Errorf("view is required") - } - - switch config.Mode { - case TUIMode: - return renderTUI(config.View) - - case JSONMode: - if config.JSONData == nil { - return fmt.Errorf("JSONData is required for JSONMode") - } - return renderJSON(config.JSONData, config.JSONIndent) - - case StaticMode: - if config.StaticData == "" { - return fmt.Errorf("StaticData is required for StaticMode") - } - return renderStatic(config.StaticData) - - default: - return fmt.Errorf("unsupported render mode: %d", config.Mode) - } -} - -// renderTUI starts a full interactive Bubble Tea program. -func renderTUI(view View) error { - p := tea.NewProgram( - view, - tea.WithAltScreen(), // Use alternate screen buffer - tea.WithMouseCellMotion(), // Enable mouse support - ) - - // Run the program - _, err := p.Run() - return err -} - -// renderJSON outputs data as JSON to stdout. -func renderJSON(data any, indent bool) error { - var output []byte - var err error - - if indent { - output, err = json.MarshalIndent(data, "", " ") - } else { - output, err = json.Marshal(data) - } - - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - // Write to stdout - fmt.Println(string(output)) - return nil -} - -// renderStatic outputs static text to stdout. -func renderStatic(data string) error { - fmt.Println(data) - return nil -} - -// RenderModeFromFlags determines the appropriate RenderMode based on CLI flags. -// -// This is a helper function for command implementations to map flags to modes. -// -// Example: -// -// func homeCmd(ctx *app.Context) *cobra.Command { -// var jsonFlag bool -// var noAnimation bool -// -// cmd := &cobra.Command{ -// Use: "home", -// Run: func(cmd *cobra.Command, args []string) { -// mode := engine.RenderModeFromFlags(jsonFlag, noAnimation) -// -// config := engine.RenderConfig{ -// View: homeView, -// Mode: mode, -// } -// -// if mode == engine.JSONMode { -// config.JSONData = homeData -// config.JSONIndent = true -// } else if mode == engine.StaticMode { -// config.StaticData = homeStaticOutput -// } -// -// engine.Render(config) -// }, -// } -// -// cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON") -// cmd.Flags().BoolVar(&noAnimation, "no-animation", false, "Static output") -// return cmd -// } -func RenderModeFromFlags(jsonFlag, noAnimation bool) RenderMode { - if jsonFlag { - return JSONMode - } - if noAnimation { - return StaticMode - } - return TUIMode -} - -// IsInteractive returns true if the render mode is interactive (TUI). -// -// This can be used to conditionally enable features that only work in TUI mode. -// -// Example: -// -// if engine.IsInteractive(mode) { -// // Enable keyboard shortcuts -// // Show animated spinners -// // Enable mouse support -// } -func IsInteractive(mode RenderMode) bool { - return mode == TUIMode -} - -// CheckTTY returns true if stdout is connected to a terminal. -// -// This can be used to auto-detect whether to use TUI mode or fall back to -// static output. -// -// Example: -// -// mode := engine.TUIMode -// if !engine.CheckTTY() { -// mode = engine.StaticMode // Fall back to static if piped -// } -func CheckTTY() bool { - fileInfo, err := os.Stdout.Stat() - if err != nil { - return false - } - return (fileInfo.Mode() & os.ModeCharDevice) != 0 -} diff --git a/pkg/ui/engine/render_test.go b/pkg/ui/engine/render_test.go deleted file mode 100644 index ca4f6b3..0000000 --- a/pkg/ui/engine/render_test.go +++ /dev/null @@ -1,396 +0,0 @@ -package engine - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRenderConfig(t *testing.T) { - view := newMockView("test") - - t.Run("TUI mode config", func(t *testing.T) { - config := RenderConfig{ - View: view, - Mode: TUIMode, - } - - assert.Equal(t, view, config.View) - assert.Equal(t, TUIMode, config.Mode) - assert.Nil(t, config.JSONData) - assert.Empty(t, config.StaticData) - }) - - t.Run("JSON mode config", func(t *testing.T) { - data := map[string]any{ - "services": []string{"postgres", "redis"}, - "count": 2, - } - - config := RenderConfig{ - View: view, - Mode: JSONMode, - JSONData: data, - JSONIndent: true, - } - - assert.Equal(t, view, config.View) - assert.Equal(t, JSONMode, config.Mode) - assert.Equal(t, data, config.JSONData) - assert.True(t, config.JSONIndent) - }) - - t.Run("Static mode config", func(t *testing.T) { - staticOutput := "Service: postgres\nStatus: running" - - config := RenderConfig{ - View: view, - Mode: StaticMode, - StaticData: staticOutput, - } - - assert.Equal(t, view, config.View) - assert.Equal(t, StaticMode, config.Mode) - assert.Equal(t, staticOutput, config.StaticData) - }) -} - -func TestRenderModeFromFlags(t *testing.T) { - tests := []struct { - name string - jsonFlag bool - noAnimation bool - expected RenderMode - }{ - { - name: "default (no flags)", - jsonFlag: false, - noAnimation: false, - expected: TUIMode, - }, - { - name: "json flag set", - jsonFlag: true, - noAnimation: false, - expected: JSONMode, - }, - { - name: "no-animation flag set", - jsonFlag: false, - noAnimation: true, - expected: StaticMode, - }, - { - name: "both flags (json takes precedence)", - jsonFlag: true, - noAnimation: true, - expected: JSONMode, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mode := RenderModeFromFlags(tt.jsonFlag, tt.noAnimation) - assert.Equal(t, tt.expected, mode) - }) - } -} - -func TestIsInteractive(t *testing.T) { - tests := []struct { - name string - mode RenderMode - expected bool - }{ - { - name: "TUI mode is interactive", - mode: TUIMode, - expected: true, - }, - { - name: "JSON mode is not interactive", - mode: JSONMode, - expected: false, - }, - { - name: "Static mode is not interactive", - mode: StaticMode, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := IsInteractive(tt.mode) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestRenderJSON(t *testing.T) { - t.Run("simple data", func(t *testing.T) { - data := map[string]any{ - "name": "postgres", - "status": "running", - "port": 5432, - } - - err := renderJSON(data, false) - require.NoError(t, err) - }) - - t.Run("with indentation", func(t *testing.T) { - data := map[string]string{ - "key": "value", - } - - err := renderJSON(data, true) - require.NoError(t, err) - }) - - t.Run("array data", func(t *testing.T) { - data := []string{"service1", "service2", "service3"} - - err := renderJSON(data, false) - require.NoError(t, err) - }) - - t.Run("nested data", func(t *testing.T) { - data := map[string]any{ - "services": []map[string]any{ - {"name": "postgres", "port": 5432}, - {"name": "redis", "port": 6379}, - }, - "total": 2, - } - - err := renderJSON(data, true) - require.NoError(t, err) - }) - - t.Run("marshal error with invalid data", func(t *testing.T) { - // Channels cannot be marshaled to JSON - data := make(chan int) - - err := renderJSON(data, false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to marshal JSON") - }) -} - -func TestRenderJSONValidOutput(t *testing.T) { - t.Run("output is valid JSON", func(t *testing.T) { - data := map[string]any{ - "name": "test", - "value": 123, - } - - // Manually marshal to verify format - output, err := json.Marshal(data) - require.NoError(t, err) - - // Verify it can be unmarshaled back - var result map[string]any - err = json.Unmarshal(output, &result) - require.NoError(t, err) - assert.Equal(t, "test", result["name"]) - assert.Equal(t, float64(123), result["value"]) // JSON numbers unmarshal as float64 - }) - - t.Run("indented output is valid JSON", func(t *testing.T) { - data := map[string]string{ - "key": "value", - } - - output, err := json.MarshalIndent(data, "", " ") - require.NoError(t, err) - - // Verify indented JSON can be unmarshaled - var result map[string]string - err = json.Unmarshal(output, &result) - require.NoError(t, err) - assert.Equal(t, "value", result["key"]) - }) -} - -func TestRenderStatic(t *testing.T) { - t.Run("simple text", func(t *testing.T) { - text := "Hello, World!" - - err := renderStatic(text) - assert.NoError(t, err) - }) - - t.Run("multi-line text", func(t *testing.T) { - text := "Line 1\nLine 2\nLine 3" - - err := renderStatic(text) - assert.NoError(t, err) - }) - - t.Run("empty text", func(t *testing.T) { - err := renderStatic("") - assert.NoError(t, err) - }) -} - -func TestRenderValidation(t *testing.T) { - t.Run("missing view", func(t *testing.T) { - config := RenderConfig{ - Mode: TUIMode, - } - - err := Render(config) - assert.Error(t, err) - assert.Contains(t, err.Error(), "view is required") - }) - - t.Run("JSON mode without data", func(t *testing.T) { - view := newMockView("test") - config := RenderConfig{ - View: view, - Mode: JSONMode, - } - - err := Render(config) - assert.Error(t, err) - assert.Contains(t, err.Error(), "JSONData is required") - }) - - t.Run("Static mode without data", func(t *testing.T) { - view := newMockView("test") - config := RenderConfig{ - View: view, - Mode: StaticMode, - } - - err := Render(config) - assert.Error(t, err) - assert.Contains(t, err.Error(), "StaticData is required") - }) - - t.Run("unsupported mode", func(t *testing.T) { - view := newMockView("test") - config := RenderConfig{ - View: view, - Mode: RenderMode(999), // Invalid mode - } - - err := Render(config) - assert.Error(t, err) - assert.Contains(t, err.Error(), "unsupported render mode") - }) -} - -// TestRenderStatic_AllViews tests that renderStatic works with view-generated content. (T244) -func TestRenderStatic_AllViews(t *testing.T) { - viewOutputs := []struct { - name string - staticData string - }{ - {"home", "Home View\nProfile: Enterprise\nPress d to open dashboard"}, - {"info", "System Info\nCLI: 1.0.0\nOS: darwin"}, - {"services", "Services\n- postgres\n- redis"}, - {"dashboard", "Dashboard\nStatus: Running"}, - } - - for _, tt := range viewOutputs { - t.Run(tt.name, func(t *testing.T) { - view := newMockView(tt.name) - config := RenderConfig{ - View: view, - Mode: StaticMode, - StaticData: tt.staticData, - } - - err := Render(config) - require.NoError(t, err, "renderStatic should succeed with view output for %s", tt.name) - }) - } -} - -// TestRenderModeFromFlags_NoAnimation verifies that --no-animation maps to StaticMode. (T245) -func TestRenderModeFromFlags_NoAnimation(t *testing.T) { - t.Run("no-animation=true selects StaticMode", func(t *testing.T) { - mode := RenderModeFromFlags(false, true) - assert.Equal(t, StaticMode, mode) - assert.False(t, IsInteractive(mode), "StaticMode should not be interactive") - }) - - t.Run("no-animation=false with no json selects TUIMode", func(t *testing.T) { - mode := RenderModeFromFlags(false, false) - assert.Equal(t, TUIMode, mode) - assert.True(t, IsInteractive(mode), "TUIMode should be interactive") - }) - - t.Run("no-animation=true with json selects JSONMode (json takes precedence)", func(t *testing.T) { - mode := RenderModeFromFlags(true, true) - assert.Equal(t, JSONMode, mode) - assert.False(t, IsInteractive(mode), "JSONMode should not be interactive") - }) -} - -// TestRenderMode_CIEnvironment verifies behavior in CI (non-interactive) environments. (T246) -func TestRenderMode_CIEnvironment(t *testing.T) { - t.Run("CI env does not affect RenderModeFromFlags", func(t *testing.T) { - // CI env var is handled at a higher level; RenderModeFromFlags is flag-driven - t.Setenv("CI", "1") - - // No flags: still TUIMode from flag perspective - // (actual CI check happens at command routing level, not here) - mode := RenderModeFromFlags(false, false) - assert.Equal(t, TUIMode, mode) - }) - - t.Run("no-animation flag in CI context selects StaticMode", func(t *testing.T) { - t.Setenv("CI", "1") - // When running in CI, --no-animation is typically passed - mode := RenderModeFromFlags(false, true) - assert.Equal(t, StaticMode, mode) - }) - - t.Run("StaticMode renders in CI without error", func(t *testing.T) { - t.Setenv("CI", "1") - view := newMockView("ci-test") - config := RenderConfig{ - View: view, - Mode: StaticMode, - StaticData: "CI output: all systems nominal", - } - - err := Render(config) - require.NoError(t, err, "Should render in CI environment without error") - }) -} - -// TestCheckTTY_PipedOutput tests that CheckTTY returns false when not in a terminal. (T247) -func TestCheckTTY_PipedOutput(t *testing.T) { - // CheckTTY checks os.Stdout; in test environments stdout is not a TTY - // This test verifies the function runs without panic and returns a bool - result := CheckTTY() - - // In test environment (non-TTY), should return false - // In a TTY environment, could return true — we just ensure no panic - assert.IsType(t, bool(false), result, "CheckTTY should return a bool") - // Note: test runners redirect stdout, so this is typically false in CI - assert.False(t, result, "CheckTTY should return false in test environment (piped output)") -} - -// TestRenderConfig_StaticDataWithView tests static rendering with view-generated content. (T244) -func TestRenderConfig_StaticDataWithView(t *testing.T) { - view := newMockView("home") - - // Simulate what a command would do: generate static text from view - viewOutput := "Home\nProfile: Enterprise" - - config := RenderConfig{ - View: view, - Mode: StaticMode, - StaticData: viewOutput, - } - - err := Render(config) - require.NoError(t, err) -} diff --git a/pkg/ui/engine/router.go b/pkg/ui/engine/router.go index 24a5d2f..cec47cb 100644 --- a/pkg/ui/engine/router.go +++ b/pkg/ui/engine/router.go @@ -1,244 +1,142 @@ package engine import ( - "fmt" - - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" + tea "github.com/charmbracelet/bubbletea" ) -// MaxHistoryDepth is the maximum number of views kept in navigation history. -// This prevents unbounded memory growth during extended CLI sessions. -const MaxHistoryDepth = 10 - -// Router manages navigation between views with history tracking. -// -// The router maintains a registry of named views and handles navigation -// between them, calling lifecycle hooks (OnEnter/OnExit) as views are -// activated and deactivated. -// -// Example Usage: -// -// // Create router -// router := engine.NewRouter(profile, theme) -// -// // Register views -// router.Register(views.NewHomeView(factory)) -// router.Register(views.NewServicesView(factory)) -// router.Register(views.NewServiceDetailView(factory)) -// -// // Navigate to initial view -// router.Navigate("home", nil) -// -// // Navigate with parameters -// router.Navigate("service-detail", map[string]any{ -// "serviceName": "postgres", -// }) -// -// // Back navigation -// router.Back() -// -// // Get current view for rendering -// currentView := router.Current() +// Router manages the collection of views and navigation between them. +// It guarantees the OnExit/OnEnter lifecycle: the outgoing view's OnExit +// is always called before the incoming view's OnEnter. type Router struct { - // views is the registry of all available views by name - views map[string]View - - // current is the currently active view - current View - - // history is the stack of previously visited view names (max 10 entries) - history []string - - // profile is the active user profile for context creation - profile *profiles.Profile - - // theme is the active theme for context creation - theme *themes.Theme - - // width is the terminal width for context creation - width int - - // height is the terminal height for context creation - height int + views []View + byName map[string]int + current int + history []int } -// NewRouter creates a new Router with the given profile and theme. -// -// The router is initialized with no registered views. Use Register() to -// add views before navigating. -// -// Terminal dimensions default to 120x40 but can be updated with SetDimensions(). -func NewRouter(profile *profiles.Profile, theme *themes.Theme) *Router { - return &Router{ - views: make(map[string]View), - history: make([]string, 0, MaxHistoryDepth), - profile: profile, - theme: theme, - width: 120, // Default terminal width - height: 40, // Default terminal height +// NewRouter creates a Router with an ordered set of views. +// The first view in the list becomes the initial active view. +// If views is empty, Current() returns nil. +func NewRouter(views []View) *Router { + r := &Router{ + views: views, + byName: make(map[string]int, len(views)), + current: 0, + history: []int{}, + } + for i, v := range views { + r.byName[v.Name()] = i } + return r } -// Register adds a view to the router's registry. -// -// The view's Name() method is used as the registration key. -// If a view with the same name already exists, it is replaced. -// -// Example: -// -// router.Register(views.NewHomeView(factory)) -// router.Register(views.NewServicesView(factory)) -func (r *Router) Register(view View) { - r.views[view.Name()] = view +// Current returns the currently active view, or nil if there are no views. +func (r *Router) Current() View { + if len(r.views) == 0 { + return nil + } + return r.views[r.current] } -// Navigate switches to the specified view by name. -// -// Navigation process: -// 1. If a current view exists, call its OnExit() method -// 2. Add current view name to history stack (max 10 entries) -// 3. Look up new view by name -// 4. Create ViewContext with profile, theme, dimensions, and args -// 5. Call new view's OnEnter(ctx) method -// 6. Set new view as current -// -// Args are optional route parameters passed to the new view's OnEnter method. -// -// Returns an error if the view name is not registered. -// -// Example: -// -// // Navigate without parameters -// err := router.Navigate("home", nil) -// -// // Navigate with parameters -// err := router.Navigate("service-detail", map[string]any{ -// "serviceName": "postgres", -// "showConfig": true, -// }) -func (r *Router) Navigate(name string, args map[string]any) error { - // Look up target view - targetView, exists := r.views[name] - if !exists { - return fmt.Errorf("view not found: %s", name) - } +// CurrentIndex returns the current view index (for navigation highlighting). +func (r *Router) CurrentIndex() int { + return r.current +} - // Call OnExit on current view if exists - if r.current != nil { - _ = r.current.OnExit() +// Views returns all registered views in order (used to render tab bar). +func (r *Router) Views() []View { + return r.views +} - // Add current view to history (limit to MaxHistoryDepth) - r.history = append(r.history, r.current.Name()) - if len(r.history) > MaxHistoryDepth { - r.history = r.history[1:] // Remove oldest entry - } +// Navigate transitions to the view with the given name. +// It calls OnExit on the current view, pushes the current index onto history, +// then calls OnEnter on the target view with the provided context. +// Returns a batch of the exit/enter commands. +// If the named view is not found, or is already current, it is a no-op. +func (r *Router) Navigate(name string, ctx ViewContext) tea.Cmd { + idx, ok := r.byName[name] + if !ok || idx == r.current { + return nil } - - // Create context for new view - ctx := NewViewContext(r.profile, r.theme, r.width, r.height, args) - - // Call OnEnter on target view - _ = targetView.OnEnter(ctx) - - // Set as current view - r.current = targetView - - return nil + return r.transitionTo(idx, ctx) } -// Back navigates to the previous view in the history stack. -// -// If history is empty (no previous views), this is a no-op. -// -// Back navigation does NOT add to history - it removes entries. -// -// Example: -// -// router.Navigate("home", nil) // History: [] -// router.Navigate("services", nil) // History: ["home"] -// router.Navigate("service-detail", map[string]any{"serviceName": "postgres"}) // History: ["home", "services"] -// router.Back() // Back to services, History: ["home"] -// router.Back() // Back to home, History: [] -// router.Back() // No-op, History: [] -func (r *Router) Back() error { - // Check if history is empty +// Back navigates to the previously displayed view. +// If there is no history it is a no-op. +func (r *Router) Back(ctx ViewContext) tea.Cmd { if len(r.history) == 0 { - return nil // No-op if no history + return nil } + prev := r.history[len(r.history)-1] + r.history = r.history[:len(r.history)-1] - // Get previous view name - prevName := r.history[len(r.history)-1] - r.history = r.history[:len(r.history)-1] // Remove from history + // transitionTo would push to history — call the inner swap directly. + return r.swap(prev, ctx) +} - // Look up previous view - prevView, exists := r.views[prevName] - if !exists { - return fmt.Errorf("previous view not found in registry: %s", prevName) +// NextTab advances to the next visible (non-hidden) view in order (wraps). +func (r *Router) NextTab(ctx ViewContext) tea.Cmd { + if len(r.views) <= 1 { + return nil } - - // Call OnExit on current view - if r.current != nil { - _ = r.current.OnExit() + next := (r.current + 1) % len(r.views) + for i := 0; i < len(r.views); i++ { + if nh, ok := r.views[next].(NavHideable); ok && nh.NavHidden() { + next = (next + 1) % len(r.views) + } else { + break + } + } + if next == r.current { + return nil } + return r.transitionTo(next, ctx) +} - // Create context for previous view (no args for back navigation) - ctx := NewViewContext(r.profile, r.theme, r.width, r.height, nil) +// PrevTab moves to the previous visible (non-hidden) view in order (wraps). +func (r *Router) PrevTab(ctx ViewContext) tea.Cmd { + if len(r.views) <= 1 { + return nil + } + prev := (r.current - 1 + len(r.views)) % len(r.views) + for i := 0; i < len(r.views); i++ { + if nh, ok := r.views[prev].(NavHideable); ok && nh.NavHidden() { + prev = (prev - 1 + len(r.views)) % len(r.views) + } else { + break + } + } + if prev == r.current { + return nil + } + return r.transitionTo(prev, ctx) +} - // Call OnEnter on previous view - _ = prevView.OnEnter(ctx) +// transitionTo records history and delegates to swap. +func (r *Router) transitionTo(idx int, ctx ViewContext) tea.Cmd { + r.history = append(r.history, r.current) + return r.swap(idx, ctx) +} - // Set as current view - r.current = prevView +// swap performs the actual view change: exit old, enter new. +func (r *Router) swap(idx int, ctx ViewContext) tea.Cmd { + var cmds []tea.Cmd - return nil -} + // Exit current view + if r.Current() != nil { + if cmd := r.Current().OnExit(); cmd != nil { + cmds = append(cmds, cmd) + } + } -// Current returns the currently active view. -// -// Returns nil if no view has been navigated to yet. -// -// Example: -// -// currentView := router.Current() -// if currentView != nil { -// output := currentView.View() -// fmt.Println(output) -// } -func (r *Router) Current() View { - return r.current -} + r.current = idx -// SetDimensions updates the terminal dimensions used for ViewContext creation. -// -// This should be called when terminal resize events are detected. -// -// Example: -// -// // On terminal resize -// router.SetDimensions(newWidth, newHeight) -// -// // Re-enter current view with new dimensions -// if router.Current() != nil { -// ctx := engine.NewViewContext(profile, theme, newWidth, newHeight, nil) -// router.Current().OnEnter(ctx) -// } -func (r *Router) SetDimensions(width, height int) { - r.width = width - r.height = height -} + // Enter new view + if r.Current() != nil { + if cmd := r.Current().OnEnter(ctx); cmd != nil { + cmds = append(cmds, cmd) + } + } -// History returns a copy of the navigation history stack. -// -// The returned slice contains view names in chronological order (oldest first). -// -// Example: -// -// history := router.History() -// fmt.Println("Visited views:", history) // ["home", "services", "service-detail"] -func (r *Router) History() []string { - // Return a copy to prevent external modification - historyCopy := make([]string, len(r.history)) - copy(historyCopy, r.history) - return historyCopy + return tea.Batch(cmds...) } diff --git a/pkg/ui/engine/router_test.go b/pkg/ui/engine/router_test.go deleted file mode 100644 index a3c76a7..0000000 --- a/pkg/ui/engine/router_test.go +++ /dev/null @@ -1,320 +0,0 @@ -package engine - -import ( - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// mockView implements the View interface for testing -type mockView struct { - name string - entered bool - exited bool - enterCtx *ViewContext - initCalled bool - updateCalled bool - viewCalled bool -} - -func newMockView(name string) *mockView { - return &mockView{name: name} -} - -func (m *mockView) Init() tea.Cmd { - m.initCalled = true - return nil -} - -func (m *mockView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - m.updateCalled = true - return m, nil -} - -func (m *mockView) View() string { - m.viewCalled = true - return "mock view: " + m.name -} - -func (m *mockView) OnEnter(ctx *ViewContext) tea.Cmd { - m.entered = true - m.enterCtx = ctx - return nil -} - -func (m *mockView) OnExit() tea.Cmd { - m.exited = true - return nil -} - -func (m *mockView) Name() string { - return m.name -} - -func (m *mockView) Keybindings() []KeyBinding { - return []KeyBinding{ - {Key: "q", Description: "Quit"}, - } -} - -func TestNewRouter(t *testing.T) { - profile := &profiles.Profile{Name: "enterprise"} - theme := &themes.Theme{Name: "enterprise-dark"} - - router := NewRouter(profile, theme) - - assert.NotNil(t, router) - assert.NotNil(t, router.views) - assert.Empty(t, router.views) - assert.NotNil(t, router.history) - assert.Empty(t, router.history) - assert.Nil(t, router.current) - assert.Equal(t, profile, router.profile) - assert.Equal(t, theme, router.theme) - assert.Equal(t, 120, router.width, "Default width") - assert.Equal(t, 40, router.height, "Default height") -} - -func TestRouterRegister(t *testing.T) { - router := NewRouter(nil, nil) - - homeView := newMockView("home") - servicesView := newMockView("services") - - router.Register(homeView) - router.Register(servicesView) - - assert.Len(t, router.views, 2) - assert.Equal(t, homeView, router.views["home"]) - assert.Equal(t, servicesView, router.views["services"]) -} - -func TestRouterRegisterOverwrite(t *testing.T) { - router := NewRouter(nil, nil) - - view1 := newMockView("test") - view2 := newMockView("test") - - router.Register(view1) - router.Register(view2) - - assert.Len(t, router.views, 1, "Should have only one view with name 'test'") - assert.Equal(t, view2, router.views["test"], "Second registration should replace first") -} - -func TestRouterNavigate(t *testing.T) { - t.Run("first navigation", func(t *testing.T) { - router := NewRouter(nil, nil) - homeView := newMockView("home") - router.Register(homeView) - - err := router.Navigate("home", nil) - - require.NoError(t, err) - assert.True(t, homeView.entered, "OnEnter should be called") - assert.False(t, homeView.exited, "OnExit should not be called") - assert.Equal(t, homeView, router.Current()) - assert.Empty(t, router.History(), "First navigation should not add to history") - }) - - t.Run("navigation with args", func(t *testing.T) { - router := NewRouter(nil, nil) - view := newMockView("detail") - router.Register(view) - - args := map[string]any{ - "serviceName": "postgres", - "showConfig": true, - } - - err := router.Navigate("detail", args) - - require.NoError(t, err) - require.NotNil(t, view.enterCtx) - assert.Equal(t, args, view.enterCtx.Args) - assert.Equal(t, "postgres", view.enterCtx.Args["serviceName"]) - assert.Equal(t, true, view.enterCtx.Args["showConfig"]) - }) - - t.Run("view not found", func(t *testing.T) { - router := NewRouter(nil, nil) - - err := router.Navigate("nonexistent", nil) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "view not found: nonexistent") - }) - - t.Run("navigation between views", func(t *testing.T) { - router := NewRouter(nil, nil) - homeView := newMockView("home") - servicesView := newMockView("services") - router.Register(homeView) - router.Register(servicesView) - - // Navigate to home - err := router.Navigate("home", nil) - require.NoError(t, err) - - // Navigate to services - err = router.Navigate("services", nil) - require.NoError(t, err) - - assert.True(t, homeView.exited, "Home view OnExit should be called") - assert.True(t, servicesView.entered, "Services view OnEnter should be called") - assert.Equal(t, servicesView, router.Current()) - assert.Equal(t, []string{"home"}, router.History()) - }) -} - -func TestRouterBack(t *testing.T) { - t.Run("back with history", func(t *testing.T) { - router := NewRouter(nil, nil) - homeView := newMockView("home") - servicesView := newMockView("services") - router.Register(homeView) - router.Register(servicesView) - - // Navigate forward - _ = router.Navigate("home", nil) - _ = router.Navigate("services", nil) - - // Reset flags - homeView.entered = false - homeView.exited = false - servicesView.exited = false - - // Navigate back - err := router.Back() - - require.NoError(t, err) - assert.True(t, servicesView.exited, "Services view OnExit should be called") - assert.True(t, homeView.entered, "Home view OnEnter should be called") - assert.Equal(t, homeView, router.Current()) - assert.Empty(t, router.History(), "History should be empty after going back once") - }) - - t.Run("back with empty history", func(t *testing.T) { - router := NewRouter(nil, nil) - homeView := newMockView("home") - router.Register(homeView) - _ = router.Navigate("home", nil) - - err := router.Back() - - require.NoError(t, err, "Back with no history should be no-op") - assert.Equal(t, homeView, router.Current(), "Current view should not change") - }) - - t.Run("multiple back navigations", func(t *testing.T) { - router := NewRouter(nil, nil) - view1 := newMockView("view1") - view2 := newMockView("view2") - view3 := newMockView("view3") - router.Register(view1) - router.Register(view2) - router.Register(view3) - - // Navigate: view1 → view2 → view3 - _ = router.Navigate("view1", nil) - _ = router.Navigate("view2", nil) - _ = router.Navigate("view3", nil) - - assert.Equal(t, []string{"view1", "view2"}, router.History()) - - // Back: view3 → view2 - _ = router.Back() - assert.Equal(t, view2, router.Current()) - assert.Equal(t, []string{"view1"}, router.History()) - - // Back: view2 → view1 - _ = router.Back() - assert.Equal(t, view1, router.Current()) - assert.Empty(t, router.History()) - - // Back: view1 (no-op) - _ = router.Back() - assert.Equal(t, view1, router.Current()) - assert.Empty(t, router.History()) - }) -} - -func TestRouterHistoryLimit(t *testing.T) { - router := NewRouter(nil, nil) - - // Register MaxHistoryDepth + 2 views - for i := 0; i <= MaxHistoryDepth+1; i++ { - view := newMockView(string(rune('a' + i))) - router.Register(view) - } - - // Navigate through all views - for i := 0; i <= MaxHistoryDepth+1; i++ { - _ = router.Navigate(string(rune('a'+i)), nil) - } - - // History should be limited to MaxHistoryDepth - history := router.History() - assert.Len(t, history, MaxHistoryDepth, "History should be limited to MaxHistoryDepth") - - // First entry should be dropped (oldest entry removed) - assert.NotContains(t, history, "a", "Oldest entry should be dropped") -} - -func TestRouterSetDimensions(t *testing.T) { - router := NewRouter(nil, nil) - - router.SetDimensions(160, 60) - - assert.Equal(t, 160, router.width) - assert.Equal(t, 60, router.height) - - // Subsequent navigations should use new dimensions - view := newMockView("test") - router.Register(view) - _ = router.Navigate("test", nil) - - assert.Equal(t, 160, view.enterCtx.Width) - assert.Equal(t, 60, view.enterCtx.Height) -} - -func TestRouterCurrent(t *testing.T) { - t.Run("no current view", func(t *testing.T) { - router := NewRouter(nil, nil) - - assert.Nil(t, router.Current()) - }) - - t.Run("with current view", func(t *testing.T) { - router := NewRouter(nil, nil) - view := newMockView("test") - router.Register(view) - _ = router.Navigate("test", nil) - - assert.Equal(t, view, router.Current()) - }) -} - -func TestRouterHistory(t *testing.T) { - t.Run("returns copy of history", func(t *testing.T) { - router := NewRouter(nil, nil) - view1 := newMockView("view1") - view2 := newMockView("view2") - router.Register(view1) - router.Register(view2) - - _ = router.Navigate("view1", nil) - _ = router.Navigate("view2", nil) - - history := router.History() - history[0] = "modified" - - // Original history should not be modified - assert.Equal(t, []string{"view1"}, router.History()) - }) -} diff --git a/pkg/ui/engine/shell.go b/pkg/ui/engine/shell.go new file mode 100644 index 0000000..5caa040 --- /dev/null +++ b/pkg/ui/engine/shell.go @@ -0,0 +1,244 @@ +package engine + +import ( + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/arc-framework/arc-cli/pkg/ui/component" +) + +// Shell is the top-level BubbleTea model. +// It owns the Router and StateManager and composes the full-screen layout: +// +// Header | Navigation | | ControlBar +// +// Shell handles global messages (resize, quit, tab) and delegates all +// view-specific messages to the active view via the Router. +type Shell struct { + router *Router + state *StateManager + keys GlobalKeys + width int + height int + initialArgs map[string]string + + // activeError holds the most recent ErrorMsg, if any. + activeError *ErrorMsg +} + +// NewShell creates a Shell with the given router and state manager. +func NewShell(router *Router, state *StateManager) *Shell { + return &Shell{ + router: router, + state: state, + keys: DefaultGlobalKeys(), + } +} + +// NewShellWithArgs creates a Shell that passes initialArgs to the first view's +// OnEnter call. Used by focused-mode commands that pre-seed view arguments. +func NewShellWithArgs(router *Router, state *StateManager, initialArgs map[string]string) *Shell { + return &Shell{ + router: router, + state: state, + keys: DefaultGlobalKeys(), + initialArgs: initialArgs, + } +} + +// Init initializes the active view. +func (s *Shell) Init() tea.Cmd { + ctx := s.buildCtx() + if len(s.initialArgs) > 0 { + ctx = ctx.WithArgs(s.initialArgs) + } + var cmds []tea.Cmd + + if v := s.router.Current(); v != nil { + cmds = append(cmds, v.Init(), v.OnEnter(ctx)) + } + return tea.Batch(cmds...) +} + +// Update handles incoming messages. +func (s *Shell) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo,cyclop // bubbletea Update must handle all message types in a single switch + switch msg := msg.(type) { + // ─── Terminal resize ─────────────────────────────────────────────────── + case tea.WindowSizeMsg: + s.width = msg.Width + s.height = msg.Height + // Propagate resize so the active view can re-flow its layout. + ctx := s.buildCtx() + if v := s.router.Current(); v != nil { + updated, cmd := v.Update(ResizeMsg{Width: msg.Width, Height: msg.Height}) + s.router.views[s.router.current] = updated + if enterCmd := updated.OnEnter(ctx); enterCmd != nil { + return s, tea.Batch(cmd, enterCmd) + } + return s, cmd + } + return s, nil + + // ─── Navigation messages ─────────────────────────────────────────────── + case NavigateMsg: + var cmd tea.Cmd + ctx := s.buildCtx() + switch msg.ViewName { + case "next": + cmd = s.router.NextTab(ctx) + case "prev": + cmd = s.router.PrevTab(ctx) + default: + cmd = s.router.Navigate(msg.ViewName, ctx.WithArgs(msg.Args)) + } + return s, cmd + + case BackMsg: + cmd := s.router.Back(s.buildCtx()) + return s, cmd + + // ─── Error overlay ───────────────────────────────────────────────────── + case ErrorMsg: + s.activeError = &msg + return s, nil + + case DismissErrorMsg: + s.activeError = nil + return s, nil + + // ─── StateChanged (profile/theme/skin switch) ────────────────────────── + case StateChangedMsg: + if msg.ProfileID != "" { + _ = s.state.ChangeProfile(msg.ProfileID) + } + if msg.ThemeID != "" { + _ = s.state.ChangeTheme(msg.ThemeID) + } + if msg.SkinID != "" { + _ = s.state.ChangeSkin(msg.SkinID) + } + // Re-enter the current view so it refreshes with the new context + // (e.g. Config page rebuilds its form with the new selections). + ctx := s.buildCtx() + if v := s.router.Current(); v != nil { + cmd := v.OnEnter(ctx) + return s, cmd + } + return s, nil + + // ─── Key events ──────────────────────────────────────────────────────── + case tea.KeyMsg: + // If the active view captures keyboard (e.g. a huh form), let it handle + // tab/shift+tab/q first. Only ctrl+c remains unconditional. + if key.Matches(msg, s.keys.Quit) && msg.String() == "ctrl+c" { + return s, tea.Quit + } + if v := s.router.Current(); v != nil { + if kc, ok := v.(KeyboardCapture); ok && kc.CapturesKeyboard() { + updated, cmd := v.Update(msg) + s.router.views[s.router.current] = updated + return s, cmd + } + } + if cmd := GlobalKeyCmds(s.keys, msg); cmd != nil { + return s, cmd + } + } + + // Delegate to the active view. + if v := s.router.Current(); v != nil { + updated, cmd := v.Update(msg) + s.router.views[s.router.current] = updated + return s, cmd + } + return s, nil +} + +// View renders the full-screen layout. +func (s *Shell) View() string { + themeCtx := s.state.Current() + + // ── Header ────────────────────────────────────────────────────────────── + header := component.Header(themeCtx, s.width) + + // ── Navigation tabs ───────────────────────────────────────────────────── + tabs := make([]component.NavTab, 0, len(s.router.Views())) + for i, v := range s.router.Views() { + hidden := false + if nh, ok := v.(NavHideable); ok { + hidden = nh.NavHidden() + } + tabs = append(tabs, component.NavTab{ + Label: v.Name(), + Active: i == s.router.CurrentIndex(), + Hidden: hidden, + }) + } + nav := component.Navigation(themeCtx, tabs, s.width) + + // ── Active view area ───────────────────────────────────────────────────── + viewContent := "" + if v := s.router.Current(); v != nil { + viewContent = v.View() + } + + // ── Control bar ────────────────────────────────────────────────────────── + bindings := []component.Keybinding{ + {Key: "q", Desc: "quit"}, + {Key: "tab", Desc: "next"}, + {Key: "shift+tab", Desc: "prev"}, + } + if v := s.router.Current(); v != nil { + for _, kb := range v.Keybindings() { + bindings = append(bindings, component.Keybinding{Key: kb.Key, Desc: kb.Desc}) + } + } + controlbar := component.ControlBar(themeCtx, bindings, s.width) + + // ── Error overlay (if active) ───────────────────────────────────────────── + errorOverlay := "" + if s.activeError != nil { + style := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF6B6B")). + Bold(true). + Padding(0, 1) + msg := style.Render("⚠ " + s.activeError.Title) + if s.activeError.Err != nil { + msg += "\n" + s.activeError.Err.Error() + } + if s.activeError.Dismissible { + msg += "\n[d] dismiss" + } + errorOverlay = "\n" + msg + } + + return lipgloss.JoinVertical(lipgloss.Left, + header, + nav, + viewContent, + errorOverlay, + controlbar, + ) +} + +// shellChromeHeight is the number of terminal rows consumed by the Shell's +// persistent chrome: +// +// - Header: 1 (PaddingTop) + 2 (brand art rows) + 1 (BorderBottom) = 4 +// - Nav bar: 1 (tab BorderTop) + 1 (tab text) + 1 (container BorderBottom) = 3 +// - ControlBar: 1 (BorderTop) + 1 (text) = 2 +// +// Total: 9 rows. +const shellChromeHeight = 9 + +// buildCtx constructs a ViewContext from the current state. +// Height is reduced by shellChromeHeight so views receive the usable +// content area height, not the raw terminal height. +func (s *Shell) buildCtx() ViewContext { + contentH := s.height - shellChromeHeight + if contentH < 1 { + contentH = 1 + } + return s.state.BuildViewContext(s.width, contentH) +} diff --git a/pkg/ui/engine/state.go b/pkg/ui/engine/state.go new file mode 100644 index 0000000..6e2d6a7 --- /dev/null +++ b/pkg/ui/engine/state.go @@ -0,0 +1,109 @@ +package engine + +import ( + "errors" + + "github.com/arc-framework/arc-cli/internal/preferences" + "github.com/arc-framework/arc-cli/pkg/ui/theme" +) + +// StateManager loads user preferences and builds ViewContext objects. +// It owns the theme.Loader and resolves profile/theme/skin references. +type StateManager struct { + loader *theme.Loader + prefs *preferences.Preferences + backend Backend + current *theme.Context + skinID string // tracks the active skin separately from profile +} + +// NewStateManager creates a StateManager with the given loader and preferences. +func NewStateManager(loader *theme.Loader, prefs *preferences.Preferences, backend Backend) (*StateManager, error) { + if loader == nil { + return nil, errors.New("theme loader is required") + } + if prefs == nil { + return nil, errors.New("preferences is required") + } + + sm := &StateManager{ + loader: loader, + prefs: prefs, + backend: backend, + skinID: prefs.GetSkin(), + } + + if err := sm.reload(); err != nil { + return nil, err + } + return sm, nil +} + +// reload refreshes the current theme.Context from preferences. +func (sm *StateManager) reload() error { + profileID := sm.prefs.GetProfile() + if profileID == "" { + profileID = "enterprise" + } + + // Prefer explicit skinID on StateManager; fall back to prefs then default. + skinID := sm.skinID + if skinID == "" { + skinID = sm.prefs.GetSkin() + } + if skinID == "" { + skinID = "default" + } + + ctx, err := sm.loader.LoadContext(profileID, skinID) + if err != nil { + // Fall back to defaults if loading fails + ctx, err = sm.loader.DefaultContext() + if err != nil { + return err + } + } + sm.current = ctx + return nil +} + +// Current returns the active theme.Context. +func (sm *StateManager) Current() *theme.Context { + return sm.current +} + +// ChangeProfile switches to the specified profile and cascades theme/skin. +func (sm *StateManager) ChangeProfile(profileID string) error { + if err := sm.prefs.SetProfile(profileID); err != nil { + return err + } + return sm.reload() +} + +// ChangeTheme switches to the specified theme. +func (sm *StateManager) ChangeTheme(themeID string) error { + if err := sm.prefs.SetTheme(themeID); err != nil { + return err + } + return sm.reload() +} + +// ChangeSkin switches to the specified skin and cascades the context update. +func (sm *StateManager) ChangeSkin(skinID string) error { + sm.skinID = skinID + if err := sm.prefs.SetSkin(skinID); err != nil { + return err + } + return sm.reload() +} + +// BuildViewContext constructs a ViewContext with current state and given dimensions. +func (sm *StateManager) BuildViewContext(width, height int) ViewContext { + return ViewContext{ + Theme: sm.current, + Width: width, + Height: height, + Backend: sm.backend, + Args: map[string]string{}, + } +} diff --git a/pkg/ui/engine/view.go b/pkg/ui/engine/view.go index 8089e74..156406d 100644 --- a/pkg/ui/engine/view.go +++ b/pkg/ui/engine/view.go @@ -1,137 +1,52 @@ -// Package engine provides the core UI rendering infrastructure for ARC CLI. -// -// The engine package implements a view-based architecture with support for -// navigation, routing, and multiple rendering modes (TUI, JSON, static). package engine import ( tea "github.com/charmbracelet/bubbletea" ) -// View represents a full-screen interface component with lifecycle methods. -// -// Each command in the CLI maps to one or more views. Views implement the -// Bubble Tea Model interface plus additional lifecycle hooks for navigation. -// -// Example Implementation: -// -// type HomeView struct { -// hero *hero.Hero -// footer *footer.Footer -// } -// -// func (v *HomeView) Init() tea.Cmd { -// return nil -// } -// -// func (v *HomeView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { -// switch msg := msg.(type) { -// case tea.KeyMsg: -// if msg.String() == "q" { -// return v, tea.Quit -// } -// } -// return v, nil -// } -// -// func (v *HomeView) View() string { -// return lipgloss.JoinVertical( -// lipgloss.Left, -// v.hero.Render(), -// v.footer.Render(), -// ) -// } -// -// func (v *HomeView) OnEnter(ctx *ViewContext) tea.Cmd { -// v.hero.SetProfile(ctx.Profile) -// return nil -// } -// -// func (v *HomeView) OnExit() tea.Cmd { -// return nil -// } -// -// func (v *HomeView) Name() string { -// return "home" -// } -// -// func (v *HomeView) Keybindings() []KeyBinding { -// return []KeyBinding{ -// {Key: "q", Description: "Quit"}, -// } -// } +// View is the interface all views must implement. +// Views are pure rendering layers that receive state via ViewContext. type View interface { - // Bubble Tea Model interface methods - tea.Model + // Init is called once when the view is first created. + Init() tea.Cmd - // OnEnter is called when this view becomes active via Router.Navigate(). - // It receives a ViewContext with profile, theme, terminal dimensions, and - // route-specific arguments. Use this to initialize view state. - // - // Example: - // func (v *ServiceDetailView) OnEnter(ctx *ViewContext) tea.Cmd { - // serviceName := ctx.Args["serviceName"].(string) - // v.service = v.loader.Load(serviceName) - // return nil - // } - OnEnter(ctx *ViewContext) tea.Cmd + // Update handles incoming messages and returns an updated view + command. + Update(msg tea.Msg) (View, tea.Cmd) - // OnExit is called when this view is replaced by another view. - // Use this to clean up resources or save state. - // - // Example: - // func (v *DashboardView) OnExit() tea.Cmd { - // v.saveScrollPosition() - // return nil - // } + // View renders the current state of the view. + View() string + + // OnEnter is called each time the view becomes active (e.g., tab selected). + // The provided ViewContext includes current dimensions and theme state. + OnEnter(ctx ViewContext) tea.Cmd + + // OnExit is called before the view is hidden (e.g., tab switched away). + // Use to stop timers, cancel requests, or save view state. OnExit() tea.Cmd - // Name returns a unique identifier for this view used in routing. - // Names should be lowercase with hyphens (e.g., "service-detail"). + // Name returns the display name for the view (used in navigation). Name() string - // Keybindings returns the keyboard shortcuts specific to this view. - // These are displayed in the status bar for user reference. + // Keybindings returns the view-specific key bindings shown in ControlBar. Keybindings() []KeyBinding } -// KeyBinding represents a keyboard shortcut for a view. -// -// Keybindings are displayed in the status bar at the bottom of the screen -// to help users discover available actions. -// -// Example: -// -// []KeyBinding{ -// {Key: "j/k", Description: "Navigate"}, -// {Key: "enter", Description: "Select"}, -// {Key: "q", Description: "Quit"}, -// } +// KeyBinding is a key-description pair shown in the control bar. type KeyBinding struct { - // Key is the key combination (e.g., "j", "enter", "ctrl+c", "j/k") - Key string + Key string + Desc string +} - // Description is a brief explanation of what the key does - Description string +// NavHideable is an optional interface views can implement to hide themselves +// from the navigation bar. Detail views (e.g., ServiceDetail) should implement +// this so they can be navigated to via NavigateMsg without appearing as tabs. +type NavHideable interface { + NavHidden() bool } -// JSONExporter is implemented by views that support JSON output mode. -// -// Views that implement this interface can be used with engine.JSONMode rendering. -// The ToJSON method should return the view's data in a JSON-marshallable form. -// -// Example Usage in a command: -// -// if exporter, ok := view.(engine.JSONExporter); ok { -// engine.Render(engine.RenderConfig{ -// View: view, -// Mode: engine.JSONMode, -// JSONData: exporter.ToJSON(), -// JSONIndent: true, -// }) -// } -type JSONExporter interface { - // ToJSON returns the view's data for JSON output mode. - // The returned value must be JSON-marshallable (e.g. a struct, map, or slice). - ToJSON() any +// KeyboardCapture is an optional interface views can implement to receive +// tab, shift+tab, and q keys before global handlers process them. +// Useful for embedded forms (e.g. huh.Form) that need those keys internally. +type KeyboardCapture interface { + CapturesKeyboard() bool } diff --git a/pkg/ui/factory.go b/pkg/ui/factory.go deleted file mode 100644 index 2ec232d..0000000 --- a/pkg/ui/factory.go +++ /dev/null @@ -1,621 +0,0 @@ -package ui - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -const ( - // InfoIcon is the info severity icon. - InfoIcon = "ℹ" -) - -// StyleRegistry holds pre-computed lipgloss styles from a ColorSet. -// Styles are cached on construction to avoid repeated Style() allocations. -// -// Design Pattern: Pre-computed style cache pattern. -// Thread Safety: NOT thread-safe. Create once per command execution. -type StyleRegistry struct { - // Text styles - Title lipgloss.Style - Subtitle lipgloss.Style - Body lipgloss.Style - Muted lipgloss.Style - - // Semantic styles - Success lipgloss.Style - Error lipgloss.Style - Warning lipgloss.Style - Info lipgloss.Style - - // Border styles - FocusedBorder lipgloss.Style - BlurredBorder lipgloss.Style - - // List item styles - SelectedItem lipgloss.Style - NormalItem lipgloss.Style - - // Layout styles - CardStyle lipgloss.Style - OverlayStyle lipgloss.Style -} - -// NewStyleRegistry creates a StyleRegistry from a ColorSet and BorderTier. -// All styles are pre-computed and cached on construction. -// If colors is nil, returns a registry with unstyled (default) styles. -func NewStyleRegistry(colors *themes.ColorSet, tier components.BorderTier) *StyleRegistry { - if colors == nil { - // Return minimal unstyled registry - return &StyleRegistry{ - Title: lipgloss.NewStyle(), - Subtitle: lipgloss.NewStyle(), - Body: lipgloss.NewStyle(), - Muted: lipgloss.NewStyle(), - Success: lipgloss.NewStyle(), - Error: lipgloss.NewStyle(), - Warning: lipgloss.NewStyle(), - Info: lipgloss.NewStyle(), - FocusedBorder: lipgloss.NewStyle(), - BlurredBorder: lipgloss.NewStyle(), - SelectedItem: lipgloss.NewStyle(), - NormalItem: lipgloss.NewStyle(), - CardStyle: lipgloss.NewStyle(), - OverlayStyle: lipgloss.NewStyle(), - } - } - - // Create SafeBorder for border selection - sb := components.NewSafeBorderWithOverride(tier) - - // Text styles - titleStyle := lipgloss.NewStyle(). - Foreground(colors.PrimaryColor()). - Bold(true) - - subtitleStyle := lipgloss.NewStyle(). - Foreground(colors.SecondaryColor()). - Bold(true) - - bodyStyle := lipgloss.NewStyle(). - Foreground(colors.ForegroundColor()) - - mutedStyle := lipgloss.NewStyle(). - Foreground(colors.MutedColor()) - - // Semantic styles - successStyle := lipgloss.NewStyle(). - Foreground(colors.SuccessColor()). - Bold(true) - - errorStyle := lipgloss.NewStyle(). - Foreground(colors.ErrorColor()). - Bold(true) - - warningStyle := lipgloss.NewStyle(). - Foreground(colors.WarningColor()). - Bold(true) - - infoStyle := lipgloss.NewStyle(). - Foreground(colors.InfoColor()). - Bold(true) - - // Border styles based on tier - focusedBorderStyle := lipgloss.NewStyle(). - Border(sb.FocusBorder()). - BorderForeground(colors.PrimaryColor()) - - blurredBorderStyle := lipgloss.NewStyle(). - Border(sb.Border()). - BorderForeground(colors.BorderColor()) - - // List item styles - selectedItemStyle := lipgloss.NewStyle(). - Foreground(colors.PrimaryColor()). - Bold(true). - PaddingLeft(1) - - normalItemStyle := lipgloss.NewStyle(). - Foreground(colors.ForegroundColor()). - PaddingLeft(1) - - // Layout styles - cardStyle := lipgloss.NewStyle(). - Border(sb.Border()). - BorderForeground(colors.BorderColor()). - Padding(1) - - overlayStyle := lipgloss.NewStyle(). - Border(sb.Border()). - BorderForeground(colors.PrimaryColor()). - Padding(1, 2). - Background(colors.BackgroundColor()) - - return &StyleRegistry{ - Title: titleStyle, - Subtitle: subtitleStyle, - Body: bodyStyle, - Muted: mutedStyle, - Success: successStyle, - Error: errorStyle, - Warning: warningStyle, - Info: infoStyle, - FocusedBorder: focusedBorderStyle, - BlurredBorder: blurredBorderStyle, - SelectedItem: selectedItemStyle, - NormalItem: normalItemStyle, - CardStyle: cardStyle, - OverlayStyle: overlayStyle, - } -} - -// ComponentFactory produces pre-themed, render-ready UI components. -// Created once per command execution from ProfileContext + BorderMode. -// All methods return rendered strings ready for terminal output. -// -// Design Pattern: styled-components — colors come from profile, not hardcoded. -// Thread Safety: NOT thread-safe. Create one per goroutine or command. -type ComponentFactory interface { - // Card renders a bordered content card with a title. - Card(title, content string) string - - // CardFocused renders a card with a highlighted border (for active selection). - CardFocused(title, content string) string - - // CardGrid renders a responsive grid of cards. - CardGrid(cards []CardData, width int) string - - // TabBar renders a horizontal tab navigation bar. - TabBar(tabs []TabItem, activeIdx, width int) string - - // SplitPane renders a left/right split layout with a divider. - SplitPane(left, right string, ratio float64, width int) string - - // StatusRail renders a bottom status bar with sections. - StatusRail(sections []RailSection, width int) string - - // Toast renders an overlay notification with severity theming. - Toast(message string, severity Severity) string - - // SectionHeader renders a themed section divider with icon and title. - SectionHeader(icon, title string) string - - // Table renders a themed table with headers and rows. - Table(headers []string, rows [][]string) string - - // ErrorBox renders a themed error box for standalone (non-dashboard) mode. - // Phase 7 (US5): Unified error boundary with profile-themed borders and colors. - ErrorBox(err error, context, hint string, severity components.Severity) string - - // Border returns the lipgloss.Border for the current tier. - Border() lipgloss.Border - - // SetBorderMode changes the border tier at runtime. - SetBorderMode(tier components.BorderTier) - - // ProfileContext returns the underlying ProfileContext. - ProfileContext() *profiles.ProfileContext - - // Theme returns the current theme (shortcut for ProfileContext().Theme()). - Theme() *themes.Theme -} - -// componentFactory implements the ComponentFactory interface. -// Produces pre-themed, render-ready UI components. -type componentFactory struct { - profileCtx *profiles.ProfileContext - tier components.BorderTier - styles *StyleRegistry - safeBorder *components.SafeBorder -} - -// CardData holds the data for a single card in the grid. -type CardData struct { - Title string - Icon string - Rows []KeyValueRow -} - -// KeyValueRow is a single key-value pair for display in cards. -type KeyValueRow struct { - Key string - Value string -} - -// TabItem represents a single tab in the tab bar. -type TabItem struct { - ID int - Label string - Icon string -} - -// RailSection represents a single section in the status rail. -type RailSection struct { - Icon string - Label string - Value string -} - -// Severity represents error/warning/info levels. -type Severity int - -const ( - SeverityError Severity = iota - SeverityWarning - SeverityInfo -) - -// NewComponentFactory creates a ComponentFactory from a ProfileContext and BorderTier. -// If profileCtx is nil, falls back to enterprise profile (never panics). -// Styles are pre-computed and cached on construction. -func NewComponentFactory(profileCtx *profiles.ProfileContext, tier components.BorderTier) ComponentFactory { - // Nil-safe fallback to enterprise profile - if profileCtx == nil { - profileCtx = profiles.GetDefaultProfileContext() - } - - // Get theme colors (may be nil if theme isn't loaded) - colors := profileCtx.ThemeColors() - - // Create style registry - styles := NewStyleRegistry(colors, tier) - - // Create safe border for the tier - safeBorder := components.NewSafeBorderWithOverride(tier) - - return &componentFactory{ - profileCtx: profileCtx, - tier: tier, - styles: styles, - safeBorder: safeBorder, - } -} - -// Card renders a bordered content card with a title. -// Title is rendered in profile primary color. -// Border style depends on current BorderTier. -func (f *componentFactory) Card(title, content string) string { - // Build card with title and separator - titleLine := f.styles.Title.Render(title) - - var renderedContent string - if title != "" { - separator := f.styles.Muted.Render(strings.Repeat("─", lipgloss.Width(title))) - renderedContent = lipgloss.JoinVertical( - lipgloss.Left, - titleLine, - separator, - "", - content, - ) - } else { - renderedContent = content - } - - // Apply card styling with border - return f.styles.CardStyle.Render(renderedContent) -} - -// CardFocused renders a card with a highlighted border (for active selection). -func (f *componentFactory) CardFocused(title, content string) string { - // Build card with title and separator - titleLine := f.styles.Title.Render(title) - - var renderedContent string - if title != "" { - separator := f.styles.Title.Render(strings.Repeat("─", lipgloss.Width(title))) - renderedContent = lipgloss.JoinVertical( - lipgloss.Left, - titleLine, - separator, - "", - content, - ) - } else { - renderedContent = content - } - - // Apply focused border styling - focusedStyle := lipgloss.NewStyle(). - Border(f.safeBorder.FocusBorder()). - BorderForeground(f.profileCtx.ThemeColors().PrimaryColor()). - Padding(1) - - return focusedStyle.Render(renderedContent) -} - -// CardGrid renders a responsive grid of cards. -// Cards reflow from multi-column to single-column based on width. -// Breakpoint: width < 100 → single column. -// Uses the CardGrid component for responsive layout with height equalization. -func (f *componentFactory) CardGrid(cards []CardData, width int) string { - if len(cards) == 0 { - return "" - } - - // Render each card using factory styles - renderedCards := make([]string, len(cards)) - for i, card := range cards { - // Build card content from rows - var rows []string - if card.Icon != "" { - iconLine := f.styles.Title.Render(card.Icon + " " + card.Title) - rows = append(rows, iconLine) - } else { - rows = append(rows, f.styles.Title.Render(card.Title)) - } - - rows = append(rows, "") // Spacing - - for _, row := range card.Rows { - keyStyle := f.styles.Muted.Render(row.Key + ": ") - valueStyle := f.styles.Body.Render(row.Value) - rows = append(rows, keyStyle+valueStyle) - } - - content := strings.Join(rows, "\n") - renderedCards[i] = f.styles.CardStyle.Render(content) - } - - // Use CardGrid component for responsive layout with height equalization - grid := components.NewCardGrid(renderedCards, width). - WithMinWidth(38). - WithMaxWidth(60). - WithColumnGap(3). - WithRowGap(1) - - return grid.Render() -} - -// TabBar renders a horizontal tab navigation bar. -// Active tab has filled/bright styling, inactive tabs are muted. -func (f *componentFactory) TabBar(tabs []TabItem, activeIdx, width int) string { - if len(tabs) == 0 { - return "" - } - - renderedTabs := make([]string, len(tabs)) - for i, tab := range tabs { - label := tab.Label - if tab.Icon != "" { - label = tab.Icon + " " + label - } - - if i == activeIdx { - renderedTabs[i] = f.styles.SelectedItem.Render(label) - } else { - renderedTabs[i] = f.styles.Muted.Render(label) - } - } - - tabBar := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...) - - // Add border at bottom if not borderless - if !f.safeBorder.IsBorderless() { - separator := f.styles.BlurredBorder.BorderForeground().String() - sepLine := lipgloss.NewStyle(). - Foreground(lipgloss.Color(separator)). - Render(strings.Repeat("─", width)) - - return lipgloss.JoinVertical(lipgloss.Left, tabBar, sepLine) - } - - return tabBar -} - -// SplitPane renders a left/right split layout with a divider. -// ratio is left pane proportion (0.0-1.0), typically 0.3. -func (f *componentFactory) SplitPane(left, right string, ratio float64, width int) string { - if ratio < 0 { - ratio = 0 - } - if ratio > 1 { - ratio = 1 - } - - leftWidth := int(float64(width) * ratio) - rightWidth := width - leftWidth - 1 // -1 for divider - - if leftWidth < 0 { - leftWidth = 0 - } - if rightWidth < 0 { - rightWidth = 0 - } - - leftStyle := lipgloss.NewStyle().Width(leftWidth) - rightStyle := lipgloss.NewStyle().Width(rightWidth) - - leftPane := leftStyle.Render(left) - rightPane := rightStyle.Render(right) - - // Create divider - divider := f.styles.BlurredBorder.BorderForeground().String() - dividerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(divider)) - dividerChar := "│" - if f.safeBorder.IsBorderless() { - dividerChar = " " - } - - // Calculate divider height based on max of left/right pane heights - leftHeight := lipgloss.Height(leftPane) - rightHeight := lipgloss.Height(rightPane) - maxHeight := leftHeight - if rightHeight > maxHeight { - maxHeight = rightHeight - } - - dividerLines := make([]string, maxHeight) - for i := 0; i < maxHeight; i++ { - dividerLines[i] = dividerStyle.Render(dividerChar) - } - dividerColumn := strings.Join(dividerLines, "\n") - - return lipgloss.JoinHorizontal(lipgloss.Top, leftPane, dividerColumn, rightPane) -} - -// StatusRail renders a bottom status bar with sections. -func (f *componentFactory) StatusRail(sections []RailSection, width int) string { - if len(sections) == 0 { - return "" - } - - renderedSections := make([]string, len(sections)) - for i, section := range sections { - label := section.Icon + " " + section.Label - if section.Value != "" { - label += ": " + section.Value - } - - renderedSections[i] = f.styles.Muted.Render(label) - } - - rail := lipgloss.JoinHorizontal(lipgloss.Top, renderedSections...) - - // Add top border if not borderless - if !f.safeBorder.IsBorderless() { - separator := f.styles.BlurredBorder.BorderForeground().String() - sepLine := lipgloss.NewStyle(). - Foreground(lipgloss.Color(separator)). - Render(strings.Repeat("─", width)) - - return lipgloss.JoinVertical(lipgloss.Left, sepLine, rail) - } - - return rail -} - -// Toast renders an overlay notification with severity theming. -func (f *componentFactory) Toast(message string, severity Severity) string { - var style lipgloss.Style - var icon string - - switch severity { - case SeverityError: - style = f.styles.Error - icon = "✗" - case SeverityWarning: - style = f.styles.Warning - icon = "⚠" - case SeverityInfo: - style = f.styles.Info - icon = InfoIcon - default: - style = f.styles.Info - icon = InfoIcon - } - - content := icon + " " + message - styledContent := style.Render(content) - - return f.styles.OverlayStyle.Render(styledContent) -} - -// SectionHeader renders a themed section divider with icon and title. -// Uses profile primary color + bold. -// This is a convenience method that creates and renders a SectionHeader component. -func (f *componentFactory) SectionHeader(icon, title string) string { - // Get primary color from profile theme colors - colors := f.profileCtx.ThemeColors() - if colors == nil { - // Fallback to default styling if no theme colors - return components.NewSectionHeaderWithDefaults(icon, title, 0).Render() - } - - // Create section header with profile primary color - header := components.NewSectionHeader(icon, title, colors.PrimaryColor(), 0) - return header.Render() -} - -// Table renders a themed table with headers and rows. -func (f *componentFactory) Table(headers []string, rows [][]string) string { - if len(headers) == 0 { - return "" - } - - // Render headers - headerCells := make([]string, len(headers)) - for i, h := range headers { - headerCells[i] = f.styles.Title.Render(h) - } - headerRow := lipgloss.JoinHorizontal(lipgloss.Top, headerCells...) - - // Render separator - totalWidth := lipgloss.Width(headerRow) - separator := f.styles.Muted.Render(strings.Repeat("─", totalWidth)) - - // Render rows - renderedRows := make([]string, len(rows)) - for i, row := range rows { - cells := make([]string, len(row)) - for j, cell := range row { - cells[j] = f.styles.Body.Render(cell) - } - renderedRows[i] = lipgloss.JoinHorizontal(lipgloss.Top, cells...) - } - - // Join all parts - parts := []string{headerRow, separator} - parts = append(parts, renderedRows...) - - return lipgloss.JoinVertical(lipgloss.Left, parts...) -} - -// ErrorBox renders a themed error box for standalone (non-dashboard) mode. -// Phase 7 (US5): Uses profile theme colors and SafeBorder for consistent error presentation. -func (f *componentFactory) ErrorBox(err error, context, hint string, severity components.Severity) string { - theme := f.profileCtx.Theme() - - opts := components.ErrorOptions{ - Severity: severity, - Context: context, - Hint: hint, - Theme: theme, - Width: 80, // Default width, can be customized - } - - return components.ErrorBox(err, opts) -} - -// Border returns the lipgloss.Border for the current tier. -// Tier 1: HiddenBorder(), Tier 2: OuterHalfBlockBorder(), Tier 3: RoundedBorder() -func (f *componentFactory) Border() lipgloss.Border { - return f.safeBorder.Border() -} - -// SetBorderMode changes the border tier at runtime. -// Triggers style cache invalidation. -func (f *componentFactory) SetBorderMode(tier components.BorderTier) { - f.tier = tier - f.safeBorder = components.NewSafeBorderWithOverride(tier) - - // Invalidate and rebuild style cache - colors := f.profileCtx.ThemeColors() - f.styles = NewStyleRegistry(colors, tier) -} - -// ProfileContext returns the underlying ProfileContext. -func (f *componentFactory) ProfileContext() *profiles.ProfileContext { - return f.profileCtx -} - -// Theme returns the current theme (shortcut for ProfileContext().Theme()). -func (f *componentFactory) Theme() *themes.Theme { - return f.profileCtx.Theme() -} - -// String returns a human-readable description of the factory. -func (f *componentFactory) String() string { - profileID := "unknown" - if f.profileCtx != nil && f.profileCtx.Profile() != nil { - profileID = f.profileCtx.Profile().ID - } - - return fmt.Sprintf("ComponentFactory{profile=%s, tier=%s}", profileID, f.tier.String()) -} diff --git a/pkg/ui/factory_example_test.go b/pkg/ui/factory_example_test.go deleted file mode 100644 index ad42847..0000000 --- a/pkg/ui/factory_example_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package ui_test - -import ( - "fmt" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -// ExampleNewComponentFactory demonstrates basic usage of the ComponentFactory. -func ExampleNewComponentFactory() { - // Load a profile context (falls back to enterprise if nil) - profileCtx := profiles.GetDefaultProfileContext() - - // Create a factory with border tier - factory := ui.NewComponentFactory(profileCtx, components.BorderTierBlock) - - // Create a simple card - card := factory.Card("Welcome", "This is a themed card") - - // The card will be styled with the profile's theme colors - fmt.Println(len(card) > 0) // Card is rendered - // Output: true -} - -// Example_card demonstrates creating themed cards. -func Example_card() { - factory := ui.NewComponentFactory(nil, components.BorderTierNone) - - // Create a card with title and content - card := factory.Card("System Info", "Version: 1.0.0\nStatus: Active") - - // Verify the card contains our content - fmt.Println(len(card) > 0) - // Output: true -} - -// Example_cardGrid demonstrates creating a responsive card grid. -func Example_cardGrid() { - factory := ui.NewComponentFactory(nil, components.BorderTierBlock) - - // Create multiple cards - cards := []ui.CardData{ - { - Title: "Service A", - Icon: "📦", - Rows: []ui.KeyValueRow{ - {Key: "Status", Value: "Running"}, - {Key: "Uptime", Value: "99.9%"}, - }, - }, - { - Title: "Service B", - Icon: "🔧", - Rows: []ui.KeyValueRow{ - {Key: "Status", Value: "Stopped"}, - {Key: "Uptime", Value: "0%"}, - }, - }, - } - - // Render grid with width = 120 (two columns) - grid := factory.CardGrid(cards, 120) - - // Verify the grid contains our cards - fmt.Println(len(grid) > 0) - // Output: true -} - -// Example_toast demonstrates creating notification toasts. -func Example_toast() { - factory := ui.NewComponentFactory(nil, components.BorderTierNone) - - // Create different severity toasts - errorToast := factory.Toast("Operation failed", ui.SeverityError) - warningToast := factory.Toast("Low disk space", ui.SeverityWarning) - infoToast := factory.Toast("Update available", ui.SeverityInfo) - - // All toasts are styled differently based on severity - fmt.Println(len(errorToast) > 0) - fmt.Println(len(warningToast) > 0) - fmt.Println(len(infoToast) > 0) - // Output: - // true - // true - // true -} - -// Example_setBorderMode demonstrates runtime border tier switching. -func Example_setBorderMode() { - factory := ui.NewComponentFactory(nil, components.BorderTierNone) - - // Initially borderless - card1 := factory.Card("Borderless", "No borders") - - // Switch to block borders - factory.SetBorderMode(components.BorderTierBlock) - card2 := factory.Card("With Borders", "Has block borders") - - // Both cards render successfully - fmt.Println(len(card1) > 0) - fmt.Println(len(card2) > 0) - // Output: - // true - // true -} diff --git a/pkg/ui/factory_test.go b/pkg/ui/factory_test.go deleted file mode 100644 index 3d5a66c..0000000 --- a/pkg/ui/factory_test.go +++ /dev/null @@ -1,980 +0,0 @@ -package ui - -import ( - "strings" - "testing" - - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// TestNewStyleRegistry tests the StyleRegistry constructor. -func TestNewStyleRegistry(t *testing.T) { - tests := []struct { - name string - colors *themes.ColorSet - tier components.BorderTier - want string // Expected non-nil fields - }{ - { - name: "nil colors creates unstyled registry", - colors: nil, - tier: components.BorderTierNone, - want: "all styles initialized", - }, - { - name: "valid colors with tier none", - colors: &themes.ColorSet{ - Primary: "#00ADD8", - Secondary: "#FF6347", - Success: "#00E091", - Error: "#FF4444", - Warning: "#FFB86C", - Info: "#00ADD8", - Foreground: "#F8F8F2", - Background: "#282A36", - Muted: "#6272A4", - Border: "#44475A", - }, - tier: components.BorderTierNone, - want: "all styles initialized", - }, - { - name: "valid colors with tier block", - colors: &themes.ColorSet{ - Primary: "#00ADD8", - Secondary: "#FF6347", - Success: "#00E091", - Error: "#FF4444", - Warning: "#FFB86C", - Info: "#00ADD8", - Foreground: "#F8F8F2", - Background: "#282A36", - Muted: "#6272A4", - Border: "#44475A", - }, - tier: components.BorderTierBlock, - want: "all styles initialized", - }, - { - name: "valid colors with tier classic", - colors: &themes.ColorSet{ - Primary: "#00ADD8", - Secondary: "#FF6347", - Success: "#00E091", - Error: "#FF4444", - Warning: "#FFB86C", - Info: "#00ADD8", - Foreground: "#F8F8F2", - Background: "#282A36", - Muted: "#6272A4", - Border: "#44475A", - }, - tier: components.BorderTierClassic, - want: "all styles initialized", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - registry := NewStyleRegistry(tt.colors, tt.tier) - - if registry == nil { - t.Fatal("NewStyleRegistry returned nil") - } - - // Verify all fields are initialized by rendering test content - // Styles are valid if they can render without panic - testContent := "test" - - // Test all text styles - _ = registry.Title.Render(testContent) - _ = registry.Subtitle.Render(testContent) - _ = registry.Body.Render(testContent) - _ = registry.Muted.Render(testContent) - - // Test semantic styles - _ = registry.Success.Render(testContent) - _ = registry.Error.Render(testContent) - _ = registry.Warning.Render(testContent) - _ = registry.Info.Render(testContent) - - // Test border styles - _ = registry.FocusedBorder.Render(testContent) - _ = registry.BlurredBorder.Render(testContent) - - // Test list styles - _ = registry.SelectedItem.Render(testContent) - _ = registry.NormalItem.Render(testContent) - - // Test layout styles - _ = registry.CardStyle.Render(testContent) - _ = registry.OverlayStyle.Render(testContent) - - // If we got here without panic, all styles are initialized - }) - } -} - -// TestNewComponentFactory tests the ComponentFactory constructor. -func TestNewComponentFactory(t *testing.T) { - tests := []struct { - name string - profileCtx *profiles.ProfileContext - tier components.BorderTier - wantNil bool - }{ - { - name: "nil profile context falls back to enterprise", - profileCtx: nil, - tier: components.BorderTierNone, - wantNil: false, - }, - { - name: "valid saiyan profile with tier block", - profileCtx: createTestProfileContext(t, "saiyan"), - tier: components.BorderTierBlock, - wantNil: false, - }, - { - name: "valid enterprise profile with tier classic", - profileCtx: createTestProfileContext(t, "enterprise"), - tier: components.BorderTierClassic, - wantNil: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - factory := NewComponentFactory(tt.profileCtx, tt.tier) - - if tt.wantNil && factory != nil { - t.Error("Expected nil factory, got non-nil") - } - - if !tt.wantNil && factory == nil { - t.Fatal("Expected non-nil factory, got nil") - } - - if factory != nil { - // Verify factory has valid components by testing public methods - if factory.ProfileContext() == nil { - t.Error("Factory ProfileContext is nil") - } - - // Test that the factory can produce components - testCard := factory.Card("Test", "Content") - if testCard == "" { - t.Error("Factory cannot produce card output") - } - - // Test that Border() returns a valid border - border := factory.Border() - if border == (lipgloss.Border{}) { - t.Error("Factory Border is empty") - } - } - }) - } -} - -// TestComponentFactory_Card tests the Card rendering. -func TestComponentFactory_Card(t *testing.T) { - tests := []struct { - name string - profileID string - tier components.BorderTier - title string - content string - wantContains []string - }{ - { - name: "card with title and content", - profileID: "enterprise", - tier: components.BorderTierBlock, - title: "System Info", - content: "Version: 1.0.0\nStatus: Active", - wantContains: []string{ - "System Info", - "Version: 1.0.0", - "Status: Active", - }, - }, - { - name: "card with empty title", - profileID: "saiyan", - tier: components.BorderTierNone, - title: "", - content: "Content only", - wantContains: []string{ - "Content only", - }, - }, - { - name: "card with borderless tier", - profileID: "enterprise", - tier: components.BorderTierNone, - title: "Borderless", - content: "No borders here", - wantContains: []string{ - "Borderless", - "No borders here", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - factory := NewComponentFactory(createTestProfileContext(t, tt.profileID), tt.tier) - result := factory.Card(tt.title, tt.content) - - if result == "" { - t.Error("Card returned empty string") - } - - for _, want := range tt.wantContains { - if !strings.Contains(result, want) { - t.Errorf("Card output missing expected substring %q", want) - } - } - }) - } -} - -// TestComponentFactory_CardFocused tests the CardFocused rendering. -func TestComponentFactory_CardFocused(t *testing.T) { - factory := NewComponentFactory(createTestProfileContext(t, "saiyan"), components.BorderTierBlock) - result := factory.CardFocused("Focused Card", "This is focused") - - if result == "" { - t.Error("CardFocused returned empty string") - } - - if !strings.Contains(result, "Focused Card") { - t.Error("CardFocused missing title") - } - - if !strings.Contains(result, "This is focused") { - t.Error("CardFocused missing content") - } -} - -// TestComponentFactory_CardGrid tests the CardGrid rendering. -func TestComponentFactory_CardGrid(t *testing.T) { - tests := []struct { - name string - cards []CardData - width int - wantContains []string - }{ - { - name: "empty card list", - cards: []CardData{}, - width: 120, - wantContains: nil, - }, - { - name: "single card", - cards: []CardData{ - { - Title: "Card 1", - Icon: "📦", - Rows: []KeyValueRow{ - {Key: "Name", Value: "Test"}, - {Key: "Status", Value: "Active"}, - }, - }, - }, - width: 120, - wantContains: []string{ - "Card 1", - "Name", - "Test", - "Status", - "Active", - }, - }, - { - name: "two cards wide layout", - cards: []CardData{ - {Title: "Card 1", Icon: "📦", Rows: []KeyValueRow{{Key: "K1", Value: "V1"}}}, - {Title: "Card 2", Icon: "📋", Rows: []KeyValueRow{{Key: "K2", Value: "V2"}}}, - }, - width: 120, - wantContains: []string{ - "Card 1", - "Card 2", - }, - }, - { - name: "single column narrow layout", - cards: []CardData{ - {Title: "Card 1", Rows: []KeyValueRow{{Key: "K1", Value: "V1"}}}, - {Title: "Card 2", Rows: []KeyValueRow{{Key: "K2", Value: "V2"}}}, - }, - width: 80, - wantContains: []string{ - "Card 1", - "Card 2", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - factory := NewComponentFactory(createTestProfileContext(t, "enterprise"), components.BorderTierBlock) - result := factory.CardGrid(tt.cards, tt.width) - - if len(tt.cards) == 0 && result != "" { - t.Error("Expected empty result for empty cards") - } - - for _, want := range tt.wantContains { - if !strings.Contains(result, want) { - t.Errorf("CardGrid missing expected substring %q", want) - } - } - }) - } -} - -// TestComponentFactory_TabBar tests the TabBar rendering. -func TestComponentFactory_TabBar(t *testing.T) { - tests := []struct { - name string - tabs []TabItem - activeIdx int - width int - wantContains []string - }{ - { - name: "empty tab list", - tabs: []TabItem{}, - activeIdx: 0, - width: 100, - wantContains: nil, - }, - { - name: "single tab active", - tabs: []TabItem{ - {ID: 1, Label: "Home", Icon: "🏠"}, - }, - activeIdx: 0, - width: 100, - wantContains: []string{ - "Home", - }, - }, - { - name: "multiple tabs with active", - tabs: []TabItem{ - {ID: 1, Label: "Home", Icon: "🏠"}, - {ID: 2, Label: "Settings", Icon: "⚙"}, - {ID: 3, Label: "Help", Icon: "❓"}, - }, - activeIdx: 1, - width: 100, - wantContains: []string{ - "Home", - "Settings", - "Help", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - factory := NewComponentFactory(createTestProfileContext(t, "saiyan"), components.BorderTierBlock) - result := factory.TabBar(tt.tabs, tt.activeIdx, tt.width) - - if len(tt.tabs) == 0 && result != "" { - t.Error("Expected empty result for empty tabs") - } - - for _, want := range tt.wantContains { - if !strings.Contains(result, want) { - t.Errorf("TabBar missing expected substring %q", want) - } - } - }) - } -} - -// TestComponentFactory_SplitPane tests the SplitPane rendering. -func TestComponentFactory_SplitPane(t *testing.T) { - factory := NewComponentFactory(createTestProfileContext(t, "enterprise"), components.BorderTierBlock) - - tests := []struct { - name string - left string - right string - ratio float64 - width int - }{ - { - name: "30-70 split", - left: "Left pane content", - right: "Right pane content", - ratio: 0.3, - width: 100, - }, - { - name: "50-50 split", - left: "Equal left", - right: "Equal right", - ratio: 0.5, - width: 80, - }, - { - name: "ratio out of bounds low", - left: "Left", - right: "Right", - ratio: -0.5, - width: 100, - }, - { - name: "ratio out of bounds high", - left: "Left", - right: "Right", - ratio: 1.5, - width: 100, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := factory.SplitPane(tt.left, tt.right, tt.ratio, tt.width) - - if result == "" { - t.Error("SplitPane returned empty string") - } - - // Both sides should be present - if !strings.Contains(result, tt.left) { - t.Error("SplitPane missing left content") - } - if !strings.Contains(result, tt.right) { - t.Error("SplitPane missing right content") - } - }) - } -} - -// TestComponentFactory_StatusRail tests the StatusRail rendering. -func TestComponentFactory_StatusRail(t *testing.T) { - tests := []struct { - name string - sections []RailSection - width int - wantContains []string - }{ - { - name: "empty sections", - sections: []RailSection{}, - width: 100, - wantContains: nil, - }, - { - name: "single section", - sections: []RailSection{ - {Icon: "✓", Label: "Status", Value: "OK"}, - }, - width: 100, - wantContains: []string{ - "Status", - "OK", - }, - }, - { - name: "multiple sections", - sections: []RailSection{ - {Icon: "✓", Label: "Status", Value: "OK"}, - {Icon: "📊", Label: "Items", Value: "42"}, - {Icon: "⏱", Label: "Time", Value: "10s"}, - }, - width: 100, - wantContains: []string{ - "Status", - "Items", - "Time", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - factory := NewComponentFactory(createTestProfileContext(t, "enterprise"), components.BorderTierNone) - result := factory.StatusRail(tt.sections, tt.width) - - if len(tt.sections) == 0 && result != "" { - t.Error("Expected empty result for empty sections") - } - - for _, want := range tt.wantContains { - if !strings.Contains(result, want) { - t.Errorf("StatusRail missing expected substring %q", want) - } - } - }) - } -} - -// TestComponentFactory_Toast tests the Toast rendering. -func TestComponentFactory_Toast(t *testing.T) { - tests := []struct { - name string - message string - severity Severity - wantContains []string - }{ - { - name: "error toast", - message: "Something went wrong", - severity: SeverityError, - wantContains: []string{ - "Something went wrong", - }, - }, - { - name: "warning toast", - message: "Be careful", - severity: SeverityWarning, - wantContains: []string{ - "Be careful", - }, - }, - { - name: "info toast", - message: "FYI", - severity: SeverityInfo, - wantContains: []string{ - "FYI", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - factory := NewComponentFactory(createTestProfileContext(t, "saiyan"), components.BorderTierBlock) - result := factory.Toast(tt.message, tt.severity) - - if result == "" { - t.Error("Toast returned empty string") - } - - for _, want := range tt.wantContains { - if !strings.Contains(result, want) { - t.Errorf("Toast missing expected substring %q", want) - } - } - }) - } -} - -// TestComponentFactory_SectionHeader tests the SectionHeader rendering. -func TestComponentFactory_SectionHeader(t *testing.T) { - factory := NewComponentFactory(createTestProfileContext(t, "enterprise"), components.BorderTierNone) - - tests := []struct { - name string - icon string - title string - }{ - { - name: "header with icon", - icon: "📋", - title: "Section Title", - }, - { - name: "header without icon", - icon: "", - title: "Plain Title", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := factory.SectionHeader(tt.icon, tt.title) - - if result == "" { - t.Error("SectionHeader returned empty string") - } - - if tt.title != "" && !strings.Contains(result, tt.title) { - t.Error("SectionHeader missing title") - } - }) - } -} - -// TestComponentFactory_Table tests the Table rendering. -func TestComponentFactory_Table(t *testing.T) { - tests := []struct { - name string - headers []string - rows [][]string - wantContains []string - }{ - { - name: "empty table", - headers: []string{}, - rows: [][]string{}, - wantContains: nil, - }, - { - name: "table with headers only", - headers: []string{"Name", "Status"}, - rows: [][]string{}, - wantContains: []string{ - "Name", - "Status", - }, - }, - { - name: "table with headers and rows", - headers: []string{"Name", "Status"}, - rows: [][]string{ - {"Item 1", "Active"}, - {"Item 2", "Inactive"}, - }, - wantContains: []string{ - "Name", - "Status", - "Item 1", - "Active", - "Item 2", - "Inactive", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - factory := NewComponentFactory(createTestProfileContext(t, "enterprise"), components.BorderTierBlock) - result := factory.Table(tt.headers, tt.rows) - - if len(tt.headers) == 0 && result != "" { - t.Error("Expected empty result for empty headers") - } - - for _, want := range tt.wantContains { - if !strings.Contains(result, want) { - t.Errorf("Table missing expected substring %q", want) - } - } - }) - } -} - -// TestComponentFactory_Border tests the Border method. -func TestComponentFactory_Border(t *testing.T) { - tests := []struct { - name string - tier components.BorderTier - }{ - { - name: "tier none", - tier: components.BorderTierNone, - }, - { - name: "tier block", - tier: components.BorderTierBlock, - }, - { - name: "tier classic", - tier: components.BorderTierClassic, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - factory := NewComponentFactory(createTestProfileContext(t, "enterprise"), tt.tier) - border := factory.Border() - - // We can't directly compare borders, but we can verify it's not empty - if border == (lipgloss.Border{}) { - t.Error("Border returned empty border") - } - }) - } -} - -// TestComponentFactory_SetBorderMode tests the SetBorderMode method. -func TestComponentFactory_SetBorderMode(t *testing.T) { - factory := NewComponentFactory(createTestProfileContext(t, "saiyan"), components.BorderTierNone) - - // Test initial state by checking Border returns a valid value - initialBorder := factory.Border() - if initialBorder == (lipgloss.Border{}) { - t.Error("Initial border should not be empty") - } - - // Create a card with initial tier - card1 := factory.Card("Test 1", "Content 1") - if card1 == "" { - t.Error("Initial card should not be empty") - } - - // Change to Block - factory.SetBorderMode(components.BorderTierBlock) - - // Verify border changed by creating a new card - card2 := factory.Card("Test 2", "Content 2") - if card2 == "" { - t.Error("Card after SetBorderMode should not be empty") - } - - // Borders for different tiers should be different - newBorder := factory.Border() - if newBorder == (lipgloss.Border{}) { - t.Error("Border after SetBorderMode should not be empty") - } - - // Change to Classic - factory.SetBorderMode(components.BorderTierClassic) - - // Verify third tier works - card3 := factory.Card("Test 3", "Content 3") - if card3 == "" { - t.Error("Card after second SetBorderMode should not be empty") - } -} - -// TestComponentFactory_ProfileContext tests the ProfileContext getter. -func TestComponentFactory_ProfileContext(t *testing.T) { - profileCtx := createTestProfileContext(t, "enterprise") - factory := NewComponentFactory(profileCtx, components.BorderTierNone) - - result := factory.ProfileContext() - if result == nil { - t.Fatal("ProfileContext returned nil") - } - - if result != profileCtx { - t.Error("ProfileContext returned different context") - } -} - -// TestComponentFactory_Theme tests the Theme getter. -func TestComponentFactory_Theme(t *testing.T) { - profileCtx := createTestProfileContext(t, "saiyan") - factory := NewComponentFactory(profileCtx, components.BorderTierNone) - - theme := factory.Theme() - // Theme might be nil if profile doesn't have one, that's OK - if theme != nil && theme.Name == "" { - t.Error("Theme has empty name") - } -} - -// TestComponentFactory_ProfileIntegration tests profile integration. -func TestComponentFactory_ProfileIntegration(t *testing.T) { - tests := []struct { - name string - profileID string - tier components.BorderTier - }{ - { - name: "enterprise profile", - profileID: "enterprise", - tier: components.BorderTierNone, - }, - { - name: "saiyan profile with block tier", - profileID: "saiyan", - tier: components.BorderTierBlock, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - factory := NewComponentFactory(createTestProfileContext(t, tt.profileID), tt.tier) - - // Verify the factory can access the profile context - profileCtx := factory.ProfileContext() - if profileCtx == nil { - t.Fatal("ProfileContext is nil") - } - - // Verify profile ID matches - profile := profileCtx.Profile() - if profile == nil { - t.Fatal("Profile is nil") - } - - if profile.ID != tt.profileID { - t.Errorf("Profile ID = %q, want %q", profile.ID, tt.profileID) - } - }) - } -} - -// TestComponentFactory_ProfileTheming tests profile theme integration (T068). -// Tests that ComponentFactory correctly applies profile theme colors to components. -// Coverage: Profile → ProfileContext → Theme → Component → Rendered Colors -func TestComponentFactory_ProfileTheming(t *testing.T) { - tests := []struct { - name string - profileName string - expectedThemeName string - expectedPrimary string - expectedInfo string - }{ - { - name: "saiyan_profile_uses_fire_theme", - profileName: "saiyan", - expectedThemeName: "fire", - expectedPrimary: "#FF6600", - expectedInfo: "#FFE600", - }, - { - name: "enterprise_profile_uses_cyan_purple", - profileName: "enterprise", - expectedThemeName: "cyan-purple", - expectedPrimary: "#00ADD8", - expectedInfo: "#BD93F9", - }, - { - name: "nil_profile_fallback_to_enterprise", - profileName: "", - expectedThemeName: "cyan-purple", - expectedPrimary: "#00ADD8", - expectedInfo: "#BD93F9", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create ProfileContext - var profileCtx *profiles.ProfileContext - if tt.profileName != "" { - profileCtx = createTestProfileContext(t, tt.profileName) - if profileCtx == nil { - t.Fatal("Failed to create ProfileContext") - } - } - // If profileName is empty, profileCtx stays nil (fallback test) - - // Create ComponentFactory with BorderTierBlock for visible borders - factory := NewComponentFactory(profileCtx, components.BorderTierBlock) - - // Verify ProfileContext is properly wired - if factory.ProfileContext() == nil { - t.Fatal("Factory ProfileContext is nil") - } - - // Verify theme is loaded correctly - theme := factory.Theme() - if theme == nil { - t.Fatal("Theme is nil - profile theme integration failed") - } - - // Verify theme name matches expected profile theme - if theme.Name != tt.expectedThemeName { - t.Errorf("Theme name = %q, want %q", theme.Name, tt.expectedThemeName) - } - - // Verify theme colors are loaded correctly - colors := factory.ProfileContext().ThemeColors() - if colors == nil { - t.Fatal("ThemeColors() returned nil") - } - - // Verify primary color matches expected - primaryColor := colors.Primary - if !strings.EqualFold(primaryColor, tt.expectedPrimary) { - t.Errorf("Primary color = %q, want %q", primaryColor, tt.expectedPrimary) - } - - // Verify info color matches expected - infoColor := colors.Info - if !strings.EqualFold(infoColor, tt.expectedInfo) { - t.Errorf("Info color = %q, want %q", infoColor, tt.expectedInfo) - } - - // Verify that components are rendered with themed styles - // Test Card component - card := factory.Card("Test Title", "Test Content") - if card == "" { - t.Error("Card rendered empty string") - } - if !strings.Contains(card, "Test Title") { - t.Error("Card missing title content") - } - if !strings.Contains(card, "Test Content") { - t.Error("Card missing body content") - } - - // Test Toast component with different severities - errorToast := factory.Toast("Error message", SeverityError) - if errorToast == "" { - t.Error("Error toast rendered empty string") - } - if !strings.Contains(errorToast, "Error message") { - t.Error("Toast missing message content") - } - - // Test SectionHeader component - header := factory.SectionHeader("📋", "Section") - if header == "" { - t.Error("SectionHeader rendered empty string") - } - if !strings.Contains(header, "Section") { - t.Error("SectionHeader missing title") - } - - // Verify profile name for non-nil profiles - if tt.profileName != "" { - profile := factory.ProfileContext().Profile() - if profile == nil { - t.Fatal("Profile is nil") - } - if profile.ID != tt.profileName { - t.Errorf("Profile ID = %q, want %q", profile.ID, tt.profileName) - } - if profile.ThemeID != tt.expectedThemeName { - t.Errorf("Profile ThemeID = %q, want %q", profile.ThemeID, tt.expectedThemeName) - } - } - }) - } -} - -// Helper function to create a test ProfileContext. -func createTestProfileContext(t *testing.T, profileID string) *profiles.ProfileContext { - t.Helper() - - // Try to load the actual profile - repo, err := profiles.NewRepository() - if err != nil { - t.Logf("Warning: Could not create repository: %v, using fallback", err) - return profiles.GetDefaultProfileContext() - } - - profile, err := repo.GetByID(profileID) - if err != nil { - t.Logf("Warning: Could not load profile %s: %v, using fallback", profileID, err) - return profiles.GetDefaultProfileContext() - } - - // Try to load theme - var theme *themes.Theme - if profile.ThemeID != "" { - loader := themes.NewLoader() - theme, err = loader.Load(profile.ThemeID) - if err != nil { - t.Logf("Warning: Could not load theme %s: %v", profile.ThemeID, err) - } - } - - // Create context - ctx, err := profiles.NewProfileContext(profile, theme) - if err != nil { - t.Fatalf("Failed to create ProfileContext: %v", err) - } - - return ctx -} diff --git a/pkg/ui/layout/layout.go b/pkg/ui/layout/layout.go deleted file mode 100644 index 8b61753..0000000 --- a/pkg/ui/layout/layout.go +++ /dev/null @@ -1,573 +0,0 @@ -// Package layout provides a flexible layout system for terminal UI components. -package layout - -import ( - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" - - "github.com/arc-framework/arc-cli/pkg/ui/styles" -) - -// JoinVertical joins strings vertically with consistent alignment. -func JoinVertical(align lipgloss.Position, strs ...string) string { - return lipgloss.JoinVertical(align, strs...) -} - -// JoinHorizontal joins strings horizontally with consistent alignment. -func JoinHorizontal(align lipgloss.Position, strs ...string) string { - return lipgloss.JoinHorizontal(align, strs...) -} - -// Place places a string within a given width and height with alignment. -func Place(width, height int, hAlign, vAlign lipgloss.Position, str string) string { - return lipgloss.Place(width, height, hAlign, vAlign, str) -} - -// Center centers a string within the given width. -func Center(width int, str string) string { - return Place(width, 1, lipgloss.Center, lipgloss.Top, str) -} - -// Truncate truncates a string to the given width with ellipsis. -func Truncate(str string, width int) string { - if width <= 0 { - return "" - } - - if lipgloss.Width(str) <= width { - return str - } - - if width <= 3 { - return strings.Repeat(".", width) - } - - return ansi.Truncate(str, width-3, "") + "..." -} - -// AdaptToWidth adjusts content to fit within the given width -func AdaptToWidth(content string, width int) string { - lines := strings.Split(content, "\n") - adapted := make([]string, 0, len(lines)) - - for _, line := range lines { - if lipgloss.Width(line) <= width { - adapted = append(adapted, line) - } else { - // Wrap long lines - wrapped := WrapText(line, width) - adapted = append(adapted, wrapped...) - } - } - - return strings.Join(adapted, "\n") -} - -// WrapText wraps text to fit within the given width -func WrapText(text string, width int) []string { - if width <= 0 { - return []string{text} - } - - words := strings.Fields(text) - if len(words) == 0 { - return []string{""} - } - - var lines []string - var currentLine strings.Builder - - for i, word := range words { - // If this is the first word on the line - if currentLine.Len() == 0 { - currentLine.WriteString(word) - } else { - // Check if adding this word would exceed width - testLine := currentLine.String() + " " + word - if lipgloss.Width(testLine) <= width { - currentLine.WriteString(" ") - currentLine.WriteString(word) - } else { - // Start a new line - lines = append(lines, currentLine.String()) - currentLine.Reset() - currentLine.WriteString(word) - } - } - - // If this is the last word, add the current line - if i == len(words)-1 { - lines = append(lines, currentLine.String()) - } - } - - return lines -} - -// FitToTerminal adjusts width to fit within terminal constraints -func FitToTerminal(preferredWidth, terminalWidth, minWidth int) int { - if terminalWidth <= 0 { - return preferredWidth - } - - if terminalWidth < minWidth { - return minWidth - } - - if preferredWidth > terminalWidth { - return terminalWidth - } - - return preferredWidth -} - -// ResponsiveWidth calculates the best width for content based on terminal size -func ResponsiveWidth(terminalWidth int) int { - const ( - minWidth = 80 - maxWidth = 120 - defaultWidth = 80 - ) - - if terminalWidth <= 0 { - return defaultWidth - } - - if terminalWidth < minWidth { - return minWidth - } - - if terminalWidth > maxWidth { - return maxWidth - } - - return terminalWidth - 4 // Leave some margin -} - -// Pad adds padding to all sides of a string. -func Pad(str string, padding int) string { - if padding <= 0 { - return str - } - - style := lipgloss.NewStyle().Padding(padding) - return style.Render(str) -} - -// PadHorizontal adds horizontal padding to a string. -func PadHorizontal(str string, left, right int) string { - style := lipgloss.NewStyle().PaddingLeft(left).PaddingRight(right) - return style.Render(str) -} - -// PadVertical adds vertical padding to a string. -func PadVertical(str string, top, bottom int) string { - style := lipgloss.NewStyle().PaddingTop(top).PaddingBottom(bottom) - return style.Render(str) -} - -// Config holds global configuration for all components -type Config struct { - TitleStyle lipgloss.Style - HeadingStyle lipgloss.Style - SubheadingStyle lipgloss.Style - CodeStyle lipgloss.Style - DescStyle lipgloss.Style - CmdStyle lipgloss.Style - Width int - PaddingLeft int - PaddingRight int - ShowEmoji bool - ColorEnabled bool -} - -// DefaultConfig returns the default configuration -func DefaultConfig() Config { - codeStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#00FF00")). - Background(lipgloss.Color("#1a1a1a")). - Padding(0, 1) - - return Config{ - Width: 80, - PaddingLeft: 2, - PaddingRight: 2, - ShowEmoji: true, - ColorEnabled: !styles.NoColor, - TitleStyle: styles.PrimaryStyle.Bold(true).Underline(true), - HeadingStyle: styles.PrimaryStyle.Bold(true), - SubheadingStyle: styles.InfoStyle.Bold(true), - CodeStyle: codeStyle, - DescStyle: styles.SecondaryStyle, - CmdStyle: styles.InfoStyle, - } -} - -// Component represents any UI component -type Component interface { - Render(config *Config) string -} - -// Title component - Main title with emoji -type Title struct { - Text string - Emoji string -} - -func (t Title) Render(config *Config) string { - var result strings.Builder - if config.ShowEmoji && t.Emoji != "" { - result.WriteString(t.Emoji + " ") - } - if config.ColorEnabled { - result.WriteString(config.TitleStyle.Render(t.Text)) - } else { - result.WriteString(t.Text) - } - result.WriteString("\n") - return result.String() -} - -// Heading component - Section heading -type Heading struct { - Text string - ShowBorder bool -} - -func (h Heading) Render(config *Config) string { - var result strings.Builder - text := h.Text - if config.ColorEnabled { - text = config.HeadingStyle.Render(h.Text) - } - result.WriteString(text) - result.WriteString("\n") - if h.ShowBorder { - borderLen := lipgloss.Width(h.Text) - result.WriteString(strings.Repeat("─", borderLen)) - result.WriteString("\n") - } - return result.String() -} - -// Subheading component - Subsection heading -type Subheading struct { - Text string - Emoji string -} - -func (s Subheading) Render(config *Config) string { - var result strings.Builder - if config.ShowEmoji && s.Emoji != "" { - result.WriteString(s.Emoji + " ") - } - text := s.Text - if config.ColorEnabled { - text = config.SubheadingStyle.Render(s.Text) - } - result.WriteString(text) - result.WriteString("\n") - return result.String() -} - -// Description component - Text description -type Description struct { - Text string - Indent int -} - -func (d Description) Render(config *Config) string { - indent := strings.Repeat(" ", d.Indent) - text := d.Text - if config.ColorEnabled { - text = config.DescStyle.Render(d.Text) - } - return indent + text + "\n" -} - -// Command component - CLI command display -type Command struct { - Name string - Description string - Emoji string -} - -func (c Command) Render(config *Config) string { - var result strings.Builder - if config.ShowEmoji && c.Emoji != "" { - result.WriteString(c.Emoji + " ") - } else { - result.WriteString(" ") - } - cmdText := c.Name - if config.ColorEnabled { - cmdText = config.CmdStyle.Render(c.Name) - } - result.WriteString(cmdText) - if c.Description != "" { - padding := strings.Repeat(" ", max(1, 30-lipgloss.Width(c.Name))) - descText := c.Description - if config.ColorEnabled { - descText = config.DescStyle.Render(c.Description) - } - result.WriteString(padding + descText) - } - result.WriteString("\n") - return result.String() -} - -// Code component - Code snippet display -type Code struct { - Content string - Lang string - Indent int -} - -func (c Code) Render(config *Config) string { - indent := strings.Repeat(" ", c.Indent) - content := c.Content - if config.ColorEnabled { - content = config.CodeStyle.Render(c.Content) - } - return indent + content + "\n" -} - -// Usage component - Usage example -type Usage struct { - Command string - Args string -} - -func (u Usage) Render(config *Config) string { - var result strings.Builder - result.WriteString("Usage: ") - if config.ColorEnabled { - result.WriteString(config.CmdStyle.Render(u.Command)) - if u.Args != "" { - result.WriteString(" ") - result.WriteString(config.DescStyle.Render(u.Args)) - } - } else { - result.WriteString(u.Command) - if u.Args != "" { - result.WriteString(" " + u.Args) - } - } - result.WriteString("\n") - return result.String() -} - -// Example component - Example with optional description -type Example struct { - Command string - Description string -} - -func (e Example) Render(config *Config) string { - var result strings.Builder - result.WriteString(" $ ") - if config.ColorEnabled { - result.WriteString(config.CodeStyle.Render(e.Command)) - } else { - result.WriteString(e.Command) - } - result.WriteString("\n") - if e.Description != "" { - descText := " " + e.Description - if config.ColorEnabled { - descText = " " + config.DescStyle.Italic(true).Render(e.Description) - } - result.WriteString(descText) - result.WriteString("\n") - } - return result.String() -} - -// List component - Bulleted list -type List struct { - Emoji string - Items []ListItem -} - -type ListItem struct { - Text string - Sub []string -} - -func (l *List) Render(config *Config) string { - var result strings.Builder - emoji := l.Emoji - if !config.ShowEmoji { - emoji = "•" - } - for _, item := range l.Items { - result.WriteString(emoji + " " + item.Text + "\n") - for _, sub := range item.Sub { - result.WriteString(" " + emoji + " " + sub + "\n") - } - } - return result.String() -} - -// Box component - Bordered box -type Box struct { - Style lipgloss.Style - Content []Component - Title string -} - -func (b *Box) Render(config *Config) string { - if !config.ColorEnabled { - return b.renderPlain(config) - } - boxStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#00ADD8")). - Padding(1, 2) - var content strings.Builder - if b.Title != "" { - content.WriteString(config.TitleStyle.Render(b.Title)) - content.WriteString("\n\n") - } - for _, comp := range b.Content { - content.WriteString(comp.Render(config)) - } - return boxStyle.Render(content.String()) -} - -func (b *Box) renderPlain(config *Config) string { - var result strings.Builder - width := config.Width - 4 - result.WriteString("+" + strings.Repeat("-", width) + "+\n") - if b.Title != "" { - titleWidth := lipgloss.Width(b.Title) - result.WriteString("| " + b.Title + strings.Repeat(" ", width-titleWidth-1) + "|\n") - result.WriteString("+" + strings.Repeat("-", width) + "+\n") - } - for _, comp := range b.Content { - lines := strings.Split(comp.Render(config), "\n") - for _, line := range lines { - if line != "" { - // Use lipgloss.Width() to handle ANSI escape codes correctly - // Bug fix: 016-ui-layout-fix Phase 2 (T017a) - lineWidth := lipgloss.Width(line) - padding := strings.Repeat(" ", width-lineWidth-1) - result.WriteString("| " + line + padding + " |\n") - } - } - } - result.WriteString("+" + strings.Repeat("-", width) + "+\n") - return result.String() -} - -// Section component - Logical section grouping -type Section struct { - Heading Component - Components []Component - Spacing int -} - -func (s *Section) Render(config *Config) string { - var result strings.Builder - if s.Heading != nil { - result.WriteString(s.Heading.Render(config)) - result.WriteString("\n") - } - for i, comp := range s.Components { - result.WriteString(comp.Render(config)) - if i < len(s.Components)-1 && s.Spacing > 0 { - result.WriteString(strings.Repeat("\n", s.Spacing)) - } - } - return result.String() -} - -// Layout - Container for all sections -type Layout struct { - Sections []Section - Config Config -} - -// NewLayout creates a new layout with default config -func NewLayout() *Layout { - return &Layout{ - Config: DefaultConfig(), - Sections: []Section{}, - } -} - -// NewLayoutWithConfig creates a layout with custom config -func NewLayoutWithConfig(config *Config) *Layout { - return &Layout{ - Config: *config, - Sections: []Section{}, - } -} - -// AddSection adds a section to the layout -func (l *Layout) AddSection(section Section) { - l.Sections = append(l.Sections, section) -} - -// Render renders the complete layout -func (l *Layout) Render() string { - var result strings.Builder - for i, section := range l.Sections { - result.WriteString(section.Render(&l.Config)) - if i < len(l.Sections)-1 { - result.WriteString("\n\n") - } - } - return result.String() -} - -// NewTitle creates a new Title component. -func NewTitle(text, emoji string) Title { - return Title{Text: text, Emoji: emoji} -} - -func NewHeading(text string, showBorder bool) Heading { - return Heading{Text: text, ShowBorder: showBorder} -} - -func NewSubheading(text, emoji string) Subheading { - return Subheading{Text: text, Emoji: emoji} -} - -func NewDescription(text string, indent int) Description { - return Description{Text: text, Indent: indent} -} - -func NewCommand(name, description, emoji string) Command { - return Command{Name: name, Description: description, Emoji: emoji} -} - -func NewCode(content string, indent int) Code { - return Code{Content: content, Indent: indent} -} - -func NewUsage(command, args string) Usage { - return Usage{Command: command, Args: args} -} - -func NewExample(command, description string) Example { - return Example{Command: command, Description: description} -} - -func NewList(emoji string, items ...string) List { - listItems := make([]ListItem, len(items)) - for i, item := range items { - listItems[i] = ListItem{Text: item} - } - return List{Items: listItems, Emoji: emoji} -} - -func NewSection(heading Component, spacing int, components ...Component) Section { - return Section{ - Heading: heading, - Components: components, - Spacing: spacing, - } -} diff --git a/pkg/ui/layout/layout_test.go b/pkg/ui/layout/layout_test.go deleted file mode 100644 index c7ba847..0000000 --- a/pkg/ui/layout/layout_test.go +++ /dev/null @@ -1,691 +0,0 @@ -package layout - -import ( - "strings" - "testing" - - "github.com/charmbracelet/lipgloss" -) - -func TestJoinVertical(t *testing.T) { - tests := []struct { - name string - align lipgloss.Position - strs []string - }{ - { - name: "left align", - align: lipgloss.Left, - strs: []string{"Line 1", "Line 2", "Line 3"}, - }, - { - name: "center align", - align: lipgloss.Center, - strs: []string{"Short", "Medium line", "Long line here"}, - }, - { - name: "right align", - align: lipgloss.Right, - strs: []string{"A", "BB", "CCC"}, - }, - { - name: "empty strings", - align: lipgloss.Left, - strs: []string{"", "", ""}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := JoinVertical(tt.align, tt.strs...) - if result == "" && len(tt.strs) > 0 { - t.Error("JoinVertical() returned empty string for non-empty input") - } - // Check that result contains all input strings - for _, str := range tt.strs { - if str != "" && !strings.Contains(result, str) { - t.Errorf("JoinVertical() result doesn't contain %q", str) - } - } - }) - } -} - -func TestJoinHorizontal(t *testing.T) { - tests := []struct { - name string - align lipgloss.Position - strs []string - }{ - { - name: "top align", - align: lipgloss.Top, - strs: []string{"Col1", "Col2", "Col3"}, - }, - { - name: "center align", - align: lipgloss.Center, - strs: []string{"A", "B", "C"}, - }, - { - name: "bottom align", - align: lipgloss.Bottom, - strs: []string{"1", "2", "3"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := JoinHorizontal(tt.align, tt.strs...) - if result == "" && len(tt.strs) > 0 { - t.Error("JoinHorizontal() returned empty string for non-empty input") - } - }) - } -} - -func TestPlace(t *testing.T) { - tests := []struct { - name string - width int - height int - hAlign lipgloss.Position - vAlign lipgloss.Position - str string - }{ - { - name: "center center", - width: 20, - height: 5, - hAlign: lipgloss.Center, - vAlign: lipgloss.Center, - str: "Test", - }, - { - name: "top left", - width: 10, - height: 3, - hAlign: lipgloss.Left, - vAlign: lipgloss.Top, - str: "TL", - }, - { - name: "bottom right", - width: 15, - height: 4, - hAlign: lipgloss.Right, - vAlign: lipgloss.Bottom, - str: "BR", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := Place(tt.width, tt.height, tt.hAlign, tt.vAlign, tt.str) - if result == "" { - t.Error("Place() returned empty string") - } - }) - } -} - -func TestCenter(t *testing.T) { - tests := []struct { - name string - width int - str string - }{ - {"short string", 20, "Hi"}, - {"exact width", 5, "Hello"}, - {"long string", 3, "Longer"}, - {"empty string", 10, ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := Center(tt.width, tt.str) - if result == "" && tt.str != "" { - t.Error("Center() returned empty for non-empty input") - } - }) - } -} - -func TestTruncate(t *testing.T) { - tests := []struct { - name string - str string - width int - want string - }{ - { - name: "no truncation needed", - str: "Hello", - width: 10, - want: "Hello", - }, - { - name: "exact width", - str: "Hello", - width: 5, - want: "Hello", - }, - { - name: "truncate with ellipsis", - str: "Hello World", - width: 8, - want: "Hello...", - }, - { - name: "very narrow width", - str: "Hello", - width: 3, - want: "...", - }, - { - name: "width of 2", - str: "Hello", - width: 2, - want: "..", - }, - { - name: "width of 1", - str: "Hello", - width: 1, - want: ".", - }, - { - name: "zero width", - str: "Hello", - width: 0, - want: "", - }, - { - name: "negative width", - str: "Hello", - width: -1, - want: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := Truncate(tt.str, tt.width) - if got != tt.want { - t.Errorf("Truncate(%q, %d) = %q, want %q", tt.str, tt.width, got, tt.want) - } - if tt.width > 0 && len(got) > tt.width { - t.Errorf("Truncate(%q, %d) length = %d, exceeds width", tt.str, tt.width, len(got)) - } - }) - } -} - -func TestAdaptToWidth(t *testing.T) { - tests := []struct { - name string - content string - width int - }{ - { - name: "single line fits", - content: "Hello World", - width: 20, - }, - { - name: "multi-line content", - content: "Line 1\nLine 2\nLine 3", - width: 15, - }, - { - name: "long line needs wrapping", - content: "This is a very long line that needs to be wrapped", - width: 20, - }, - { - name: "empty content", - content: "", - width: 10, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := AdaptToWidth(tt.content, tt.width) - - // Check that no line exceeds width - lines := strings.Split(result, "\n") - for i, line := range lines { - if len(line) > tt.width { - t.Errorf("Line %d length = %d, exceeds width %d: %q", i, len(line), tt.width, line) - } - } - }) - } -} - -func TestWrapText(t *testing.T) { - tests := []struct { - name string - text string - width int - want int // expected number of lines - }{ - { - name: "no wrapping needed", - text: "Hello", - width: 10, - want: 1, - }, - { - name: "wrap single long line", - text: "This is a long line that needs wrapping", - width: 15, - want: 3, // Approximate - }, - { - name: "empty text", - text: "", - width: 10, - want: 1, - }, - { - name: "zero width", - text: "Hello World", - width: 0, - want: 1, - }, - { - name: "single word", - text: "Hello", - width: 3, - want: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - lines := WrapText(tt.text, tt.width) - if len(lines) == 0 { - t.Error("WrapText() returned empty slice") - } - - // Check that no line exceeds width (unless single word is longer) - for i, line := range lines { - words := strings.Fields(line) - if len(words) > 1 && len(line) > tt.width && tt.width > 0 { - t.Errorf("Line %d length = %d, exceeds width %d: %q", i, len(line), tt.width, line) - } - } - }) - } -} - -func TestLayoutComposition(t *testing.T) { - // Test combining multiple layout functions - header := Center(40, "Header") - body := "Body content here" - footer := Center(40, "Footer") - - result := JoinVertical(lipgloss.Left, header, body, footer) - - if result == "" { - t.Error("Layout composition returned empty string") - } - if !strings.Contains(result, "Header") { - t.Error("Composed layout doesn't contain header") - } - if !strings.Contains(result, "Body content") { - t.Error("Composed layout doesn't contain body") - } - if !strings.Contains(result, "Footer") { - t.Error("Composed layout doesn't contain footer") - } -} - -func TestEdgeCases_EmptyInput(t *testing.T) { - // Test all functions with empty input - t.Run("JoinVertical empty", func(t *testing.T) { - result := JoinVertical(lipgloss.Left) - if result != "" { - t.Error("JoinVertical() with no args should return empty") - } - }) - - t.Run("JoinHorizontal empty", func(t *testing.T) { - result := JoinHorizontal(lipgloss.Left) - if result != "" { - t.Error("JoinHorizontal() with no args should return empty") - } - }) - - t.Run("Truncate empty", func(t *testing.T) { - result := Truncate("", 10) - if result != "" { - t.Error("Truncate('', 10) should return empty") - } - }) - - t.Run("WrapText empty", func(t *testing.T) { - lines := WrapText("", 10) - if len(lines) != 1 || lines[0] != "" { - t.Error("WrapText('', 10) should return single empty line") - } - }) -} - -// ANSI-styled test strings for comprehensive width calculation testing -var ( - styledText = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")).Render("Hello") - styledBoldText = lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF00")).Bold(true).Render("World") - styledUnderline = lipgloss.NewStyle().Underline(true).Render("Test") - plainText = "Plain" - longStyledText = lipgloss.NewStyle().Foreground(lipgloss.Color("#0000FF")).Render("This is a very long styled text string") - mixedStyledText = styledText + " " + plainText + " " + styledBoldText -) - -func TestTruncate_WithANSI(t *testing.T) { - tests := []struct { - name string - input string - width int - checkWidth bool - checkEllipsis bool - }{ - { - name: "styled text shorter than width", - input: styledText, - width: 10, - checkWidth: true, - }, - { - name: "styled text longer than width", - input: longStyledText, - width: 15, - checkWidth: true, - checkEllipsis: true, - }, - { - name: "mixed styled text", - input: mixedStyledText, - width: 20, - checkWidth: true, - }, - { - name: "bold styled text truncated", - input: styledBoldText + " " + styledBoldText + " " + styledBoldText, - width: 10, - checkWidth: true, - checkEllipsis: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := Truncate(tt.input, tt.width) - resultWidth := lipgloss.Width(result) - - // Verify the result doesn't exceed the width - if tt.checkWidth && resultWidth > tt.width { - t.Errorf("Truncate() visual width = %d, want <= %d", resultWidth, tt.width) - } - - // For truncated text, verify ellipsis present - if tt.checkEllipsis && lipgloss.Width(tt.input) > tt.width { - if !strings.HasSuffix(result, "...") { - t.Errorf("Truncate() should add ellipsis for truncated text") - } - } - }) - } -} - -func TestAdaptToWidth_WithANSI(t *testing.T) { - tests := []struct { - name string - content string - width int - }{ - { - name: "styled text single line", - content: styledText + " " + styledBoldText, - width: 20, - }, - { - name: "styled multiline content", - content: styledText + "\n" + styledBoldText + "\n" + styledUnderline, - width: 10, - }, - { - name: "long styled text wraps", - content: longStyledText, - width: 15, - }, - { - name: "mixed styled and plain", - content: mixedStyledText, - width: 12, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := AdaptToWidth(tt.content, tt.width) - lines := strings.Split(result, "\n") - - // Verify no line exceeds the visual width - for i, line := range lines { - lineWidth := lipgloss.Width(line) - if lineWidth > tt.width { - t.Errorf("Line %d visual width = %d, exceeds width %d", i, lineWidth, tt.width) - } - } - }) - } -} - -func TestWrapText_WithANSI(t *testing.T) { - tests := []struct { - name string - text string - width int - }{ - { - name: "styled text single line", - text: styledText + " " + styledBoldText, - width: 20, - }, - { - name: "styled text wraps", - text: longStyledText, - width: 15, - }, - { - name: "bold text wraps", - text: styledBoldText + " " + styledBoldText + " " + styledBoldText + " " + styledBoldText, - width: 10, - }, - { - name: "mixed styled wraps", - text: mixedStyledText + " " + mixedStyledText, - width: 15, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - lines := WrapText(tt.text, tt.width) - - // Verify each line respects visual width - for i, line := range lines { - lineWidth := lipgloss.Width(line) - words := strings.Fields(line) - // Allow single words to exceed width, but multi-word lines should not - if len(words) > 1 && lineWidth > tt.width { - t.Errorf("Line %d visual width = %d, exceeds width %d", i, lineWidth, tt.width) - } - } - }) - } -} - -func TestHeadingRender_WithANSI(t *testing.T) { - config := DefaultConfig() - - tests := []struct { - name string - heading Heading - }{ - { - name: "styled text heading with border", - heading: Heading{ - Text: styledText, - ShowBorder: true, - }, - }, - { - name: "bold styled heading with border", - heading: Heading{ - Text: styledBoldText, - ShowBorder: true, - }, - }, - { - name: "mixed styled heading with border", - heading: Heading{ - Text: mixedStyledText, - ShowBorder: true, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.heading.Render(&config) - lines := strings.Split(strings.TrimSpace(result), "\n") - - if tt.heading.ShowBorder && len(lines) >= 2 { - // Border should match the visual width of the text - borderLine := strings.TrimSpace(lines[1]) - borderWidth := lipgloss.Width(borderLine) - textWidth := lipgloss.Width(tt.heading.Text) - - if borderWidth != textWidth { - t.Errorf("Border width = %d, text width = %d, should be equal", borderWidth, textWidth) - } - } - }) - } -} - -func TestCommandRender_WithANSI(t *testing.T) { - config := DefaultConfig() - - tests := []struct { - name string - command Command - }{ - { - name: "styled command name", - command: Command{ - Name: styledText, - Description: "Test description", - Emoji: "", - }, - }, - { - name: "bold styled command name", - command: Command{ - Name: styledBoldText, - Description: "Test description", - Emoji: "✓", - }, - }, - { - name: "mixed styled command", - command: Command{ - Name: mixedStyledText, - Description: "Test description", - Emoji: "", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.command.Render(&config) - - // Should render without panicking - if result == "" { - t.Error("Command.Render() returned empty string") - } - - // Should contain command name (check for plain text portion) - if !strings.Contains(result, "Test description") { - t.Error("Command.Render() should contain description") - } - }) - } -} - -func TestBoxRenderPlain_WithANSI(t *testing.T) { - config := DefaultConfig() - config.ColorEnabled = false - config.Width = 80 - - tests := []struct { - name string - box Box - }{ - { - name: "box with styled title", - box: Box{ - Title: styledText, - Content: []Component{ - Description{Text: "Test content", Indent: 0}, - }, - }, - }, - { - name: "box with bold styled title", - box: Box{ - Title: styledBoldText, - Content: []Component{ - Description{Text: "Test content", Indent: 0}, - }, - }, - }, - { - name: "box with mixed styled title", - box: Box{ - Title: mixedStyledText, - Content: []Component{ - Description{Text: "Test content", Indent: 0}, - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.box.Render(&config) - - // Should have borders - if !strings.Contains(result, "+") { - t.Error("Box should have borders") - } - - // Check that title line has proper padding - lines := strings.Split(result, "\n") - for _, line := range lines { - if strings.HasPrefix(line, "|") && strings.Contains(line, "Test content") { - // Basic validation: line should have consistent width - lineWidth := lipgloss.Width(line) - if lineWidth == 0 { - t.Error("Box line should have non-zero width") - } - } - } - }) - } -} diff --git a/pkg/ui/layout/terminal.go b/pkg/ui/layout/terminal.go deleted file mode 100644 index 711067f..0000000 --- a/pkg/ui/layout/terminal.go +++ /dev/null @@ -1,111 +0,0 @@ -// Package layout provides terminal layout and constraint utilities. -package layout - -import ( - "github.com/arc-framework/arc-cli/internal/terminal" -) - -// LayoutConstraints defines constraints for responsive layout rendering. -type LayoutConstraints struct { - // TerminalWidth is the detected terminal width. - TerminalWidth int - - // TerminalHeight is the detected terminal height. - TerminalHeight int - - // MinWidth is the minimum supported width (default: 80). - MinWidth int - - // MaxWidth is the maximum render width (default: 120). - MaxWidth int - - // MarginLeft is the left margin in columns. - MarginLeft int - - // MarginRight is the right margin in columns. - MarginRight int - - // Padding is the internal padding. - Padding int - - // ColumnWidths specifies explicit column widths (-1 for auto). - ColumnWidths []int - - // ColumnGap is the space between columns. - ColumnGap int - - // Truncate enables truncation for long lines. - Truncate bool - - // ShowScrollHint shows "..." for truncated content. - ShowScrollHint bool -} - -// NewConstraints creates layout constraints from terminal capabilities. -func NewConstraints(caps terminal.Capabilities) LayoutConstraints { - constraints := LayoutConstraints{ - TerminalWidth: caps.Width, - TerminalHeight: caps.Height, - MinWidth: 80, - MaxWidth: 120, - MarginLeft: 2, - MarginRight: 2, - Padding: 1, - ColumnGap: 2, - Truncate: false, - ShowScrollHint: false, - } - - // Adjust for narrow terminals - constraints.AdjustForWidth(caps.Width) - - return constraints -} - -// AdjustForWidth adapts layout constraints based on terminal width. -func (c *LayoutConstraints) AdjustForWidth(width int) { - switch { - case width < 80: - // Very narrow terminal - c.Truncate = true - c.ShowScrollHint = true - c.ColumnGap = 1 - c.MarginLeft = 0 - c.MarginRight = 0 - c.Padding = 0 - - case width < 120: - // Standard terminal - c.ColumnGap = 2 - c.MarginLeft = 1 - c.MarginRight = 1 - c.Padding = 1 - - default: - // Wide terminal - c.ColumnGap = 4 - c.MarginLeft = 2 - c.MarginRight = 2 - c.Padding = 1 - } -} - -// ContentWidth returns the available width for content after margins. -func (c *LayoutConstraints) ContentWidth() int { - return c.TerminalWidth - c.MarginLeft - c.MarginRight -} - -// IsNarrow returns true if the terminal is considered narrow. -func (c *LayoutConstraints) IsNarrow() bool { - return c.TerminalWidth < c.MinWidth -} - -// IsWide returns true if the terminal is considered wide. -func (c *LayoutConstraints) IsWide() bool { - return c.TerminalWidth >= c.MaxWidth -} - -// ShouldTruncate returns true if content should be truncated. -func (c *LayoutConstraints) ShouldTruncate() bool { - return c.Truncate || c.IsNarrow() -} diff --git a/pkg/ui/layout/terminal_test.go b/pkg/ui/layout/terminal_test.go deleted file mode 100644 index e7eda74..0000000 --- a/pkg/ui/layout/terminal_test.go +++ /dev/null @@ -1,291 +0,0 @@ -package layout - -import ( - "testing" - - "github.com/arc-framework/arc-cli/internal/terminal" -) - -func TestNewConstraints(t *testing.T) { - caps := terminal.Capabilities{ - Width: 100, - Height: 30, - IsTTY: true, - } - - constraints := NewConstraints(caps) - - if constraints.TerminalWidth != caps.Width { - t.Errorf("TerminalWidth = %d, want %d", constraints.TerminalWidth, caps.Width) - } - if constraints.TerminalHeight != caps.Height { - t.Errorf("TerminalHeight = %d, want %d", constraints.TerminalHeight, caps.Height) - } - if constraints.MinWidth != 80 { - t.Errorf("MinWidth = %d, want 80", constraints.MinWidth) - } -} - -func TestConstraints_AdjustForWidth(t *testing.T) { - tests := []struct { - name string - width int - wantTruncate bool - wantMarginLeft int - }{ - { - name: "very narrow terminal", - width: 60, - wantTruncate: true, - wantMarginLeft: 0, - }, - { - name: "standard terminal", - width: 100, - wantTruncate: false, - wantMarginLeft: 1, - }, - { - name: "wide terminal", - width: 150, - wantTruncate: false, - wantMarginLeft: 2, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - constraints := LayoutConstraints{ - TerminalWidth: tt.width, - MinWidth: 80, - } - constraints.AdjustForWidth(tt.width) - - if constraints.Truncate != tt.wantTruncate { - t.Errorf("Truncate = %v, want %v", constraints.Truncate, tt.wantTruncate) - } - if constraints.MarginLeft != tt.wantMarginLeft { - t.Errorf("MarginLeft = %d, want %d", constraints.MarginLeft, tt.wantMarginLeft) - } - }) - } -} - -func TestConstraints_ContentWidth(t *testing.T) { - tests := []struct { - name string - termWidth int - marginLeft int - marginRight int - want int - }{ - { - name: "standard margins", - termWidth: 100, - marginLeft: 2, - marginRight: 2, - want: 96, - }, - { - name: "no margins", - termWidth: 80, - marginLeft: 0, - marginRight: 0, - want: 80, - }, - { - name: "large margins", - termWidth: 120, - marginLeft: 10, - marginRight: 10, - want: 100, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - constraints := LayoutConstraints{ - TerminalWidth: tt.termWidth, - MarginLeft: tt.marginLeft, - MarginRight: tt.marginRight, - } - got := constraints.ContentWidth() - if got != tt.want { - t.Errorf("ContentWidth() = %d, want %d", got, tt.want) - } - }) - } -} - -func TestConstraints_IsNarrow(t *testing.T) { - tests := []struct { - name string - termWidth int - minWidth int - want bool - }{ - { - name: "narrow terminal", - termWidth: 70, - minWidth: 80, - want: true, - }, - { - name: "at minimum", - termWidth: 80, - minWidth: 80, - want: false, - }, - { - name: "wide terminal", - termWidth: 120, - minWidth: 80, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - constraints := LayoutConstraints{ - TerminalWidth: tt.termWidth, - MinWidth: tt.minWidth, - } - got := constraints.IsNarrow() - if got != tt.want { - t.Errorf("IsNarrow() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestConstraints_ResponsiveAdjustments(t *testing.T) { - // Test that constraints properly adjust for different terminal sizes - widths := []int{60, 80, 100, 120, 160} - - for _, width := range widths { - t.Run(string(rune('A'+width/20)), func(t *testing.T) { - caps := terminal.Capabilities{ - Width: width, - Height: 30, - } - constraints := NewConstraints(caps) - - // Verify constraints are reasonable - if constraints.ContentWidth() <= 0 { - t.Errorf("ContentWidth() = %d, should be positive", constraints.ContentWidth()) - } - - // Very narrow terminals should enable truncation - if width < 80 && !constraints.Truncate { - t.Error("Truncate should be true for narrow terminals") - } - - // Wide terminals should have more spacing - if width >= 120 && constraints.ColumnGap < 2 { - t.Errorf("ColumnGap = %d, expected >= 2 for wide terminal", constraints.ColumnGap) - } - }) - } -} - -func TestConstraints_MinimumWidth(t *testing.T) { - // Test handling of minimum width - constraints := LayoutConstraints{ - TerminalWidth: 80, - MinWidth: 80, - MarginLeft: 2, - MarginRight: 2, - } - - if constraints.IsNarrow() { - t.Error("Terminal at minimum width should not be narrow") - } - - constraints.TerminalWidth = 79 - if !constraints.IsNarrow() { - t.Error("Terminal below minimum width should be narrow") - } -} - -func TestConstraints_ColumnGapAdaptation(t *testing.T) { - tests := []struct { - width int - wantGapMin int - wantGapMax int - }{ - {60, 1, 1}, // Very narrow - {100, 2, 2}, // Standard - {150, 4, 4}, // Wide - } - - for _, tt := range tests { - constraints := LayoutConstraints{ - TerminalWidth: tt.width, - } - constraints.AdjustForWidth(tt.width) - - if constraints.ColumnGap < tt.wantGapMin || constraints.ColumnGap > tt.wantGapMax { - t.Errorf("ColumnGap = %d, want between %d and %d for width %d", - constraints.ColumnGap, tt.wantGapMin, tt.wantGapMax, tt.width) - } - } -} - -func TestConstraints_ShowScrollHint(t *testing.T) { - // Narrow terminals should show scroll hint - narrow := LayoutConstraints{TerminalWidth: 70} - narrow.AdjustForWidth(70) - - if !narrow.ShowScrollHint { - t.Error("ShowScrollHint should be true for narrow terminal") - } - - // Wide terminals should not - wide := LayoutConstraints{TerminalWidth: 120} - wide.AdjustForWidth(120) - - if wide.ShowScrollHint { - t.Error("ShowScrollHint should be false for wide terminal") - } -} - -func TestConstraints_PaddingAdaptation(t *testing.T) { - // Test that padding is adjusted based on width - tests := []struct { - width int - wantPadding int - }{ - {60, 0}, // Very narrow - no padding - {100, 1}, // Standard - some padding - {150, 1}, // Wide - padding - } - - for _, tt := range tests { - constraints := LayoutConstraints{} - constraints.AdjustForWidth(tt.width) - - if constraints.Padding != tt.wantPadding { - t.Errorf("Padding = %d, want %d for width %d", - constraints.Padding, tt.wantPadding, tt.width) - } - } -} - -func TestConstraints_TerminalSizeChanges(t *testing.T) { - // Test that constraints can be updated when terminal is resized - constraints := LayoutConstraints{ - TerminalWidth: 100, - MinWidth: 80, - } - constraints.AdjustForWidth(100) - - initialMargin := constraints.MarginLeft - - // Simulate terminal resize - constraints.TerminalWidth = 60 - constraints.AdjustForWidth(60) - - if constraints.MarginLeft == initialMargin { - t.Error("MarginLeft should change after terminal resize") - } -} diff --git a/pkg/ui/layouts/.gitkeep b/pkg/ui/layouts/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/pkg/ui/legacy/README.md b/pkg/ui/legacy/README.md deleted file mode 100644 index 9c2bb0f..0000000 --- a/pkg/ui/legacy/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# pkg/ui/legacy — Deprecation Notice - -**Status: DEPRECATED** - -This directory marks the boundary between the legacy CLI rendering approach -and the new UI engine introduced in spec `017-ui-engine`. - -## What is "legacy UI"? - -The legacy UI refers to the original dashboard and command-specific rendering -code that lives primarily in `pkg/cli/dashboard/`. It uses direct Bubble Tea -programs without the structured View / ComponentFactory abstractions. - -## Migration path - -All new views must be implemented in `pkg/ui/views/` using the patterns -established by the `017-ui-engine` spec: - -| Legacy location | New location | -|------------------------------|-------------------------------------| -| `pkg/cli/dashboard/app.go` | `pkg/ui/views/dashboardview.go` | -| Command-specific `fmt.Print` | `pkg/ui/views/view.go` | - -## Opting back into legacy UI - -Set the environment variable `ARC_USE_LEGACY_UI=1` before running any command: - -```sh -ARC_USE_LEGACY_UI=1 arc services list -ARC_USE_LEGACY_UI=1 arc config list-profiles -ARC_USE_LEGACY_UI=1 arc config get-profile -ARC_USE_LEGACY_UI=1 arc config set-profile jedi -``` - -This is intended for debugging and backwards-compatibility testing only. -The legacy path will be removed in a future release. - -## Files with deprecation notices - -- `pkg/cli/dashboard/app.go` — top-level deprecation comment added diff --git a/pkg/ui/markdown/markdown.go b/pkg/ui/markdown/markdown.go deleted file mode 100644 index ca30fe3..0000000 --- a/pkg/ui/markdown/markdown.go +++ /dev/null @@ -1,42 +0,0 @@ -// Package markdown provides utilities for rendering markdown content in the terminal. -package markdown - -import ( - "github.com/charmbracelet/glamour" -) - -// Render renders markdown content with automatic styling based on terminal -func Render(content string) (string, error) { - r, err := glamour.NewTermRenderer( - glamour.WithAutoStyle(), - glamour.WithWordWrap(80), - ) - if err != nil { - return content, err - } - return r.Render(content) -} - -// RenderWithWidth renders markdown with a custom width -func RenderWithWidth(content string, width int) (string, error) { - r, err := glamour.NewTermRenderer( - glamour.WithAutoStyle(), - glamour.WithWordWrap(width), - ) - if err != nil { - return content, err - } - return r.Render(content) -} - -// RenderDark renders markdown with dark theme -func RenderDark(content string) (string, error) { - r, err := glamour.NewTermRenderer( - glamour.WithStylePath("dark"), - glamour.WithWordWrap(80), - ) - if err != nil { - return content, err - } - return r.Render(content) -} diff --git a/pkg/ui/markdown/markdown_test.go b/pkg/ui/markdown/markdown_test.go deleted file mode 100644 index 325cf47..0000000 --- a/pkg/ui/markdown/markdown_test.go +++ /dev/null @@ -1,324 +0,0 @@ -package markdown - -import ( - "strings" - "testing" -) - -func TestRender(t *testing.T) { - tests := []struct { - name string - content string - wantErr bool - }{ - { - name: "simple text", - content: "Hello World", - wantErr: false, - }, - { - name: "heading", - content: "# Heading 1\n## Heading 2", - wantErr: false, - }, - { - name: "list", - content: "- Item 1\n- Item 2\n- Item 3", - wantErr: false, - }, - { - name: "code block", - content: "```go\nfunc main() {}\n```", - wantErr: false, - }, - { - name: "links", - content: "[Link](https://example.com)", - wantErr: false, - }, - { - name: "emphasis", - content: "**bold** and *italic*", - wantErr: false, - }, - { - name: "empty content", - content: "", - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := Render(tt.content) - if (err != nil) != tt.wantErr { - t.Errorf("Render() error = %v, wantErr %v", err, tt.wantErr) - return - } - if got == "" && tt.content != "" { - t.Error("Render() returned empty string for non-empty content") - } - }) - } -} - -func TestRenderWithWidth(t *testing.T) { - tests := []struct { - name string - content string - width int - wantErr bool - }{ - { - name: "narrow width", - content: "This is a long line that needs to be wrapped to fit narrow terminal", - width: 40, - wantErr: false, - }, - { - name: "standard width", - content: "# Heading\n\nParagraph text here", - width: 80, - wantErr: false, - }, - { - name: "wide width", - content: "Short text", - width: 120, - wantErr: false, - }, - { - name: "very narrow width", - content: "Text", - width: 20, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := RenderWithWidth(tt.content, tt.width) - if (err != nil) != tt.wantErr { - t.Errorf("RenderWithWidth() error = %v, wantErr %v", err, tt.wantErr) - return - } - if got == "" && tt.content != "" { - t.Error("RenderWithWidth() returned empty string for non-empty content") - } - }) - } -} - -func TestRenderDark(t *testing.T) { - tests := []struct { - name string - content string - wantErr bool - }{ - { - name: "simple content", - content: "# Dark Theme\n\nContent here", - wantErr: false, - }, - { - name: "code block", - content: "```\ncode\n```", - wantErr: false, - }, - { - name: "empty content", - content: "", - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := RenderDark(tt.content) - if (err != nil) != tt.wantErr { - t.Errorf("RenderDark() error = %v, wantErr %v", err, tt.wantErr) - return - } - if got == "" && tt.content != "" { - t.Error("RenderDark() returned empty string for non-empty content") - } - }) - } -} - -func TestMarkdownFormatting(t *testing.T) { - // Test that various markdown elements are processed - content := ` -# Heading 1 -## Heading 2 - -This is **bold** and *italic* text. - -- List item 1 -- List item 2 - -[Link](https://example.com) - -` + "```go\nfunc main() {}\n```" - - result, err := Render(content) - if err != nil { - t.Fatalf("Render() failed: %v", err) - } - - if result == "" { - t.Error("Render() returned empty string") - } - - // The rendered output should be non-empty and styled - if len(result) < len(content)/2 { - t.Error("Rendered output seems too short") - } -} - -func TestRenderConsistency(t *testing.T) { - // Test that rendering the same content twice produces the same result - content := "# Test\n\nConsistency check" - - result1, err1 := Render(content) - if err1 != nil { - t.Fatalf("First Render() failed: %v", err1) - } - - result2, err2 := Render(content) - if err2 != nil { - t.Fatalf("Second Render() failed: %v", err2) - } - - if result1 != result2 { - t.Error("Render() produced different results for same content") - } -} - -func TestRenderWithWidth_Consistency(t *testing.T) { - content := "Test content for width consistency" - width := 60 - - result1, err1 := RenderWithWidth(content, width) - if err1 != nil { - t.Fatalf("First RenderWithWidth() failed: %v", err1) - } - - result2, err2 := RenderWithWidth(content, width) - if err2 != nil { - t.Fatalf("Second RenderWithWidth() failed: %v", err2) - } - - if result1 != result2 { - t.Error("RenderWithWidth() produced different results for same input") - } -} - -func TestRenderNestedStructures(t *testing.T) { - content := ` -# Main Heading - -## Sub Heading - -- Item 1 - - Nested item 1 - - Nested item 2 -- Item 2 - -1. Numbered item 1 -2. Numbered item 2 - -> Blockquote text here -` - - result, err := Render(content) - if err != nil { - t.Fatalf("Render() failed: %v", err) - } - - if result == "" { - t.Error("Render() returned empty string for nested structures") - } -} - -func TestRenderCodeBlock(t *testing.T) { - content := "```go\npackage main\n\nfunc main() {\n\tprintln(\"Hello\")\n}\n```" - - result, err := Render(content) - if err != nil { - t.Fatalf("Render() failed: %v", err) - } - - if result == "" { - t.Error("Render() returned empty string for code block") - } -} - -func TestRenderLinks(t *testing.T) { - tests := []struct { - name string - content string - }{ - {"inline link", "[Example](https://example.com)"}, - {"reference link", "[Example][ref]\n\n[ref]: https://example.com"}, - {"autolink", ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := Render(tt.content) - if err != nil { - t.Errorf("Render() failed: %v", err) - } - if result == "" { - t.Error("Render() returned empty string for link") - } - }) - } -} - -func TestRenderEmphasis(t *testing.T) { - tests := []struct { - name string - content string - }{ - {"bold", "**bold text**"}, - {"italic", "*italic text*"}, - {"strikethrough", "~~strikethrough~~"}, - {"combined", "**bold and *italic***"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := Render(tt.content) - if err != nil { - t.Errorf("Render() failed: %v", err) - } - if result == "" { - t.Error("Render() returned empty string for emphasis") - } - }) - } -} - -func TestRenderEdgeCases(t *testing.T) { - tests := []struct { - name string - content string - }{ - {"only whitespace", " \n \t \n "}, - {"special characters", "!@#$%^&*()"}, - {"unicode", "Hello 世界 🌍"}, - {"very long line", strings.Repeat("a", 1000)}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := Render(tt.content) - if err != nil { - t.Errorf("Render() failed: %v", err) - } - // Should not panic or error - }) - } -} diff --git a/pkg/ui/profiles/context.go b/pkg/ui/profiles/context.go deleted file mode 100644 index 63e11d7..0000000 --- a/pkg/ui/profiles/context.go +++ /dev/null @@ -1,103 +0,0 @@ -package profiles - -import ( - "fmt" - "sync" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// ProfileContext provides thread-safe access to profile-specific UI configuration. -// This is the primary integration point for profile branding throughout the CLI. -// -// Spec: 014-profile-init-wizard -// FR-001, FR-002, FR-003, FR-013, FR-014 -type ProfileContext struct { - profile *Profile - theme *themes.Theme - tierNames []string - bannerLogo string - mu sync.RWMutex -} - -// NewProfileContext creates a new ProfileContext with validation. -// Returns error if profile is nil or if tierNames doesn't have exactly 3 elements. -func NewProfileContext(profile *Profile, theme *themes.Theme) (*ProfileContext, error) { - if profile == nil { - return nil, fmt.Errorf("profile cannot be nil") - } - - // Validate tier names - if len(profile.TierNames) != 3 { - return nil, fmt.Errorf("profile must have exactly 3 tier names, got %d", len(profile.TierNames)) - } - - // Create tier names copy to prevent mutations - tierNames := make([]string, 3) - copy(tierNames, profile.TierNames) - - return &ProfileContext{ - profile: profile, - theme: theme, - tierNames: tierNames, - bannerLogo: profile.Logo, - }, nil -} - -// Profile returns the underlying profile (thread-safe). -func (pc *ProfileContext) Profile() *Profile { - pc.mu.RLock() - defer pc.mu.RUnlock() - return pc.profile -} - -// Theme returns the associated theme (thread-safe). -// Returns nil if no theme was provided during construction. -func (pc *ProfileContext) Theme() *themes.Theme { - pc.mu.RLock() - defer pc.mu.RUnlock() - return pc.theme -} - -// TierNames returns a copy of the tier names array (thread-safe). -func (pc *ProfileContext) TierNames() []string { - pc.mu.RLock() - defer pc.mu.RUnlock() - - result := make([]string, len(pc.tierNames)) - copy(result, pc.tierNames) - return result -} - -// BannerLogo returns the banner logo string (thread-safe). -func (pc *ProfileContext) BannerLogo() string { - pc.mu.RLock() - defer pc.mu.RUnlock() - return pc.bannerLogo -} - -// ThemeColors returns the theme's color set (thread-safe). -// Returns nil if no theme is set. -func (pc *ProfileContext) ThemeColors() *themes.ColorSet { - pc.mu.RLock() - defer pc.mu.RUnlock() - - if pc.theme == nil { - return nil - } - return &pc.theme.Colors -} - -// GetTierName returns the profile-specific tier name for the given tier index. -// Returns error if tier index is out of bounds (must be 0-2). -// Thread-safe. -func (pc *ProfileContext) GetTierName(tierIndex int) (string, error) { - pc.mu.RLock() - defer pc.mu.RUnlock() - - if tierIndex < 0 || tierIndex >= len(pc.tierNames) { - return "", fmt.Errorf("tier index %d out of bounds (must be 0-2)", tierIndex) - } - - return pc.tierNames[tierIndex], nil -} diff --git a/pkg/ui/profiles/context_test.go b/pkg/ui/profiles/context_test.go deleted file mode 100644 index 58bffe6..0000000 --- a/pkg/ui/profiles/context_test.go +++ /dev/null @@ -1,353 +0,0 @@ -package profiles - -import ( - "sync" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// Unit tests for ProfileContext -// Target coverage: 80%+ -// -// Spec: 014-profile-init-wizard -// Test requirements from T015 - -func TestNewProfileContext(t *testing.T) { - tests := []struct { - name string - profile *Profile - theme *themes.Theme - wantErr bool - errContains string - }{ - { - name: "valid profile with theme", - profile: &Profile{ - ID: "test", - Name: "Test", - TierNames: []string{"Tier1", "Tier2", "Tier3"}, - Logo: "TEST LOGO", - }, - theme: &themes.Theme{Name: "test-theme"}, - wantErr: false, - }, - { - name: "valid profile without theme", - profile: &Profile{ - ID: "test", - Name: "Test", - TierNames: []string{"Tier1", "Tier2", "Tier3"}, - Logo: "TEST LOGO", - }, - theme: nil, - wantErr: false, - }, - { - name: "nil profile", - profile: nil, - theme: &themes.Theme{}, - wantErr: true, - errContains: "profile cannot be nil", - }, - { - name: "profile with too few tier names", - profile: &Profile{ - ID: "test", - Name: "Test", - TierNames: []string{"Tier1", "Tier2"}, - Logo: "TEST LOGO", - }, - theme: &themes.Theme{}, - wantErr: true, - errContains: "must have exactly 3 tier names", - }, - { - name: "profile with too many tier names", - profile: &Profile{ - ID: "test", - Name: "Test", - TierNames: []string{"Tier1", "Tier2", "Tier3", "Tier4"}, - Logo: "TEST LOGO", - }, - theme: &themes.Theme{}, - wantErr: true, - errContains: "must have exactly 3 tier names", - }, - { - name: "profile with empty tier names", - profile: &Profile{ - ID: "test", - Name: "Test", - TierNames: []string{}, - Logo: "TEST LOGO", - }, - theme: &themes.Theme{}, - wantErr: true, - errContains: "must have exactly 3 tier names", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx, err := NewProfileContext(tt.profile, tt.theme) - - if tt.wantErr { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.errContains) - assert.Nil(t, ctx) - } else { - require.NoError(t, err) - require.NotNil(t, ctx) - assert.Equal(t, tt.profile, ctx.profile) - assert.Equal(t, tt.theme, ctx.theme) - assert.Equal(t, tt.profile.Logo, ctx.bannerLogo) - assert.Equal(t, 3, len(ctx.tierNames)) - } - }) - } -} - -func TestProfileContext_Profile(t *testing.T) { - profile := &Profile{ - ID: "test", - Name: "Test Profile", - TierNames: []string{"T1", "T2", "T3"}, - } - - ctx, err := NewProfileContext(profile, nil) - require.NoError(t, err) - - result := ctx.Profile() - assert.Equal(t, profile, result) -} - -func TestProfileContext_Theme(t *testing.T) { - profile := &Profile{ - ID: "test", - TierNames: []string{"T1", "T2", "T3"}, - } - - t.Run("with theme", func(t *testing.T) { - theme := &themes.Theme{Name: "test-theme"} - ctx, err := NewProfileContext(profile, theme) - require.NoError(t, err) - - result := ctx.Theme() - assert.Equal(t, theme, result) - }) - - t.Run("without theme", func(t *testing.T) { - ctx, err := NewProfileContext(profile, nil) - require.NoError(t, err) - - result := ctx.Theme() - assert.Nil(t, result) - }) -} - -func TestProfileContext_TierNames(t *testing.T) { - profile := &Profile{ - ID: "test", - TierNames: []string{"Alpha", "Beta", "Gamma"}, - } - - ctx, err := NewProfileContext(profile, nil) - require.NoError(t, err) - - result := ctx.TierNames() - assert.Equal(t, []string{"Alpha", "Beta", "Gamma"}, result) - - // Verify it returns a copy (mutation doesn't affect original) - result[0] = "Modified" - assert.Equal(t, []string{"Alpha", "Beta", "Gamma"}, ctx.TierNames()) -} - -func TestProfileContext_BannerLogo(t *testing.T) { - profile := &Profile{ - ID: "test", - TierNames: []string{"T1", "T2", "T3"}, - Logo: "ASCII ART LOGO", - } - - ctx, err := NewProfileContext(profile, nil) - require.NoError(t, err) - - result := ctx.BannerLogo() - assert.Equal(t, "ASCII ART LOGO", result) -} - -func TestProfileContext_ThemeColors(t *testing.T) { - profile := &Profile{ - ID: "test", - TierNames: []string{"T1", "T2", "T3"}, - } - - t.Run("with theme", func(t *testing.T) { - theme := &themes.Theme{ - Name: "test-theme", - Colors: themes.ColorSet{ - Primary: "#FF0000", - Secondary: "#00FF00", - }, - } - - ctx, err := NewProfileContext(profile, theme) - require.NoError(t, err) - - result := ctx.ThemeColors() - require.NotNil(t, result) - assert.Equal(t, "#FF0000", result.Primary) - assert.Equal(t, "#00FF00", result.Secondary) - }) - - t.Run("without theme", func(t *testing.T) { - ctx, err := NewProfileContext(profile, nil) - require.NoError(t, err) - - result := ctx.ThemeColors() - assert.Nil(t, result) - }) -} - -func TestProfileContext_GetTierName(t *testing.T) { - profile := &Profile{ - ID: "test", - TierNames: []string{"Starter", "Pro", "Ultra"}, - } - - ctx, err := NewProfileContext(profile, nil) - require.NoError(t, err) - - tests := []struct { - name string - tierIndex int - want string - wantErr bool - errContains string - }{ - { - name: "tier 0", - tierIndex: 0, - want: "Starter", - wantErr: false, - }, - { - name: "tier 1", - tierIndex: 1, - want: "Pro", - wantErr: false, - }, - { - name: "tier 2", - tierIndex: 2, - want: "Ultra", - wantErr: false, - }, - { - name: "negative index", - tierIndex: -1, - wantErr: true, - errContains: "out of bounds", - }, - { - name: "index too high", - tierIndex: 3, - wantErr: true, - errContains: "out of bounds", - }, - { - name: "index way too high", - tierIndex: 99, - wantErr: true, - errContains: "out of bounds", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := ctx.GetTierName(tt.tierIndex) - - if tt.wantErr { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.errContains) - assert.Empty(t, result) - } else { - require.NoError(t, err) - assert.Equal(t, tt.want, result) - } - }) - } -} - -func TestProfileContext_ThreadSafety(t *testing.T) { - profile := &Profile{ - ID: "test", - TierNames: []string{"T1", "T2", "T3"}, - Logo: "LOGO", - } - - theme := &themes.Theme{ - Name: "test-theme", - Colors: themes.ColorSet{ - Primary: "#FF0000", - }, - } - - ctx, err := NewProfileContext(profile, theme) - require.NoError(t, err) - - // Concurrently call all getters to verify thread safety - var wg sync.WaitGroup - iterations := 100 - - for i := 0; i < iterations; i++ { - wg.Add(6) - - go func() { - defer wg.Done() - _ = ctx.Profile() - }() - - go func() { - defer wg.Done() - _ = ctx.Theme() - }() - - go func() { - defer wg.Done() - _ = ctx.TierNames() - }() - - go func() { - defer wg.Done() - _ = ctx.BannerLogo() - }() - - go func() { - defer wg.Done() - _ = ctx.ThemeColors() - }() - - go func() { - defer wg.Done() - _, _ = ctx.GetTierName(1) - }() - } - - // Wait for all goroutines to complete - wg.Wait() - - // Verify data integrity after concurrent access - assert.Equal(t, profile, ctx.Profile()) - assert.Equal(t, theme, ctx.Theme()) - assert.Equal(t, []string{"T1", "T2", "T3"}, ctx.TierNames()) - assert.Equal(t, "LOGO", ctx.BannerLogo()) - - tierName, err := ctx.GetTierName(0) - require.NoError(t, err) - assert.Equal(t, "T1", tierName) -} diff --git a/pkg/ui/profiles/defaults.go b/pkg/ui/profiles/defaults.go deleted file mode 100644 index b026357..0000000 --- a/pkg/ui/profiles/defaults.go +++ /dev/null @@ -1,189 +0,0 @@ -package profiles - -import ( - "github.com/arc-framework/arc-cli/internal/preferences" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// Enterprise default profile logic and fallback handling. -// -// Spec: 014-profile-init-wizard -// FR-001, FR-002, FR-003, FR-013, FR-014 - -const ( - // DefaultProfileID is the default profile ID used on fresh installs - DefaultProfileID = "enterprise" - - // FallbackTier1 is used when profile loading completely fails - FallbackTier1 = "Tier 1" - // FallbackTier2 is used when profile loading completely fails - FallbackTier2 = "Tier 2" - // FallbackTier3 is used when profile loading completely fails - FallbackTier3 = "Tier 3" - // FallbackBanner is used when profile loading completely fails - FallbackBanner = "A.R.C." -) - -// GetDefaultProfileContext returns a ProfileContext with the Enterprise profile. -// This is used as the primary default on fresh installs. -// -// Fallback behavior: -// 1. Attempt to load "enterprise" profile from repository -// 2. If that fails, return minimal fallback profile -// -// This function always succeeds (never returns error). -func GetDefaultProfileContext() *ProfileContext { - repo, err := NewRepository() - if err != nil { - // Repository initialization failed - return minimal fallback - return getMinimalFallbackContext() - } - - profile, err := repo.GetByID(DefaultProfileID) - if err != nil { - // Enterprise profile not found - return minimal fallback - return getMinimalFallbackContext() - } - - // Try to load the theme associated with the profile - var theme *themes.Theme - if profile.ThemeID != "" { - themeLoader := themes.NewLoader() - theme, _ = themeLoader.Load(profile.ThemeID) - // If theme loading fails, theme will be nil (acceptable) - } - - // Create context from loaded profile - ctx, err := NewProfileContext(profile, theme) - if err != nil { - // Profile validation failed - return minimal fallback - return getMinimalFallbackContext() - } - - return ctx -} - -// LoadProfileContext loads a ProfileContext for the given profile ID. -// This implements the multi-layered fallback strategy: -// -// Layer 1: If profileID is empty, use "enterprise" -// Layer 2: If profile loading fails, fallback to Enterprise -// Layer 3: If Enterprise fails, return minimal fallback -// -// This function always succeeds (never returns error). -func LoadProfileContext(profileID string) *ProfileContext { - // Layer 1: Default to enterprise if empty - if profileID == "" { - profileID = DefaultProfileID - } - - repo, err := NewRepository() - if err != nil { - // Repository failed - try enterprise as fallback - return GetDefaultProfileContext() - } - - // Try to load requested profile - profile, err := repo.GetByID(profileID) - if err != nil { - // Layer 2: Requested profile failed, fallback to Enterprise - return loadFallbackProfile(repo, profileID) - } - - // Try to load the theme associated with the profile - var theme *themes.Theme - if profile.ThemeID != "" { - themeLoader := themes.NewLoader() - theme, _ = themeLoader.Load(profile.ThemeID) - // If theme loading fails, theme will be nil (acceptable) - } - - // Create context from loaded profile - ctx, err := NewProfileContext(profile, theme) - if err != nil { - // Profile validation failed - fallback to Enterprise or minimal - if profileID != DefaultProfileID { - return GetDefaultProfileContext() - } - return getMinimalFallbackContext() - } - - return ctx -} - -// LoadProfileContextFromPreferences loads a ProfileContext based on user preferences. -// This is the primary entry point for the Factory. -// -// Fallback strategy: -// 1. Load preferences, use stored profile ID -// 2. If preferences.GetProfile() returns empty, use "enterprise" -// 3. If profile loading fails, fallback through LoadProfileContext() -// -// This function always succeeds (never returns error). -func LoadProfileContextFromPreferences() *ProfileContext { - prefs, err := preferences.Load() - if err != nil { - // Preferences loading failed, use enterprise default - return GetDefaultProfileContext() - } - - profileID := prefs.GetProfile() - - // Use LoadProfileContext which implements the full fallback chain - return LoadProfileContext(profileID) -} - -// loadFallbackProfile attempts to load the enterprise profile as fallback. -// If that also fails, returns minimal fallback context. -func loadFallbackProfile(repo *Repository, requestedProfileID string) *ProfileContext { - if requestedProfileID == DefaultProfileID { - // Already tried enterprise, use minimal fallback - return getMinimalFallbackContext() - } - - // Try to load enterprise as fallback - profile, err := repo.GetByID(DefaultProfileID) - if err != nil { - // Enterprise also failed, use minimal fallback - return getMinimalFallbackContext() - } - - // Load theme for enterprise profile - var theme *themes.Theme - if profile.ThemeID != "" { - themeLoader := themes.NewLoader() - theme, _ = themeLoader.Load(profile.ThemeID) - } - - // Create context from enterprise profile - ctx, err := NewProfileContext(profile, theme) - if err != nil { - return getMinimalFallbackContext() - } - - return ctx -} - -// getMinimalFallbackContext creates a minimal fallback ProfileContext. -// This is used when all other loading strategies fail. -// It uses hardcoded tier names and a simple text banner. -func getMinimalFallbackContext() *ProfileContext { - minimalProfile := &Profile{ - ID: DefaultProfileID, - Name: "Enterprise", - Description: "Minimal fallback profile", - TierNames: []string{FallbackTier1, FallbackTier2, FallbackTier3}, - Logo: FallbackBanner, - ThemeID: "", - } - - // Create context without theme (nil is acceptable) - ctx, err := NewProfileContext(minimalProfile, nil) - if err != nil { - // This should never happen since we control the minimal profile structure - // But if it does, we have no choice but to panic - panic("minimal fallback profile is invalid: " + err.Error()) - } - - return ctx -} diff --git a/pkg/ui/profiles/defaults_test.go b/pkg/ui/profiles/defaults_test.go deleted file mode 100644 index 7a8698a..0000000 --- a/pkg/ui/profiles/defaults_test.go +++ /dev/null @@ -1,326 +0,0 @@ -package profiles - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/internal/preferences" -) - -// Unit tests for Enterprise default logic and fallback handling -// Target coverage: 85%+ -// -// Spec: 014-profile-init-wizard -// Test requirements from T018 - -func TestGetDefaultProfileContext(t *testing.T) { - // This test verifies that GetDefaultProfileContext always succeeds - // and returns a valid context with Enterprise profile - ctx := GetDefaultProfileContext() - - require.NotNil(t, ctx, "GetDefaultProfileContext should never return nil") - - profile := ctx.Profile() - require.NotNil(t, profile, "Profile should not be nil") - - // Should use Enterprise profile by default - assert.Equal(t, DefaultProfileID, profile.ID) - - // Should have valid tier names - tierNames := ctx.TierNames() - assert.Len(t, tierNames, 3, "Should have exactly 3 tier names") - - // Should have a banner logo - logo := ctx.BannerLogo() - assert.NotEmpty(t, logo, "Banner logo should not be empty") -} - -func TestLoadProfileContext(t *testing.T) { - tests := []struct { - name string - profileID string - expectProfileID string - expectNonNil bool - expectTierNames int - expectValidLogo bool - }{ - { - name: "empty profile ID defaults to enterprise", - profileID: "", - expectProfileID: DefaultProfileID, - expectNonNil: true, - expectTierNames: 3, - expectValidLogo: true, - }, - { - name: "explicit enterprise profile", - profileID: "enterprise", - expectProfileID: "enterprise", - expectNonNil: true, - expectTierNames: 3, - expectValidLogo: true, - }, - { - name: "invalid profile falls back to enterprise", - profileID: "nonexistent-profile-xyz", - expectProfileID: DefaultProfileID, - expectNonNil: true, - expectTierNames: 3, - expectValidLogo: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx := LoadProfileContext(tt.profileID) - - if tt.expectNonNil { - require.NotNil(t, ctx) - - profile := ctx.Profile() - require.NotNil(t, profile) - assert.Equal(t, tt.expectProfileID, profile.ID) - - tierNames := ctx.TierNames() - assert.Len(t, tierNames, tt.expectTierNames) - - if tt.expectValidLogo { - logo := ctx.BannerLogo() - assert.NotEmpty(t, logo) - } - } - }) - } -} - -func TestLoadProfileContext_ValidProfiles(t *testing.T) { - // Test that we can load known valid profiles - // These profiles should exist in embedded/*.yaml - validProfiles := []string{ - "enterprise", - // Add other known profiles if they exist - } - - for _, profileID := range validProfiles { - t.Run("load_"+profileID, func(t *testing.T) { - ctx := LoadProfileContext(profileID) - - require.NotNil(t, ctx) - profile := ctx.Profile() - require.NotNil(t, profile) - - // Verify profile has correct ID - assert.Equal(t, profileID, profile.ID) - - // Verify tier names - tierNames := ctx.TierNames() - assert.Len(t, tierNames, 3) - for i, name := range tierNames { - assert.NotEmpty(t, name, "Tier name at index %d should not be empty", i) - } - - // Verify logo - assert.NotEmpty(t, profile.Logo) - }) - } -} - -func TestLoadProfileContextFromPreferences_FreshInstall(t *testing.T) { - // Simulate fresh install by using a temporary home directory - tempHome := t.TempDir() - originalHome := os.Getenv("HOME") - defer func() { - if originalHome != "" { - os.Setenv("HOME", originalHome) - } - }() - os.Setenv("HOME", tempHome) - - // No preferences file exists - ctx := LoadProfileContextFromPreferences() - - require.NotNil(t, ctx) - profile := ctx.Profile() - require.NotNil(t, profile) - - // Should default to Enterprise - assert.Equal(t, DefaultProfileID, profile.ID) -} - -func TestLoadProfileContextFromPreferences_WithExistingProfile(t *testing.T) { - // Create temporary home directory - tempHome := t.TempDir() - originalHome := os.Getenv("HOME") - defer func() { - if originalHome != "" { - os.Setenv("HOME", originalHome) - } - }() - os.Setenv("HOME", tempHome) - - // Create .arc directory - arcDir := filepath.Join(tempHome, ".arc") - err := os.MkdirAll(arcDir, 0o700) - require.NoError(t, err) - - // Create preferences with enterprise profile - prefs := &preferences.Preferences{ - Theme: "cyan-purple", - Profile: "enterprise", - } - err = prefs.Save() - require.NoError(t, err) - - // Load profile context - ctx := LoadProfileContextFromPreferences() - - require.NotNil(t, ctx) - profile := ctx.Profile() - require.NotNil(t, profile) - - assert.Equal(t, "enterprise", profile.ID) -} - -func TestLoadProfileContextFromPreferences_EmptyProfileField(t *testing.T) { - // Create temporary home directory - tempHome := t.TempDir() - originalHome := os.Getenv("HOME") - defer func() { - if originalHome != "" { - os.Setenv("HOME", originalHome) - } - }() - os.Setenv("HOME", tempHome) - - // Create .arc directory - arcDir := filepath.Join(tempHome, ".arc") - err := os.MkdirAll(arcDir, 0o700) - require.NoError(t, err) - - // Create preferences with empty profile field - prefs := &preferences.Preferences{ - Theme: "cyan-purple", - Profile: "", // Empty - } - err = prefs.Save() - require.NoError(t, err) - - // Load profile context - ctx := LoadProfileContextFromPreferences() - - require.NotNil(t, ctx) - profile := ctx.Profile() - require.NotNil(t, profile) - - // Should default to Enterprise - assert.Equal(t, DefaultProfileID, profile.ID) -} - -func TestLoadProfileContextFromPreferences_InvalidProfile(t *testing.T) { - // Create temporary home directory - tempHome := t.TempDir() - originalHome := os.Getenv("HOME") - defer func() { - if originalHome != "" { - os.Setenv("HOME", originalHome) - } - }() - os.Setenv("HOME", tempHome) - - // Create .arc directory - arcDir := filepath.Join(tempHome, ".arc") - err := os.MkdirAll(arcDir, 0o700) - require.NoError(t, err) - - // Create preferences with invalid profile - prefs := &preferences.Preferences{ - Theme: "cyan-purple", - Profile: "invalid-profile-that-does-not-exist", - } - err = prefs.Save() - require.NoError(t, err) - - // Load profile context - ctx := LoadProfileContextFromPreferences() - - require.NotNil(t, ctx) - profile := ctx.Profile() - require.NotNil(t, profile) - - // Should fallback to Enterprise - assert.Equal(t, DefaultProfileID, profile.ID) -} - -func TestGetMinimalFallbackContext(t *testing.T) { - // Test the minimal fallback directly - ctx := getMinimalFallbackContext() - - require.NotNil(t, ctx) - - profile := ctx.Profile() - require.NotNil(t, profile) - - // Should use Enterprise ID - assert.Equal(t, DefaultProfileID, profile.ID) - - // Should have exactly 3 tier names - tierNames := ctx.TierNames() - require.Len(t, tierNames, 3) - - // Should use fallback tier names - assert.Equal(t, FallbackTier1, tierNames[0]) - assert.Equal(t, FallbackTier2, tierNames[1]) - assert.Equal(t, FallbackTier3, tierNames[2]) - - // Should have fallback banner - assert.Equal(t, FallbackBanner, ctx.BannerLogo()) - - // Theme may be nil in minimal fallback - theme := ctx.Theme() - // Theme can be nil, so we don't assert on it - _ = theme -} - -func TestLoadProfileContext_FallbackLayers(t *testing.T) { - // Test the multi-layered fallback strategy - - t.Run("Layer 1: empty ID uses enterprise", func(t *testing.T) { - ctx := LoadProfileContext("") - require.NotNil(t, ctx) - assert.Equal(t, DefaultProfileID, ctx.Profile().ID) - }) - - t.Run("Layer 2: invalid profile falls back to enterprise", func(t *testing.T) { - ctx := LoadProfileContext("totally-invalid-profile") - require.NotNil(t, ctx) - assert.Equal(t, DefaultProfileID, ctx.Profile().ID) - }) - - t.Run("Layer 3: always returns valid context", func(t *testing.T) { - // Even with completely invalid input, should return valid context - ctx := LoadProfileContext("!@#$%^&*()") - require.NotNil(t, ctx) - - profile := ctx.Profile() - require.NotNil(t, profile) - - tierNames := ctx.TierNames() - assert.Len(t, tierNames, 3) - - logo := ctx.BannerLogo() - assert.NotEmpty(t, logo) - }) -} - -func TestDefaultConstants(t *testing.T) { - // Verify that the constants are set correctly - assert.Equal(t, "enterprise", DefaultProfileID) - assert.Equal(t, "Tier 1", FallbackTier1) - assert.Equal(t, "Tier 2", FallbackTier2) - assert.Equal(t, "Tier 3", FallbackTier3) - assert.Equal(t, "A.R.C.", FallbackBanner) -} diff --git a/pkg/ui/profiles/embedded/README.md b/pkg/ui/profiles/embedded/README.md deleted file mode 100644 index 5c142db..0000000 --- a/pkg/ui/profiles/embedded/README.md +++ /dev/null @@ -1,155 +0,0 @@ -# Profile YAML Schema - -This directory contains embedded profile definitions for the A.R.C. CLI. - -## Schema Definition - -```yaml -# Profile ID (unique identifier, lowercase, no spaces) -id: enterprise - -# Display name (user-facing) -name: Enterprise - -# Brief description of the profile theme -description: Professional corporate naming for business environments - -# Tier names (MUST have exactly 3 entries) -# Index 0 = Tier 1 (lowest) -# Index 1 = Tier 2 (middle) -# Index 2 = Tier 3 (highest) -tier_names: - - Starter - - Pro - - Ultra - -# Theme ID (must reference existing embedded theme) -# Valid themes: fire, nord, gruvbox, ocean, rainbow, solarized, monokai, cyan-purple, dracula -theme_id: cyan-purple - -# ASCII logo (multi-line string) -# CRITICAL RULE: Text must spell "A.R.C." but creative freedom for styling -# - Use appropriate ASCII font from https://patorjk.com/software/taag/ -# - Text MUST spell "A.R.C." (the letters) -# - Full creative freedom for borders, icons, backgrounds, decorations -# - Max width: 80 characters -# - Recommended height: 5-10 lines -logo: | - █████╗ ██████╗ ██████╗ - ██╔══██╗██╔══██╗██╔════╝ - ███████║██████╔╝██║ - ██╔══██║██╔══██╗██║ - ██║ ██║██║ ██║╚██████╗ - ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ - -# Optional: Primary color (hex format, falls back to theme if omitted) -primary_color: "#4A90E2" - -# Optional: Secondary color (hex format, falls back to theme if omitted) -secondary_color: "#7B68EE" -``` - -## Required Profiles - -The following 10 profiles MUST be present: - -1. **enterprise.yaml** - Professional/Corporate (Starter → Pro → Ultra) -2. **saiyan.yaml** - Dragon Ball Z (Super Saiyan → Super Saiyan Blue → Ultra Instinct) -3. **shinobi.yaml** - Naruto (Genin → Jonin → Hokage) -4. **pirate.yaml** - One Piece (Rookie → Supernova → Yonko) -5. **pokemon.yaml** - Pokémon (Basic → Stage 1 → Stage 2) -6. **triforce.yaml** - Legend of Zelda (Courage → Wisdom → Power) -7. **crystal.yaml** - Final Fantasy (Warrior → Knight → Paladin) -8. **jedi.yaml** - Star Wars (Padawan → Knight → Master) -9. **bending.yaml** - Avatar: The Last Airbender (Bender → Avatar → Cosmic) -10. **horcrux.yaml** - Harry Potter (Student → Auror → Headmaster) - -## Validation Rules - -### Required Fields -- `id` - Non-empty string, lowercase, no spaces -- `name` - Non-empty string -- `description` - Non-empty string -- `tier_names` - Array with exactly 3 non-empty strings -- `theme_id` - Must reference existing theme -- `logo` - Non-empty multi-line string - -### Optional Fields -- `primary_color` - Hex color string (e.g., "#4A90E2") -- `secondary_color` - Hex color string (e.g., "#7B68EE") - -### Logo Content Rule -**Text must spell "A.R.C."** - This ensures branding consistency. - -**Creative freedom** for: -- Font selection (from patorjk.com) -- Borders and frames -- Icons and symbols -- Background patterns -- Decorative elements -- Taglines or flavor text - -**Examples**: -- ✅ "A.R.C." in Big font = Good -- ✅ "A.R.C." in Big font with lightning bolts = Better -- ✅ "A.R.C." with border + icons + tagline = Best -- ❌ Triforce symbol only (no "A.R.C." text) = Wrong -- ❌ "JEDI" spelled out (not "A.R.C.") = Wrong - -See `specs/013-profile-tiers/LOGO_CONTENT_RULE.md` for detailed examples. - -## Theme Mapping - -| Profile | Theme | Colors | -|---------|-------|--------| -| enterprise | cyan-purple | Professional blues and purples | -| saiyan | fire | Bold oranges and reds | -| shinobi | gruvbox | Earthy browns and oranges | -| pirate | ocean | Deep blues and teals | -| pokemon | rainbow | Bright multi-color palette | -| triforce | solarized | Balanced warm/cool tones | -| crystal | monokai | Rich purples and pinks | -| jedi | nord | Cool blues and grays | -| bending | rainbow | Elemental color spectrum | -| horcrux | dracula | Dark purples and reds | - -## Font Recommendations - -| Profile | Recommended Font | Style | -|---------|------------------|-------| -| enterprise | Speed | Clean, professional | -| saiyan | Big | Bold, energetic | -| shinobi | Graffiti | Edgy, street art | -| pirate | Colossal | Rough, adventurous | -| pokemon | Bulbhead | Playful, rounded | -| triforce | Delta Corps Priest 1 | Angular, mystical | -| crystal | Banner3 | Elegant, fantasy | -| jedi | Star Wars | Sci-fi, futuristic | -| bending | Larry 3D | Dimensional, flowing | -| horcrux | Merlin1 | Mystical, ornate | - -Generate logos at: https://patorjk.com/software/taag/ - -## User-Defined Profiles - -Users can create custom profiles in `~/.config/arc/profiles/`: - -1. Create a YAML file following the schema above -2. User profiles override embedded profiles with the same ID -3. Validation rules apply equally to user profiles - -Example: `~/.config/arc/profiles/custom.yaml` - -```yaml -id: custom -name: My Custom Profile -description: Personalized tier names -tier_names: - - Bronze - - Silver - - Gold -theme_id: fire -logo: | - A.R.C. - Custom Edition -``` diff --git a/pkg/ui/profiles/embedded/pokemon.yaml b/pkg/ui/profiles/embedded/pokemon.yaml deleted file mode 100644 index 46b1720..0000000 --- a/pkg/ui/profiles/embedded/pokemon.yaml +++ /dev/null @@ -1,18 +0,0 @@ -id: pokemon -name: Pokémon -description: Pokémon evolution stages for collectors -tier_names: - - Basic - - Stage 1 - - Stage 2 -theme_id: rainbow -logo: | - ╔══════════════════╗ - ║ ║ - ║ ⚪ A.R.C. ⚪ ║ - ║ ║ - ║ ◉─◉─◉ ║ - ║ Evolution ║ - ╚══════════════════╝ -primary_color: "#FFCB05" -secondary_color: "#3D7DCA" diff --git a/pkg/ui/profiles/embedded/saiyan.yaml b/pkg/ui/profiles/embedded/saiyan.yaml deleted file mode 100644 index 6a54cc0..0000000 --- a/pkg/ui/profiles/embedded/saiyan.yaml +++ /dev/null @@ -1,21 +0,0 @@ -id: saiyan -name: Saiyan -description: Dragon Ball Z transformation levels for power users -tier_names: - - Super Saiyan - - Super Saiyan Blue - - Ultra Instinct -theme_id: fire -logo: | - ⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡ - - _____ ____ ____ - / _ \| _ \ / ___| - / /_\ \ \ |_) | | - / ___ \ \ _ <| |___ - /_/ \_\_\|_| \_\_____| - - POWER ▰▰▰▰▰▰▰▰▱▱ - ⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡ -primary_color: "#FFD700" -secondary_color: "#FF6B6B" diff --git a/pkg/ui/profiles/fixtures_test.go b/pkg/ui/profiles/fixtures_test.go deleted file mode 100644 index 604a90f..0000000 --- a/pkg/ui/profiles/fixtures_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package profiles_test - -import "github.com/arc-framework/arc-cli/pkg/ui/profiles" - -// TestProfiles provides sample profiles for testing -var TestProfiles = []profiles.Profile{ - { - ID: "enterprise", - Name: "Enterprise", - Description: "Professional corporate naming for business environments", - TierNames: []string{"Starter", "Pro", "Ultra"}, - ThemeID: "cyan-purple", - Logo: ` █████╗ ██████╗ ██████╗ -██╔══██╗██╔══██╗██╔════╝ -███████║██████╔╝██║ -██╔══██║██╔══██╗██║ -██║ ██║██║ ██║╚██████╗ -╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝`, - PrimaryColor: "#4A90E2", - SecondaryColor: "#7B68EE", - }, - { - ID: "saiyan", - Name: "Saiyan", - Description: "Dragon Ball Z transformation levels for power users", - TierNames: []string{"Super Saiyan", "Super Saiyan Blue", "Ultra Instinct"}, - ThemeID: "fire", - Logo: ` _____ ____ ____ - / _ \| _ \ / ___| - / /_\ \ \ |_) | | - / ___ \ \ _ <| | - /_/ \_\_\|_| \_\_____|`, - PrimaryColor: "#FFD700", - SecondaryColor: "#FF6B6B", - }, - { - ID: "jedi", - Name: "Jedi", - Description: "Star Wars Force user ranks for sci-fi enthusiasts", - TierNames: []string{"Padawan", "Knight", "Master"}, - ThemeID: "nord", - Logo: ` ▄▀█ █▀█ █▀▀ - █▀█ █▀▄ █▄▄`, - PrimaryColor: "#81A1C1", - SecondaryColor: "#5E81AC", - }, -} - -// GetTestProfileIDs returns all test profile IDs -func GetTestProfileIDs() []string { - ids := make([]string, len(TestProfiles)) - for i, p := range TestProfiles { - ids[i] = p.ID - } - return ids -} - -// GetTestProfile returns a test profile by ID -func GetTestProfile(id string) *profiles.Profile { - for _, p := range TestProfiles { - if p.ID == id { - profile := p // Create copy - return &profile - } - } - return nil -} - -// InvalidProfile returns a profile missing required fields for testing validation -func InvalidProfile() profiles.Profile { - return profiles.Profile{ - ID: "invalid", - Name: "", // Missing required field - TierNames: []string{"One", "Two"}, // Wrong count (needs 3) - } -} diff --git a/pkg/ui/profiles/loader.go b/pkg/ui/profiles/loader.go deleted file mode 100644 index 0270f37..0000000 --- a/pkg/ui/profiles/loader.go +++ /dev/null @@ -1,254 +0,0 @@ -package profiles - -import ( - "embed" - "fmt" - "os" - "path/filepath" - - "gopkg.in/yaml.v3" -) - -const ( - yamlExt = ".yaml" -) - -//go:embed embedded/*.yaml -var embeddedFS embed.FS - -// Repository implements ProfileRepository interface -type Repository struct { - embedded []Profile - userDefined map[string]Profile - cache *profileCache - validator *Validator -} - -// profileCache implements a simple LRU cache for loaded profiles -type profileCache struct { - profiles map[string]*Profile - maxSize int -} - -// newProfileCache creates a new LRU cache with specified max size -func newProfileCache(maxSize int) *profileCache { - return &profileCache{ - profiles: make(map[string]*Profile), - maxSize: maxSize, - } -} - -// get retrieves a profile from cache -func (c *profileCache) get(id string) (*Profile, bool) { - profile, exists := c.profiles[id] - if !exists { - return nil, false - } - // Return a copy to prevent mutations - profileCopy := *profile - return &profileCopy, true -} - -// set stores a profile in cache -func (c *profileCache) set(id string, profile *Profile) { - // Simple cache eviction: if we're at max size and adding a new entry, clear oldest - // For now, we use a simple map which doesn't track access order - // This is acceptable for small profile sets (<100) - if len(c.profiles) >= c.maxSize && c.profiles[id] == nil { - // Clear cache if full (simple eviction strategy) - c.profiles = make(map[string]*Profile) - } - - // Store a copy to prevent mutations - profileCopy := *profile - c.profiles[id] = &profileCopy -} - -// clear removes all entries from cache -func (c *profileCache) clear() { - c.profiles = make(map[string]*Profile) -} - -// NewRepository creates a new profile repository -func NewRepository() (*Repository, error) { - repo := &Repository{ - embedded: make([]Profile, 0), - userDefined: make(map[string]Profile), - cache: newProfileCache(100), // Cache up to 100 profiles - validator: DefaultValidator(), - } - - // Load embedded profiles - if err := repo.loadEmbedded(); err != nil { - return nil, fmt.Errorf("failed to load embedded profiles: %w", err) - } - - // Load user-defined profiles (optional, doesn't fail if missing) - _ = repo.loadUserDefined() - - return repo, nil -} - -// loadEmbedded loads all embedded profile YAML files -func (r *Repository) loadEmbedded() error { - entries, err := embeddedFS.ReadDir("embedded") - if err != nil { - return fmt.Errorf("failed to read embedded directory: %w", err) - } - - for _, entry := range entries { - if entry.IsDir() || filepath.Ext(entry.Name()) != yamlExt { - continue - } - - fileData, readErr := embeddedFS.ReadFile("embedded/" + entry.Name()) - if readErr != nil { - return fmt.Errorf("failed to read %s: %w", entry.Name(), readErr) - } - - var profile Profile - if unmarshalErr := yaml.Unmarshal(fileData, &profile); unmarshalErr != nil { - return fmt.Errorf("failed to parse %s: %w", entry.Name(), unmarshalErr) - } - - if validateErr := r.Validate(&profile); validateErr != nil { - return fmt.Errorf("invalid profile %s: %w", entry.Name(), validateErr) - } - - r.embedded = append(r.embedded, profile) - } - - if len(r.embedded) == 0 { - return fmt.Errorf("no embedded profiles found") - } - - return nil -} - -// loadUserDefined loads profiles from ~/.config/arc/profiles/ -func (r *Repository) loadUserDefined() error { - home, err := os.UserHomeDir() - if err != nil { - return err - } - - profilesDir := filepath.Join(home, ".config", "arc", "profiles") - entries, readDirErr := os.ReadDir(profilesDir) - if readDirErr != nil { - // User profiles directory doesn't exist - this is okay - if os.IsNotExist(readDirErr) { - return nil - } - // Other errors should be reported - return fmt.Errorf("failed to read user profiles directory: %w", readDirErr) - } - - for _, entry := range entries { - if entry.IsDir() || filepath.Ext(entry.Name()) != yamlExt { - continue - } - - path := filepath.Join(profilesDir, entry.Name()) - fileData, readErr := os.ReadFile(path) - if readErr != nil { - continue // Skip files we can't read - } - - var profile Profile - if unmarshalErr := yaml.Unmarshal(fileData, &profile); unmarshalErr != nil { - continue // Skip invalid YAML - } - - if validateErr := r.Validate(&profile); validateErr != nil { - continue // Skip invalid profiles - } - - r.userDefined[profile.ID] = profile - } - - return nil -} - -// ListProfiles returns all available profiles as pointers (user-defined override embedded) -// Alias for LoadAll to match CLI interface expectations -func (r *Repository) ListProfiles() ([]*Profile, error) { - profiles, err := r.LoadAll() - if err != nil { - return nil, err - } - - // Convert to pointers - result := make([]*Profile, len(profiles)) - for i := range profiles { - result[i] = &profiles[i] - } - return result, nil -} - -// LoadAll returns all available profiles (user-defined override embedded) -func (r *Repository) LoadAll() ([]Profile, error) { - profiles := make([]Profile, 0, len(r.embedded)+len(r.userDefined)) - - // Start with embedded profiles - embeddedMap := make(map[string]Profile) - for i := range r.embedded { - p := &r.embedded[i] - embeddedMap[p.ID] = *p - } - - // Override with user-defined profiles - for id := range r.userDefined { - embeddedMap[id] = r.userDefined[id] - } - - // Convert map back to slice - for id := range embeddedMap { - profiles = append(profiles, embeddedMap[id]) - } - - return profiles, nil -} - -// GetProfile retrieves a specific profile by ID with caching -// Alias for GetByID to match CLI interface expectations -func (r *Repository) GetProfile(id string) (*Profile, error) { - return r.GetByID(id) -} - -// GetByID retrieves a specific profile by ID with caching -func (r *Repository) GetByID(id string) (*Profile, error) { - // Check cache first for fast lookup (<5ms target) - if cached, exists := r.cache.get(id); exists { - return cached, nil - } - - // Check user-defined first (they override embedded) - if userProfile, exists := r.userDefined[id]; exists { - profile := userProfile // Create copy - // Store in cache for next lookup - r.cache.set(id, &profile) - return &profile, nil - } - - // Check embedded profiles - for i := range r.embedded { - profile := &r.embedded[i] - if profile.ID == id { - // Store in cache for next lookup - r.cache.set(id, profile) - return profile, nil - } - } - - return nil, fmt.Errorf("profile %q not found", id) -} - -// Validate checks if a profile has all required fields using the comprehensive validator -func (r *Repository) Validate(profile *Profile) error { - return r.validator.ValidateProfile(profile) -} - -// ClearCache clears the profile cache (useful for testing or when profiles are updated) -func (r *Repository) ClearCache() { - r.cache.clear() -} diff --git a/pkg/ui/profiles/loader_test.go b/pkg/ui/profiles/loader_test.go deleted file mode 100644 index 6f446c5..0000000 --- a/pkg/ui/profiles/loader_test.go +++ /dev/null @@ -1,324 +0,0 @@ -package profiles - -import ( - "testing" - "time" -) - -func TestProfileCache(t *testing.T) { - cache := newProfileCache(3) - - profile1 := &Profile{ - ID: "test1", - Name: "Test 1", - Description: "Test profile 1", - TierNames: []string{"T1", "T2", "T3"}, - ThemeID: "fire", - Logo: "Logo 1 content", - } - - profile2 := &Profile{ - ID: "test2", - Name: "Test 2", - Description: "Test profile 2", - TierNames: []string{"T1", "T2", "T3"}, - ThemeID: "nord", - Logo: "Logo 2 content", - } - - // Test cache miss - t.Run("cache miss", func(t *testing.T) { - _, exists := cache.get("nonexistent") - if exists { - t.Error("Expected cache miss, but got hit") - } - }) - - // Test cache set and get - t.Run("cache set and get", func(t *testing.T) { - cache.set("test1", profile1) - cached, exists := cache.get("test1") - if !exists { - t.Error("Expected cache hit, but got miss") - } - if cached.ID != profile1.ID { - t.Errorf("Expected ID %s, got %s", profile1.ID, cached.ID) - } - }) - - // Test cache returns copy (mutations don't affect cache) - t.Run("cache returns copy", func(t *testing.T) { - cache.set("test2", profile2) - cached, _ := cache.get("test2") - cached.Name = "Modified Name" - - // Get again and verify original value - cached2, _ := cache.get("test2") - if cached2.Name != "Test 2" { - t.Errorf("Expected original name 'Test 2', got %s", cached2.Name) - } - }) - - // Test cache clear - t.Run("cache clear", func(t *testing.T) { - cache.set("test1", profile1) - cache.clear() - _, exists := cache.get("test1") - if exists { - t.Error("Expected cache miss after clear, but got hit") - } - }) -} - -func TestRepositoryGetByIDWithCache(t *testing.T) { - // Create a repository with embedded profiles - repo := &Repository{ - embedded: []Profile{ - { - ID: "enterprise", - Name: "Enterprise", - Description: "Professional theme", - TierNames: []string{"Starter", "Pro", "Enterprise"}, - ThemeID: "fire", - Logo: "Enterprise logo content", - }, - { - ID: "saiyan", - Name: "Saiyan", - Description: "Dragon Ball theme", - TierNames: []string{"Super Saiyan", "SSB", "Ultra Instinct"}, - ThemeID: "fire", - Logo: "Saiyan logo content", - }, - }, - userDefined: make(map[string]Profile), - cache: newProfileCache(100), - validator: DefaultValidator(), - } - - // Test first lookup (cache miss, should populate cache) - t.Run("first lookup populates cache", func(t *testing.T) { - profile, err := repo.GetByID("enterprise") - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - if profile.ID != "enterprise" { - t.Errorf("Expected ID 'enterprise', got %s", profile.ID) - } - - // Verify cache was populated - cached, exists := repo.cache.get("enterprise") - if !exists { - t.Error("Expected cache to be populated after first lookup") - } - if cached.ID != "enterprise" { - t.Errorf("Expected cached ID 'enterprise', got %s", cached.ID) - } - }) - - // Test second lookup (cache hit) - t.Run("second lookup uses cache", func(t *testing.T) { - // Clear embedded to ensure we're hitting cache - originalEmbedded := repo.embedded - repo.embedded = []Profile{} - - profile, err := repo.GetByID("enterprise") - if err != nil { - t.Fatalf("Expected no error from cache, got %v", err) - } - if profile.ID != "enterprise" { - t.Errorf("Expected ID 'enterprise' from cache, got %s", profile.ID) - } - - // Restore embedded - repo.embedded = originalEmbedded - }) - - // Test cache lookup performance (<5ms target) - t.Run("cache lookup performance", func(t *testing.T) { - // Warm up cache - _, _ = repo.GetByID("saiyan") - - start := time.Now() - for i := 0; i < 100; i++ { - _, err := repo.GetByID("saiyan") - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - } - elapsed := time.Since(start) - - avgLookup := elapsed / 100 - if avgLookup > 5*time.Millisecond { - t.Errorf("Average cache lookup time %v exceeds 5ms target", avgLookup) - } - }) - - // Test profile not found - t.Run("profile not found", func(t *testing.T) { - _, err := repo.GetByID("nonexistent") - if err == nil { - t.Error("Expected error for nonexistent profile, got nil") - } - }) -} - -func TestRepositoryUserDefinedOverride(t *testing.T) { - repo := &Repository{ - embedded: []Profile{ - { - ID: "enterprise", - Name: "Enterprise", - Description: "Professional theme", - TierNames: []string{"Starter", "Pro", "Enterprise"}, - ThemeID: "fire", - Logo: "Enterprise logo content", - }, - }, - userDefined: map[string]Profile{ - "enterprise": { - ID: "enterprise", - Name: "Custom Enterprise", - Description: "User customized theme", - TierNames: []string{"Custom 1", "Custom 2", "Custom 3"}, - ThemeID: "nord", - Logo: "Custom logo content", - }, - }, - cache: newProfileCache(100), - validator: DefaultValidator(), - } - - // Test that user-defined profile overrides embedded - t.Run("user-defined overrides embedded", func(t *testing.T) { - profile, err := repo.GetByID("enterprise") - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - if profile.Name != "Custom Enterprise" { - t.Errorf("Expected user-defined name 'Custom Enterprise', got %s", profile.Name) - } - if profile.Description != "User customized theme" { - t.Errorf("Expected user-defined description, got %s", profile.Description) - } - }) -} - -func TestRepositoryClearCache(t *testing.T) { - repo := &Repository{ - embedded: []Profile{ - { - ID: "test", - Name: "Test", - Description: "Test profile", - TierNames: []string{"T1", "T2", "T3"}, - ThemeID: "fire", - Logo: "Test logo content", - }, - }, - userDefined: make(map[string]Profile), - cache: newProfileCache(100), - validator: DefaultValidator(), - } - - // Populate cache - _, _ = repo.GetByID("test") - - // Verify cache has entry - _, exists := repo.cache.get("test") - if !exists { - t.Error("Expected cache to be populated") - } - - // Clear cache - repo.ClearCache() - - // Verify cache is empty - _, exists = repo.cache.get("test") - if exists { - t.Error("Expected cache to be empty after clear") - } -} - -func TestRepositoryValidateUsesComprehensiveValidator(t *testing.T) { - repo := &Repository{ - embedded: []Profile{}, - userDefined: make(map[string]Profile), - cache: newProfileCache(100), - validator: DefaultValidator(), - } - - // Test that Validate uses the comprehensive validator - invalidProfile := &Profile{ - ID: "Invalid ID", // Invalid: has space and uppercase - Name: "Test", - Description: "Test", - TierNames: []string{"T1", "T2", "T3"}, - ThemeID: "fire", - Logo: "Test logo content", - } - - err := repo.Validate(invalidProfile) - if err == nil { - t.Error("Expected validation error for invalid profile") - } - - // Should use comprehensive validator that checks ID format - validProfile := &Profile{ - ID: "valid-id", - Name: "Test", - Description: "Test", - TierNames: []string{"T1", "T2", "T3"}, - ThemeID: "fire", - Logo: "Test logo content", - } - - err = repo.Validate(validProfile) - if err != nil { - t.Errorf("Expected no error for valid profile, got %v", err) - } -} - -func BenchmarkCacheLookup(b *testing.B) { - cache := newProfileCache(100) - profile := &Profile{ - ID: "benchmark", - Name: "Benchmark Profile", - Description: "Profile for benchmarking", - TierNames: []string{"T1", "T2", "T3"}, - ThemeID: "fire", - Logo: "Benchmark logo content", - } - cache.set("benchmark", profile) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _ = cache.get("benchmark") - } -} - -func BenchmarkRepositoryGetByID(b *testing.B) { - repo := &Repository{ - embedded: []Profile{ - { - ID: "benchmark", - Name: "Benchmark", - Description: "Benchmark profile", - TierNames: []string{"T1", "T2", "T3"}, - ThemeID: "fire", - Logo: "Benchmark logo content", - }, - }, - userDefined: make(map[string]Profile), - cache: newProfileCache(100), - validator: DefaultValidator(), - } - - // Warm up cache - _, _ = repo.GetByID("benchmark") - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _ = repo.GetByID("benchmark") - } -} diff --git a/pkg/ui/profiles/profile.go b/pkg/ui/profiles/profile.go deleted file mode 100644 index a4f523c..0000000 --- a/pkg/ui/profiles/profile.go +++ /dev/null @@ -1,66 +0,0 @@ -package profiles - -// Profile represents a franchise-themed profile with custom tier names and branding -type Profile struct { - // ID is the unique identifier (e.g., "enterprise", "saiyan", "jedi") - ID string `yaml:"id"` - - // Name is the display name (e.g., "Enterprise", "Saiyan", "Jedi") - Name string `yaml:"name"` - - // Description explains the profile theme - Description string `yaml:"description"` - - // TierNames maps tier indices to franchise-specific names - // Index 0 = Tier 1 (Starter/Super Saiyan/Padawan) - // Index 1 = Tier 2 (Pro/Super Saiyan Blue/Knight) - // Index 2 = Tier 3 (Ultra/Ultra Instinct/Master) - TierNames []string `yaml:"tier_names"` - - // ThemeID references an existing embedded theme (e.g., "fire", "nord", "cyan-purple") - ThemeID string `yaml:"theme_id"` - - // Logo is the ASCII art representation of "A.R.C." in franchise-appropriate font - // Stored as multi-line string, rendered with theme colors - Logo string `yaml:"logo"` - - // PrimaryColor for profile card branding (optional, falls back to theme) - PrimaryColor string `yaml:"primary_color,omitempty"` - - // SecondaryColor for accents (optional, falls back to theme) - SecondaryColor string `yaml:"secondary_color,omitempty"` -} - -// ProfileRepository loads and manages profiles -type ProfileRepository interface { - // LoadAll returns all available profiles (embedded + user-defined) - LoadAll() ([]Profile, error) - - // GetByID retrieves a specific profile by ID - GetByID(id string) (*Profile, error) - - // Validate checks if a profile has all required fields - Validate(profile *Profile) error -} - -// ProfileResolver resolves tier names based on active profile -type ProfileResolver interface { - // ResolveTierName converts a tier index (0-2) to profile-specific name - // Example: index=0, profile="saiyan" → "Super Saiyan" - ResolveTierName(tierIndex int, profileID string) (string, error) - - // GetActiveTierName returns the tier name for the current workspace profile - GetActiveTierName(tierIndex int) (string, error) -} - -// ProfileRenderer handles ASCII art rendering and animations -type ProfileRenderer interface { - // RenderLogo renders the profile's ASCII logo with theme colors - RenderLogo(profile *Profile) (string, error) - - // RenderWithAnimation renders logo with fade-in effect - RenderWithAnimation(profile *Profile) (string, error) - - // SupportsTerminal checks if terminal width supports logo display - SupportsTerminal() bool -} diff --git a/pkg/ui/profiles/renderer.go b/pkg/ui/profiles/renderer.go deleted file mode 100644 index 3507ea0..0000000 --- a/pkg/ui/profiles/renderer.go +++ /dev/null @@ -1,218 +0,0 @@ -package profiles - -import ( - "fmt" - "strings" - "time" - - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/internal/terminal" - "github.com/arc-framework/arc-cli/pkg/ui/animations" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// Renderer implements ProfileRenderer interface for displaying ASCII art logos with theme colors -type Renderer struct { - themeLoader *themes.Loader - detector *terminal.Detector -} - -// NewRenderer creates a new profile renderer -func NewRenderer() *Renderer { - return &Renderer{ - themeLoader: themes.NewLoader(), - detector: terminal.NewDetector(), - } -} - -// RenderLogo renders the profile's ASCII logo with theme colors -func (r *Renderer) RenderLogo(profile *Profile) (string, error) { - if profile == nil { - return "", fmt.Errorf("profile is nil") - } - - if profile.Logo == "" { - return "", fmt.Errorf("profile has no logo") - } - - // Load theme for the profile - theme, err := r.themeLoader.Load(profile.ThemeID) - if err != nil { - return "", fmt.Errorf("failed to load theme %q: %w", profile.ThemeID, err) - } - - // Apply colors to the logo - return r.applyThemeColors(profile.Logo, theme) -} - -// RenderWithAnimation renders logo with fade-in effect if animations are enabled -func (r *Renderer) RenderWithAnimation(profile *Profile) (string, error) { - if profile == nil { - return "", fmt.Errorf("profile is nil") - } - - if profile.Logo == "" { - return "", fmt.Errorf("profile has no logo") - } - - // Check if animations should be used - if !animations.ShouldAnimate() { - // Fall back to static rendering - return r.RenderLogo(profile) - } - - // Load theme for the profile - theme, err := r.themeLoader.Load(profile.ThemeID) - if err != nil { - return "", fmt.Errorf("failed to load theme %q: %w", profile.ThemeID, err) - } - - // Perform fade-in animation - if animErr := r.fadeInAnimation(profile.Logo, theme); animErr != nil { - // If animation fails, fall back to static rendering - return r.RenderLogo(profile) - } - - // Return the final rendered logo - return r.applyThemeColors(profile.Logo, theme) -} - -// SupportsTerminal checks if terminal width supports logo display -func (r *Renderer) SupportsTerminal() bool { - caps := r.detector.Detect() - - // Check if we're in a TTY first - if !caps.IsTTY { - return false - } - - // Require minimum 80 columns for logo display - return caps.Width >= 80 -} - -// applyThemeColors applies theme colors to the ASCII logo -func (r *Renderer) applyThemeColors(logo string, theme *themes.Theme) (string, error) { - lines := strings.Split(logo, "\n") - gradientColors := theme.Colors.BannerColors() - - // If no gradient colors available, use primary color - if len(gradientColors) == 0 { - primaryColor := theme.Colors.PrimaryColor() - style := lipgloss.NewStyle().Foreground(primaryColor).Bold(true) - - coloredLines := make([]string, 0, len(lines)) - for _, line := range lines { - coloredLines = append(coloredLines, style.Render(line)) - } - return strings.Join(coloredLines, "\n"), nil - } - - // Apply gradient across lines - numLines := len(lines) - coloredLines := make([]string, 0, numLines) - - for i, line := range lines { - // Calculate which color to use based on line position - colorIndex := (i * len(gradientColors)) / maxInt(numLines, 1) - if colorIndex >= len(gradientColors) { - colorIndex = len(gradientColors) - 1 - } - - color := gradientColors[colorIndex] - style := lipgloss.NewStyle().Foreground(color).Bold(true) - coloredLines = append(coloredLines, style.Render(line)) - } - - return strings.Join(coloredLines, "\n"), nil -} - -// fadeInAnimation performs a fade-in effect on the logo -func (r *Renderer) fadeInAnimation(logo string, theme *themes.Theme) error { - config := animations.LoadConfig() - duration := 300 * time.Millisecond // Fixed duration for fade-in - frameTime := time.Second / time.Duration(config.TargetFPS) - - lines := strings.Split(logo, "\n") - gradientColors := theme.Colors.BannerColors() - - // If no gradient colors, use primary color - if len(gradientColors) == 0 { - gradientColors = []lipgloss.Color{theme.Colors.PrimaryColor()} - } - - // Hide cursor during animation - fmt.Print("\033[?25l") - defer fmt.Print("\033[?25h") - - startTime := time.Now() - - for { - elapsed := time.Since(startTime) - if elapsed >= duration { - // Clear the animation - for range lines { - fmt.Print("\033[1A\033[K") // Move up and clear line - } - break - } - - // Calculate fade progress (0.0 to 1.0) - progress := float64(elapsed) / float64(duration) - - // Apply ease-out for smoother animation - smoothProgress := animations.EaseOut(progress) - - // Render frame with faded colors - r.renderFadeFrame(lines, gradientColors, smoothProgress) - - time.Sleep(frameTime) - } - - return nil -} - -// renderFadeFrame renders a single frame of the fade-in animation -func (r *Renderer) renderFadeFrame(lines []string, colors []lipgloss.Color, alpha float64) { - numLines := len(lines) - - for i, line := range lines { - // Calculate which color to use based on line position - colorIndex := (i * len(colors)) / maxInt(numLines, 1) - if colorIndex >= len(colors) { - colorIndex = len(colors) - 1 - } - - color := colors[colorIndex] - - // Apply alpha blending by adjusting opacity - // For terminal colors, we simulate this by blending with background - style := lipgloss.NewStyle(). - Foreground(color). - Bold(true) - - // Only show characters based on progress (fade-in effect) - visibleChars := int(float64(len(line)) * alpha) - visibleLine := "" - if visibleChars > 0 && visibleChars <= len(line) { - visibleLine = line[:visibleChars] - } else if alpha >= 1.0 { - visibleLine = line - } - - fmt.Println(style.Render(visibleLine)) - } - - // Move cursor back up to overwrite in next frame - if len(lines) > 0 { - fmt.Printf("\033[%dA", len(lines)) - } -} - -// maxInt returns the maximum of two integers -func maxInt(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/pkg/ui/profiles/renderer_test.go b/pkg/ui/profiles/renderer_test.go deleted file mode 100644 index fd17b6d..0000000 --- a/pkg/ui/profiles/renderer_test.go +++ /dev/null @@ -1,407 +0,0 @@ -package profiles - -import ( - "os" - "strings" - "testing" - - "github.com/arc-framework/arc-cli/pkg/ui/animations" -) - -func TestNewRenderer(t *testing.T) { - renderer := NewRenderer() - if renderer == nil { - t.Fatal("NewRenderer() returned nil") - } - - if renderer.themeLoader == nil { - t.Error("Renderer.themeLoader is nil") - } - - if renderer.detector == nil { - t.Error("Renderer.detector is nil") - } -} - -func TestRenderer_RenderLogo(t *testing.T) { - // Force color output for testing - os.Setenv("CLICOLOR_FORCE", "1") - os.Setenv("COLORTERM", "truecolor") - defer func() { - os.Unsetenv("CLICOLOR_FORCE") - os.Unsetenv("COLORTERM") - }() - - renderer := NewRenderer() - - tests := []struct { - name string - profile *Profile - wantErr bool - errContains string - }{ - { - name: "nil profile", - profile: nil, - wantErr: true, - errContains: "profile is nil", - }, - { - name: "empty logo", - profile: &Profile{ - ID: "test", - Name: "Test", - ThemeID: "cyan-purple", - Logo: "", - }, - wantErr: true, - errContains: "no logo", - }, - { - name: "invalid theme", - profile: &Profile{ - ID: "test", - Name: "Test", - ThemeID: "nonexistent-theme", - Logo: "TEST", - }, - wantErr: true, - errContains: "failed to load theme", - }, - { - name: "valid profile with cyan-purple theme", - profile: &Profile{ - ID: "test", - Name: "Test", - ThemeID: "cyan-purple", - Logo: ` ___ ____ ____ - / _ \| _ \ / ___| -/ /_\ \ \ |_) | | -\_____/_|_| \_\_____|`, - }, - wantErr: false, - }, - { - name: "valid profile with fire theme", - profile: &Profile{ - ID: "saiyan", - Name: "Saiyan", - ThemeID: "fire", - Logo: `⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡ - -_____ ____ ____ -/ _ \| _ \ / ___| -/ /_\ \ \ |_) | | -/ ___ \ \ _ <| |___ -/_/ \_\_\|_| \_\_____| - -POWER ▰▰▰▰▰▰▰▰▱▱ -⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡`, - }, - wantErr: false, - }, - { - name: "profile with multiline logo", - profile: &Profile{ - ID: "jedi", - Name: "Jedi", - ThemeID: "nord", - Logo: `╔════════════════════════╗ -║ ║ -║ ▄▀█ █▀█ █▀▀ ║ -║ █▀█ █▀▄ █▄▄ ║ -║ ║ -║ ═══⚔ Force ⚔═══ ║ -╚════════════════════════╝`, - }, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := renderer.RenderLogo(tt.profile) - - if tt.wantErr { - if err == nil { - t.Errorf("RenderLogo() expected error containing %q, got nil", tt.errContains) - } else if !strings.Contains(err.Error(), tt.errContains) { - t.Errorf("RenderLogo() error = %v, want error containing %q", err, tt.errContains) - } - return - } - - if err != nil { - t.Errorf("RenderLogo() unexpected error: %v", err) - return - } - - if result == "" { - t.Error("RenderLogo() returned empty string") - } - - // Verify result contains ANSI escape codes (color formatting) - if !strings.Contains(result, "\x1b[") { - t.Error("RenderLogo() result doesn't contain ANSI color codes") - } - }) - } -} - -func TestRenderer_RenderWithAnimation_NoAnimation(t *testing.T) { - // Disable animations for this test - origNoAnimation := animations.NoAnimation - animations.NoAnimation = true - defer func() { animations.NoAnimation = origNoAnimation }() - - renderer := NewRenderer() - profile := &Profile{ - ID: "test", - Name: "Test", - ThemeID: "cyan-purple", - Logo: "TEST LOGO", - } - - result, err := renderer.RenderWithAnimation(profile) - if err != nil { - t.Errorf("RenderWithAnimation() unexpected error: %v", err) - } - - if result == "" { - t.Error("RenderWithAnimation() returned empty string") - } - - // Should fall back to RenderLogo when animations disabled - staticResult, _ := renderer.RenderLogo(profile) - if result != staticResult { - t.Error("RenderWithAnimation() with NoAnimation should match RenderLogo()") - } -} - -func TestRenderer_RenderWithAnimation_NilProfile(t *testing.T) { - renderer := NewRenderer() - - _, err := renderer.RenderWithAnimation(nil) - if err == nil { - t.Error("RenderWithAnimation() expected error for nil profile, got nil") - } - - if !strings.Contains(err.Error(), "profile is nil") { - t.Errorf("RenderWithAnimation() error = %v, want error containing 'profile is nil'", err) - } -} - -func TestRenderer_RenderWithAnimation_EmptyLogo(t *testing.T) { - renderer := NewRenderer() - profile := &Profile{ - ID: "test", - Name: "Test", - ThemeID: "cyan-purple", - Logo: "", - } - - _, err := renderer.RenderWithAnimation(profile) - if err == nil { - t.Error("RenderWithAnimation() expected error for empty logo, got nil") - } - - if !strings.Contains(err.Error(), "no logo") { - t.Errorf("RenderWithAnimation() error = %v, want error containing 'no logo'", err) - } -} - -func TestRenderer_RenderWithAnimation_InvalidTheme(t *testing.T) { - renderer := NewRenderer() - profile := &Profile{ - ID: "test", - Name: "Test", - ThemeID: "invalid-theme-xyz", - Logo: "TEST", - } - - _, err := renderer.RenderWithAnimation(profile) - if err == nil { - t.Error("RenderWithAnimation() expected error for invalid theme, got nil") - } - - if !strings.Contains(err.Error(), "failed to load theme") { - t.Errorf("RenderWithAnimation() error = %v, want error containing 'failed to load theme'", err) - } -} - -func TestRenderer_SupportsTerminal(t *testing.T) { - renderer := NewRenderer() - - // This test will vary based on the actual terminal environment - // We just verify it returns a boolean without panicking - result := renderer.SupportsTerminal() - - // Result should be a valid boolean - _ = result - - // In CI/non-TTY environments, this should typically be false - if os.Getenv("CI") != "" { - if result { - t.Log("Warning: SupportsTerminal() returned true in CI environment") - } - } -} - -func TestRenderer_SupportsTerminal_Width(t *testing.T) { - renderer := NewRenderer() - - // The actual width depends on the terminal, but we can test the logic - // by checking the detector capabilities - caps := renderer.detector.Detect() - - result := renderer.SupportsTerminal() - - // If terminal width < 80 or not a TTY, should return false - if !caps.IsTTY { - if result { - t.Error("SupportsTerminal() should return false for non-TTY") - } - } - - if caps.IsTTY && caps.Width < 80 { - if result { - t.Error("SupportsTerminal() should return false for terminal width < 80") - } - } -} - -func TestRenderer_ApplyThemeColors_GradientDistribution(t *testing.T) { - // Force color output for testing - os.Setenv("CLICOLOR_FORCE", "1") - os.Setenv("COLORTERM", "truecolor") - defer func() { - os.Unsetenv("CLICOLOR_FORCE") - os.Unsetenv("COLORTERM") - }() - - renderer := NewRenderer() - - // Create a multi-line logo to test gradient distribution - logo := `Line 1 -Line 2 -Line 3 -Line 4 -Line 5` - - profile := &Profile{ - ID: "test", - Name: "Test", - ThemeID: "fire", // Fire theme has a good gradient - Logo: logo, - } - - result, err := renderer.RenderLogo(profile) - if err != nil { - t.Fatalf("RenderLogo() unexpected error: %v", err) - } - - // Verify we got a result with multiple lines - lines := strings.Split(result, "\n") - if len(lines) != 5 { - t.Errorf("Expected 5 lines in output, got %d", len(lines)) - } - - // Verify each line has ANSI color codes - for i, line := range lines { - if !strings.Contains(line, "\x1b[") { - t.Errorf("Line %d doesn't contain ANSI color codes", i+1) - } - } -} - -func TestRenderer_ApplyThemeColors_SingleLine(t *testing.T) { - // Force color output for testing - os.Setenv("CLICOLOR_FORCE", "1") - os.Setenv("COLORTERM", "truecolor") - defer func() { - os.Unsetenv("CLICOLOR_FORCE") - os.Unsetenv("COLORTERM") - }() - - renderer := NewRenderer() - - logo := "Single Line Logo" - profile := &Profile{ - ID: "test", - Name: "Test", - ThemeID: "cyan-purple", - Logo: logo, - } - - result, err := renderer.RenderLogo(profile) - if err != nil { - t.Fatalf("RenderLogo() unexpected error: %v", err) - } - - if !strings.Contains(result, "\x1b[") { - t.Error("Single line logo should still have color codes") - } - - if !strings.Contains(result, "Single Line Logo") { - t.Error("Result should contain the original logo text") - } -} - -func TestRenderer_MaxIntHelper(t *testing.T) { - tests := []struct { - name string - a int - b int - want int - }{ - {"a greater", 10, 5, 10}, - {"b greater", 5, 10, 10}, - {"equal", 7, 7, 7}, - {"negative numbers", -5, -10, -5}, - {"zero", 0, 0, 0}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := maxInt(tt.a, tt.b) - if got != tt.want { - t.Errorf("maxInt(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want) - } - }) - } -} - -// BenchmarkRenderer_RenderLogo benchmarks the logo rendering performance -func BenchmarkRenderer_RenderLogo(b *testing.B) { - renderer := NewRenderer() - profile := &Profile{ - ID: "saiyan", - Name: "Saiyan", - ThemeID: "fire", - Logo: `⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡ - -_____ ____ ____ -/ _ \| _ \ / ___| -/ /_\ \ \ |_) | | -/ ___ \ \ _ <| |___ -/_/ \_\_\|_| \_\_____| - -POWER ▰▰▰▰▰▰▰▰▱▱ -⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡`, - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _ = renderer.RenderLogo(profile) - } -} - -// BenchmarkRenderer_SupportsTerminal benchmarks terminal capability detection -func BenchmarkRenderer_SupportsTerminal(b *testing.B) { - renderer := NewRenderer() - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = renderer.SupportsTerminal() - } -} diff --git a/pkg/ui/profiles/resolver.go b/pkg/ui/profiles/resolver.go deleted file mode 100644 index 8154093..0000000 --- a/pkg/ui/profiles/resolver.go +++ /dev/null @@ -1,127 +0,0 @@ -package profiles - -import ( - "fmt" - "sync" - - "github.com/arc-framework/arc-cli/internal/preferences" -) - -const ( - defaultProfileID = "enterprise" -) - -// Resolver resolves tier names based on active profile -type Resolver struct { - repo ProfileRepository - prefs *preferences.Preferences - cache map[string]map[int]string // map[profileID]map[tierIndex]tierName - mu sync.RWMutex // Protects cache access -} - -// NewResolver creates a new ProfileResolver with caching -func NewResolver(repo ProfileRepository, prefs *preferences.Preferences) *Resolver { - return &Resolver{ - repo: repo, - prefs: prefs, - cache: make(map[string]map[int]string), - } -} - -// ResolveTierName converts a tier index (0-2) to profile-specific name -// Example: index=0, profile="saiyan" → "Super Saiyan" -// Results are cached for <1ms subsequent lookups -func (r *Resolver) ResolveTierName(tierIndex int, profileID string) (string, error) { - // Validate tier index first - if tierIndex < 0 || tierIndex > 2 { - return "", fmt.Errorf("invalid tier index %d: must be 0-2", tierIndex) - } - - // Check cache first (read lock) - r.mu.RLock() - if tierMap, exists := r.cache[profileID]; exists { - if tierName, found := tierMap[tierIndex]; found { - r.mu.RUnlock() - return tierName, nil - } - } - r.mu.RUnlock() - - // Cache miss - look up profile - profile, err := r.repo.GetByID(profileID) - if err != nil { - // If profile not found, try fallback to "enterprise" profile - if profileID != defaultProfileID { - return r.resolveFallback(tierIndex) - } - return "", fmt.Errorf("profile %q not found: %w", profileID, err) - } - - // Validate that the tier index is within the TierNames array bounds - if tierIndex >= len(profile.TierNames) { - return "", fmt.Errorf("tier index %d out of bounds for profile %q (has %d tiers)", tierIndex, profileID, len(profile.TierNames)) - } - - tierName := profile.TierNames[tierIndex] - - // Cache the result (write lock) - r.mu.Lock() - if _, exists := r.cache[profileID]; !exists { - r.cache[profileID] = make(map[int]string) - } - r.cache[profileID][tierIndex] = tierName - r.mu.Unlock() - - return tierName, nil -} - -// GetActiveTierName returns the tier name for the current workspace profile -// Uses the ProfileID from preferences, falls back to "enterprise" if missing -func (r *Resolver) GetActiveTierName(tierIndex int) (string, error) { - // Validate tier index first - if tierIndex < 0 || tierIndex > 2 { - return "", fmt.Errorf("invalid tier index %d: must be 0-2", tierIndex) - } - - // Get active profile ID from preferences - // GetProfile() already handles the default to "enterprise" - profileID := r.prefs.GetProfile() - - return r.ResolveTierName(tierIndex, profileID) -} - -// resolveFallback attempts to resolve using the "enterprise" fallback profile -func (r *Resolver) resolveFallback(tierIndex int) (string, error) { - // Check cache first for enterprise profile - r.mu.RLock() - if tierMap, exists := r.cache[defaultProfileID]; exists { - if tierName, found := tierMap[tierIndex]; found { - r.mu.RUnlock() - return tierName, nil - } - } - r.mu.RUnlock() - - // Load enterprise profile - profile, err := r.repo.GetByID(defaultProfileID) - if err != nil { - return "", fmt.Errorf("fallback profile %q not found: %w", defaultProfileID, err) - } - - // Validate tier index - if tierIndex >= len(profile.TierNames) { - return "", fmt.Errorf("tier index %d out of bounds for fallback profile (has %d tiers)", tierIndex, len(profile.TierNames)) - } - - tierName := profile.TierNames[tierIndex] - - // Cache the fallback result - r.mu.Lock() - if _, exists := r.cache[defaultProfileID]; !exists { - r.cache[defaultProfileID] = make(map[int]string) - } - r.cache[defaultProfileID][tierIndex] = tierName - r.mu.Unlock() - - return tierName, nil -} diff --git a/pkg/ui/profiles/resolver_bench_test.go b/pkg/ui/profiles/resolver_bench_test.go deleted file mode 100644 index 91ec416..0000000 --- a/pkg/ui/profiles/resolver_bench_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package profiles_test - -import ( - "testing" - - "github.com/arc-framework/arc-cli/internal/preferences" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -// BenchmarkResolver_ResolveTierName_CacheMiss measures initial lookup performance -func BenchmarkResolver_ResolveTierName_CacheMiss(b *testing.B) { - repo := newMockRepository() - prefs := preferences.Default() - - b.ResetTimer() - for i := 0; i < b.N; i++ { - // Create new resolver each time to ensure cache miss - resolver := profiles.NewResolver(repo, prefs) - _, err := resolver.ResolveTierName(0, "saiyan") - if err != nil { - b.Fatalf("ResolveTierName() error = %v", err) - } - } -} - -// BenchmarkResolver_ResolveTierName_CacheHit measures cached lookup performance -func BenchmarkResolver_ResolveTierName_CacheHit(b *testing.B) { - repo := newMockRepository() - prefs := preferences.Default() - resolver := profiles.NewResolver(repo, prefs) - - // Prime the cache - _, err := resolver.ResolveTierName(0, "saiyan") - if err != nil { - b.Fatalf("ResolveTierName() error = %v", err) - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, err := resolver.ResolveTierName(0, "saiyan") - if err != nil { - b.Fatalf("ResolveTierName() error = %v", err) - } - } -} - -// BenchmarkResolver_GetActiveTierName measures active tier name lookup -func BenchmarkResolver_GetActiveTierName(b *testing.B) { - repo := newMockRepository() - prefs := preferences.Default() - _ = prefs.SetProfile("saiyan") - resolver := profiles.NewResolver(repo, prefs) - - // Prime the cache - _, err := resolver.GetActiveTierName(0) - if err != nil { - b.Fatalf("GetActiveTierName() error = %v", err) - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, err := resolver.GetActiveTierName(0) - if err != nil { - b.Fatalf("GetActiveTierName() error = %v", err) - } - } -} - -// BenchmarkResolver_MultiProfile measures performance with multiple profiles -func BenchmarkResolver_MultiProfile(b *testing.B) { - repo := newMockRepository() - prefs := preferences.Default() - resolver := profiles.NewResolver(repo, prefs) - - profiles := []string{"saiyan", "enterprise", "jedi"} - tiers := []int{0, 1, 2} - - b.ResetTimer() - for i := 0; i < b.N; i++ { - profileID := profiles[i%len(profiles)] - tierIndex := tiers[i%len(tiers)] - _, err := resolver.ResolveTierName(tierIndex, profileID) - if err != nil { - b.Fatalf("ResolveTierName() error = %v", err) - } - } -} - -// BenchmarkResolver_ConcurrentCacheHit measures concurrent cached access performance -func BenchmarkResolver_ConcurrentCacheHit(b *testing.B) { - repo := newMockRepository() - prefs := preferences.Default() - resolver := profiles.NewResolver(repo, prefs) - - // Prime the cache - _, err := resolver.ResolveTierName(0, "saiyan") - if err != nil { - b.Fatalf("ResolveTierName() error = %v", err) - } - - b.ResetTimer() - b.RunParallel(func(pb *testing.PB) { - for pb.Next() { - _, err := resolver.ResolveTierName(0, "saiyan") - if err != nil { - b.Fatalf("ResolveTierName() error = %v", err) - } - } - }) -} diff --git a/pkg/ui/profiles/resolver_test.go b/pkg/ui/profiles/resolver_test.go deleted file mode 100644 index 01feb02..0000000 --- a/pkg/ui/profiles/resolver_test.go +++ /dev/null @@ -1,367 +0,0 @@ -package profiles_test - -import ( - "testing" - - "github.com/arc-framework/arc-cli/internal/preferences" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -// mockRepository implements ProfileRepository for testing -type mockRepository struct { - profiles map[string]profiles.Profile -} - -func newMockRepository() *mockRepository { - repo := &mockRepository{ - profiles: make(map[string]profiles.Profile), - } - // Add test profiles - for _, p := range TestProfiles { - repo.profiles[p.ID] = p - } - return repo -} - -func (m *mockRepository) LoadAll() ([]profiles.Profile, error) { - result := make([]profiles.Profile, 0, len(m.profiles)) - for _, p := range m.profiles { - result = append(result, p) - } - return result, nil -} - -func (m *mockRepository) GetByID(id string) (*profiles.Profile, error) { - if p, exists := m.profiles[id]; exists { - profile := p // Create copy - return &profile, nil - } - return nil, &profileNotFoundError{id: id} -} - -func (m *mockRepository) Validate(profile *profiles.Profile) error { - // Simple validation for tests - if profile.ID == "" { - return &invalidProfileError{msg: "ID is required"} - } - if len(profile.TierNames) != 3 { - return &invalidProfileError{msg: "must have exactly 3 tier names"} - } - return nil -} - -func TestResolver_ResolveTierName(t *testing.T) { - tests := []struct { - name string - profileID string - tierIndex int - want string - wantErr bool - }{ - { - name: "saiyan tier 0", - profileID: "saiyan", - tierIndex: 0, - want: "Super Saiyan", - wantErr: false, - }, - { - name: "saiyan tier 1", - profileID: "saiyan", - tierIndex: 1, - want: "Super Saiyan Blue", - wantErr: false, - }, - { - name: "saiyan tier 2", - profileID: "saiyan", - tierIndex: 2, - want: "Ultra Instinct", - wantErr: false, - }, - { - name: "enterprise tier 0", - profileID: "enterprise", - tierIndex: 0, - want: "Starter", - wantErr: false, - }, - { - name: "enterprise tier 1", - profileID: "enterprise", - tierIndex: 1, - want: "Pro", - wantErr: false, - }, - { - name: "enterprise tier 2", - profileID: "enterprise", - tierIndex: 2, - want: "Ultra", - wantErr: false, - }, - { - name: "jedi tier 0", - profileID: "jedi", - tierIndex: 0, - want: "Padawan", - wantErr: false, - }, - { - name: "jedi tier 1", - profileID: "jedi", - tierIndex: 1, - want: "Knight", - wantErr: false, - }, - { - name: "jedi tier 2", - profileID: "jedi", - tierIndex: 2, - want: "Master", - wantErr: false, - }, - { - name: "invalid tier index -1", - profileID: "saiyan", - tierIndex: -1, - want: "", - wantErr: true, - }, - { - name: "invalid tier index 3", - profileID: "saiyan", - tierIndex: 3, - want: "", - wantErr: true, - }, - { - name: "invalid tier index 100", - profileID: "enterprise", - tierIndex: 100, - want: "", - wantErr: true, - }, - { - name: "unknown profile fallback to enterprise", - profileID: "nonexistent", - tierIndex: 0, - want: "Starter", - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - repo := newMockRepository() - prefs := preferences.Default() - resolver := profiles.NewResolver(repo, prefs) - - got, err := resolver.ResolveTierName(tt.tierIndex, tt.profileID) - if (err != nil) != tt.wantErr { - t.Errorf("ResolveTierName() error = %v, wantErr %v", err, tt.wantErr) - return - } - if got != tt.want { - t.Errorf("ResolveTierName() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestResolver_GetActiveTierName(t *testing.T) { - tests := []struct { - name string - profileID string // Set in preferences - tierIndex int - want string - wantErr bool - }{ - { - name: "active profile saiyan tier 0", - profileID: "saiyan", - tierIndex: 0, - want: "Super Saiyan", - wantErr: false, - }, - { - name: "active profile saiyan tier 1", - profileID: "saiyan", - tierIndex: 1, - want: "Super Saiyan Blue", - wantErr: false, - }, - { - name: "active profile enterprise tier 0", - profileID: "enterprise", - tierIndex: 0, - want: "Starter", - wantErr: false, - }, - { - name: "active profile jedi tier 2", - profileID: "jedi", - tierIndex: 2, - want: "Master", - wantErr: false, - }, - { - name: "empty profile defaults to enterprise", - profileID: "", - tierIndex: 0, - want: "Starter", - wantErr: false, - }, - { - name: "invalid tier index", - profileID: "saiyan", - tierIndex: 5, - want: "", - wantErr: true, - }, - { - name: "invalid tier index -1", - profileID: "enterprise", - tierIndex: -1, - want: "", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - repo := newMockRepository() - prefs := preferences.Default() - _ = prefs.SetProfile(tt.profileID) - resolver := profiles.NewResolver(repo, prefs) - - got, err := resolver.GetActiveTierName(tt.tierIndex) - if (err != nil) != tt.wantErr { - t.Errorf("GetActiveTierName() error = %v, wantErr %v", err, tt.wantErr) - return - } - if got != tt.want { - t.Errorf("GetActiveTierName() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestResolver_Caching(t *testing.T) { - repo := newMockRepository() - prefs := preferences.Default() - _ = prefs.SetProfile("saiyan") - resolver := profiles.NewResolver(repo, prefs) - - // First call - cache miss - got1, err := resolver.ResolveTierName(0, "saiyan") - if err != nil { - t.Fatalf("ResolveTierName() error = %v", err) - } - if got1 != "Super Saiyan" { - t.Errorf("ResolveTierName() = %v, want %v", got1, "Super Saiyan") - } - - // Second call - should be cached (same result) - got2, err := resolver.ResolveTierName(0, "saiyan") - if err != nil { - t.Fatalf("ResolveTierName() error = %v", err) - } - if got2 != "Super Saiyan" { - t.Errorf("ResolveTierName() = %v, want %v", got2, "Super Saiyan") - } - - // Verify both calls return the same result - if got1 != got2 { - t.Errorf("Cached result differs: first=%v, second=%v", got1, got2) - } - - // Test caching for different tier indices - for i := 0; i <= 2; i++ { - _, err := resolver.ResolveTierName(i, "saiyan") - if err != nil { - t.Errorf("ResolveTierName(%d, 'saiyan') error = %v", i, err) - } - } - - // Test caching for different profiles - for _, profileID := range GetTestProfileIDs() { - for i := 0; i <= 2; i++ { - _, err := resolver.ResolveTierName(i, profileID) - if err != nil { - t.Errorf("ResolveTierName(%d, %q) error = %v", i, profileID, err) - } - } - } -} - -func TestResolver_ConcurrentAccess(t *testing.T) { - repo := newMockRepository() - prefs := preferences.Default() - _ = prefs.SetProfile("saiyan") - resolver := profiles.NewResolver(repo, prefs) - - // Test concurrent access to ensure thread safety - done := make(chan bool) - for i := 0; i < 10; i++ { - go func(tierIndex int) { - for j := 0; j < 100; j++ { - _, err := resolver.ResolveTierName(tierIndex%3, "saiyan") - if err != nil { - t.Errorf("ResolveTierName() error = %v", err) - } - } - done <- true - }(i) - } - - // Wait for all goroutines to complete - for i := 0; i < 10; i++ { - <-done - } -} - -func TestResolver_FallbackMechanism(t *testing.T) { - repo := newMockRepository() - prefs := preferences.Default() - resolver := profiles.NewResolver(repo, prefs) - - // Test fallback to enterprise profile when profile not found - got, err := resolver.ResolveTierName(0, "nonexistent") - if err != nil { - t.Fatalf("ResolveTierName() should fallback, error = %v", err) - } - if got != "Starter" { - t.Errorf("ResolveTierName() fallback = %v, want %v", got, "Starter") - } - - // Test all tier indices with fallback to enterprise profile - expectedFallbacks := []string{"Starter", "Pro", "Ultra"} - for i, expected := range expectedFallbacks { - got, err := resolver.ResolveTierName(i, "missing-profile") - if err != nil { - t.Errorf("ResolveTierName(%d, 'missing-profile') should fallback, error = %v", i, err) - continue - } - if got != expected { - t.Errorf("ResolveTierName(%d, 'missing-profile') fallback = %v, want %v", i, got, expected) - } - } -} - -// Error types for testing -type profileNotFoundError struct { - id string -} - -func (e *profileNotFoundError) Error() string { - return "profile \"" + e.id + "\" not found" -} - -type invalidProfileError struct { - msg string -} - -func (e *invalidProfileError) Error() string { - return "invalid profile: " + e.msg -} diff --git a/pkg/ui/profiles/validator.go b/pkg/ui/profiles/validator.go deleted file mode 100644 index a1a2ffa..0000000 --- a/pkg/ui/profiles/validator.go +++ /dev/null @@ -1,253 +0,0 @@ -package profiles - -import ( - "fmt" - "regexp" - "strings" - "unicode" -) - -// Validator provides comprehensive validation for Profile structs -type Validator struct { - validThemeIDs map[string]bool -} - -// NewValidator creates a new Profile validator with known theme IDs -func NewValidator(validThemeIDs []string) *Validator { - themeMap := make(map[string]bool) - for _, id := range validThemeIDs { - themeMap[id] = true - } - return &Validator{ - validThemeIDs: themeMap, - } -} - -// DefaultValidator creates a validator with standard embedded theme IDs -func DefaultValidator() *Validator { - // These are the embedded themes from pkg/ui/themes/embedded/ - standardThemes := []string{ - "cyan-purple", - "dracula", - "fire", - "gruvbox", - "matrix", - "monokai", - "nord", - "ocean", - "rainbow", - "solarized", - } - return NewValidator(standardThemes) -} - -// ValidateProfile performs comprehensive validation on a Profile -func (v *Validator) ValidateProfile(profile *Profile) error { - if profile == nil { - return fmt.Errorf("profile is nil") - } - - // Validate ID format - if err := v.validateID(profile.ID); err != nil { - return err - } - - // Validate required fields - if err := v.validateRequiredFields(profile); err != nil { - return err - } - - // Validate TierNames - if err := v.validateTierNames(profile.TierNames); err != nil { - return err - } - - // Validate ThemeID - if err := v.validateThemeID(profile.ThemeID); err != nil { - return err - } - - // Validate Logo content - if err := v.validateLogo(profile.Logo); err != nil { - return err - } - - // Validate optional color fields - return v.validateColors(profile) -} - -// validateID checks that the profile ID follows the required format: -// - lowercase letters, numbers, and hyphens only -// - no spaces -// - must start with a letter -// - between 2 and 32 characters -func (v *Validator) validateID(id string) error { - if id == "" { - return fmt.Errorf("profile ID is required") - } - - if len(id) < 2 { - return fmt.Errorf("profile ID %q is too short (minimum 2 characters)", id) - } - - if len(id) > 32 { - return fmt.Errorf("profile ID %q is too long (maximum 32 characters)", id) - } - - // Check for spaces - if strings.Contains(id, " ") { - return fmt.Errorf("profile ID %q cannot contain spaces", id) - } - - // Check if lowercase - if id != strings.ToLower(id) { - return fmt.Errorf("profile ID %q must be lowercase", id) - } - - // Must start with a letter - if !unicode.IsLetter(rune(id[0])) { - return fmt.Errorf("profile ID %q must start with a letter", id) - } - - // Validate format: lowercase letters, numbers, and hyphens only - validIDPattern := regexp.MustCompile(`^[a-z][a-z0-9-]*$`) - if !validIDPattern.MatchString(id) { - return fmt.Errorf("profile ID %q contains invalid characters (only lowercase letters, numbers, and hyphens allowed)", id) - } - - return nil -} - -// validateRequiredFields checks that all required string fields are present -func (v *Validator) validateRequiredFields(profile *Profile) error { - if profile.Name == "" { - return fmt.Errorf("profile name is required") - } - - if strings.TrimSpace(profile.Name) == "" { - return fmt.Errorf("profile name cannot be only whitespace") - } - - if profile.Description == "" { - return fmt.Errorf("profile description is required") - } - - if strings.TrimSpace(profile.Description) == "" { - return fmt.Errorf("profile description cannot be only whitespace") - } - - return nil -} - -// validateTierNames checks that TierNames has exactly 3 entries and all are non-empty -func (v *Validator) validateTierNames(tierNames []string) error { - if len(tierNames) != 3 { - return fmt.Errorf("profile must have exactly 3 tier names, got %d", len(tierNames)) - } - - for i, tierName := range tierNames { - if tierName == "" { - return fmt.Errorf("tier name at index %d is empty", i) - } - - if strings.TrimSpace(tierName) == "" { - return fmt.Errorf("tier name at index %d cannot be only whitespace", i) - } - - // Check for reasonable length - if len(tierName) > 50 { - return fmt.Errorf("tier name at index %d is too long (maximum 50 characters): %q", i, tierName) - } - } - - return nil -} - -// validateThemeID checks that the theme_id references a valid embedded theme -func (v *Validator) validateThemeID(themeID string) error { - if themeID == "" { - return fmt.Errorf("profile theme_id is required") - } - - if strings.TrimSpace(themeID) == "" { - return fmt.Errorf("profile theme_id cannot be only whitespace") - } - - // Check against known theme IDs if validator has them - if len(v.validThemeIDs) > 0 { - if !v.validThemeIDs[themeID] { - validIDs := make([]string, 0, len(v.validThemeIDs)) - for id := range v.validThemeIDs { - validIDs = append(validIDs, id) - } - return fmt.Errorf("theme_id %q is not a valid theme (valid themes: %v)", themeID, validIDs) - } - } - - return nil -} - -// validateLogo checks that the logo content is valid ASCII art -func (v *Validator) validateLogo(logo string) error { - if logo == "" { - return fmt.Errorf("profile logo is required") - } - - if strings.TrimSpace(logo) == "" { - return fmt.Errorf("profile logo cannot be only whitespace") - } - - // Check for reasonable size (not too small, not too large) - if len(logo) < 10 { - return fmt.Errorf("profile logo is too small (minimum 10 characters)") - } - - // Maximum size: ~10KB (enough for elaborate ASCII art) - const maxLogoSize = 10 * 1024 - if len(logo) > maxLogoSize { - return fmt.Errorf("profile logo is too large (maximum %d bytes, got %d bytes)", maxLogoSize, len(logo)) - } - - // Check that logo contains at least one printable character besides whitespace - hasPrintable := false - for _, r := range logo { - if unicode.IsPrint(r) && !unicode.IsSpace(r) { - hasPrintable = true - break - } - } - - if !hasPrintable { - return fmt.Errorf("profile logo must contain at least one printable character") - } - - return nil -} - -// validateColors checks optional color fields if present -func (v *Validator) validateColors(profile *Profile) error { - if profile.PrimaryColor != "" { - if err := v.validateHexColor(profile.PrimaryColor, "primary_color"); err != nil { - return err - } - } - - if profile.SecondaryColor != "" { - if err := v.validateHexColor(profile.SecondaryColor, "secondary_color"); err != nil { - return err - } - } - - return nil -} - -// validateHexColor checks if a color string is a valid hex color -func (v *Validator) validateHexColor(color, fieldName string) error { - // Valid hex color formats: #RGB, #RRGGBB, #RRGGBBAA - hexColorPattern := regexp.MustCompile(`^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$`) - if !hexColorPattern.MatchString(color) { - return fmt.Errorf("%s %q is not a valid hex color (expected format: #RRGGBB)", fieldName, color) - } - - return nil -} diff --git a/pkg/ui/profiles/validator_test.go b/pkg/ui/profiles/validator_test.go deleted file mode 100644 index fb92e1e..0000000 --- a/pkg/ui/profiles/validator_test.go +++ /dev/null @@ -1,524 +0,0 @@ -package profiles - -import ( - "strings" - "testing" -) - -func TestValidateID(t *testing.T) { - validator := DefaultValidator() - - tests := []struct { - name string - id string - wantErr bool - errMsg string - }{ - { - name: "valid lowercase ID", - id: "enterprise", - wantErr: false, - }, - { - name: "valid ID with numbers", - id: "profile123", - wantErr: false, - }, - { - name: "valid ID with hyphens", - id: "my-profile", - wantErr: false, - }, - { - name: "valid short ID", - id: "ab", - wantErr: false, - }, - { - name: "empty ID", - id: "", - wantErr: true, - errMsg: "ID is required", - }, - { - name: "ID too short", - id: "a", - wantErr: true, - errMsg: "too short", - }, - { - name: "ID too long", - id: "this-is-a-very-long-profile-id-that-exceeds-maximum", - wantErr: true, - errMsg: "too long", - }, - { - name: "ID with spaces", - id: "my profile", - wantErr: true, - errMsg: "cannot contain spaces", - }, - { - name: "ID with uppercase", - id: "MyProfile", - wantErr: true, - errMsg: "must be lowercase", - }, - { - name: "ID starting with number", - id: "123profile", - wantErr: true, - errMsg: "must start with a letter", - }, - { - name: "ID with special characters", - id: "profile_name", - wantErr: true, - errMsg: "invalid characters", - }, - { - name: "ID with dots", - id: "profile.name", - wantErr: true, - errMsg: "invalid characters", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validator.validateID(tt.id) - if (err != nil) != tt.wantErr { - t.Errorf("validateID() error = %v, wantErr %v", err, tt.wantErr) - return - } - if tt.wantErr && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("validateID() error = %v, want error containing %q", err, tt.errMsg) - } - }) - } -} - -func TestValidateTierNames(t *testing.T) { - validator := DefaultValidator() - - tests := []struct { - name string - tierNames []string - wantErr bool - errMsg string - }{ - { - name: "valid tier names", - tierNames: []string{"Tier 1", "Tier 2", "Tier 3"}, - wantErr: false, - }, - { - name: "valid franchise names", - tierNames: []string{"Super Saiyan", "Super Saiyan Blue", "Ultra Instinct"}, - wantErr: false, - }, - { - name: "too few tier names", - tierNames: []string{"Tier 1", "Tier 2"}, - wantErr: true, - errMsg: "exactly 3 tier names", - }, - { - name: "too many tier names", - tierNames: []string{"Tier 1", "Tier 2", "Tier 3", "Tier 4"}, - wantErr: true, - errMsg: "exactly 3 tier names", - }, - { - name: "empty tier name", - tierNames: []string{"Tier 1", "", "Tier 3"}, - wantErr: true, - errMsg: "index 1 is empty", - }, - { - name: "whitespace only tier name", - tierNames: []string{"Tier 1", " ", "Tier 3"}, - wantErr: true, - errMsg: "only whitespace", - }, - { - name: "tier name too long", - tierNames: []string{"Tier 1", "This is an extremely long tier name that exceeds the maximum allowed length", "Tier 3"}, - wantErr: true, - errMsg: "too long", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validator.validateTierNames(tt.tierNames) - if (err != nil) != tt.wantErr { - t.Errorf("validateTierNames() error = %v, wantErr %v", err, tt.wantErr) - return - } - if tt.wantErr && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("validateTierNames() error = %v, want error containing %q", err, tt.errMsg) - } - }) - } -} - -func TestValidateThemeID(t *testing.T) { - validator := DefaultValidator() - - tests := []struct { - name string - themeID string - wantErr bool - errMsg string - }{ - { - name: "valid theme ID - fire", - themeID: "fire", - wantErr: false, - }, - { - name: "valid theme ID - nord", - themeID: "nord", - wantErr: false, - }, - { - name: "valid theme ID - dracula", - themeID: "dracula", - wantErr: false, - }, - { - name: "empty theme ID", - themeID: "", - wantErr: true, - errMsg: "is required", - }, - { - name: "whitespace only theme ID", - themeID: " ", - wantErr: true, - errMsg: "whitespace", - }, - { - name: "invalid theme ID", - themeID: "nonexistent-theme", - wantErr: true, - errMsg: "not a valid theme", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validator.validateThemeID(tt.themeID) - if (err != nil) != tt.wantErr { - t.Errorf("validateThemeID() error = %v, wantErr %v", err, tt.wantErr) - return - } - if tt.wantErr && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("validateThemeID() error = %v, want error containing %q", err, tt.errMsg) - } - }) - } -} - -func TestValidateLogo(t *testing.T) { - validator := DefaultValidator() - - tests := []struct { - name string - logo string - wantErr bool - errMsg string - }{ - { - name: "valid ASCII logo", - logo: ` - _____ _____ _____ - | _ | | _ | / ___\ - | |_| | | |_| | | | - | _ | | _ | | | - | | | | | | | | | |___ - |_| |_| |_| |_| \_____| -`, - wantErr: false, - }, - { - name: "valid simple logo", - logo: "A.R.C. Logo", - wantErr: false, - }, - { - name: "empty logo", - logo: "", - wantErr: true, - errMsg: "is required", - }, - { - name: "whitespace only logo", - logo: " \n\n\n ", - wantErr: true, - errMsg: "whitespace", - }, - { - name: "logo too small", - logo: "ABC", - wantErr: true, - errMsg: "too small", - }, - { - name: "logo too large", - logo: strings.Repeat("X", 11*1024), // 11KB - wantErr: true, - errMsg: "too large", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validator.validateLogo(tt.logo) - if (err != nil) != tt.wantErr { - t.Errorf("validateLogo() error = %v, wantErr %v", err, tt.wantErr) - return - } - if tt.wantErr && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("validateLogo() error = %v, want error containing %q", err, tt.errMsg) - } - }) - } -} - -func TestValidateHexColor(t *testing.T) { - validator := DefaultValidator() - - tests := []struct { - name string - color string - fieldName string - wantErr bool - }{ - { - name: "valid 6-digit hex", - color: "#FF5733", - fieldName: "primary_color", - wantErr: false, - }, - { - name: "valid 3-digit hex", - color: "#F57", - fieldName: "primary_color", - wantErr: false, - }, - { - name: "valid 8-digit hex with alpha", - color: "#FF5733AA", - fieldName: "primary_color", - wantErr: false, - }, - { - name: "lowercase hex", - color: "#ff5733", - fieldName: "primary_color", - wantErr: false, - }, - { - name: "missing hash", - color: "FF5733", - fieldName: "primary_color", - wantErr: true, - }, - { - name: "invalid characters", - color: "#GG5733", - fieldName: "primary_color", - wantErr: true, - }, - { - name: "too short", - color: "#FF", - fieldName: "primary_color", - wantErr: true, - }, - { - name: "too long", - color: "#FF5733AABB", - fieldName: "primary_color", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validator.validateHexColor(tt.color, tt.fieldName) - if (err != nil) != tt.wantErr { - t.Errorf("validateHexColor() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -func TestValidateProfile(t *testing.T) { - validator := DefaultValidator() - - validProfile := &Profile{ - ID: "enterprise", - Name: "Enterprise", - Description: "Professional corporate theme", - TierNames: []string{"Starter", "Professional", "Enterprise"}, - ThemeID: "fire", - Logo: "ASCII art logo here with enough characters", - } - - tests := []struct { - name string - profile *Profile - wantErr bool - errMsg string - }{ - { - name: "valid profile", - profile: validProfile, - wantErr: false, - }, - { - name: "nil profile", - profile: nil, - wantErr: true, - errMsg: "nil", - }, - { - name: "invalid ID", - profile: &Profile{ - ID: "Invalid ID", - Name: "Test", - Description: "Test", - TierNames: []string{"T1", "T2", "T3"}, - ThemeID: "fire", - Logo: "Logo content here", - }, - wantErr: true, - errMsg: "cannot contain spaces", - }, - { - name: "missing name", - profile: &Profile{ - ID: "test", - Name: "", - Description: "Test", - TierNames: []string{"T1", "T2", "T3"}, - ThemeID: "fire", - Logo: "Logo content here", - }, - wantErr: true, - errMsg: "name is required", - }, - { - name: "invalid tier names count", - profile: &Profile{ - ID: "test", - Name: "Test", - Description: "Test", - TierNames: []string{"T1", "T2"}, - ThemeID: "fire", - Logo: "Logo content here", - }, - wantErr: true, - errMsg: "exactly 3 tier names", - }, - { - name: "invalid theme ID", - profile: &Profile{ - ID: "test", - Name: "Test", - Description: "Test", - TierNames: []string{"T1", "T2", "T3"}, - ThemeID: "nonexistent", - Logo: "Logo content here", - }, - wantErr: true, - errMsg: "not a valid theme", - }, - { - name: "valid profile with optional colors", - profile: &Profile{ - ID: "test", - Name: "Test", - Description: "Test", - TierNames: []string{"T1", "T2", "T3"}, - ThemeID: "fire", - Logo: "Logo content here", - PrimaryColor: "#FF5733", - SecondaryColor: "#3498DB", - }, - wantErr: false, - }, - { - name: "invalid primary color", - profile: &Profile{ - ID: "test", - Name: "Test", - Description: "Test", - TierNames: []string{"T1", "T2", "T3"}, - ThemeID: "fire", - Logo: "Logo content here", - PrimaryColor: "not-a-color", - }, - wantErr: true, - errMsg: "not a valid hex color", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validator.ValidateProfile(tt.profile) - if (err != nil) != tt.wantErr { - t.Errorf("ValidateProfile() error = %v, wantErr %v", err, tt.wantErr) - return - } - if tt.wantErr && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("ValidateProfile() error = %v, want error containing %q", err, tt.errMsg) - } - }) - } -} - -func TestNewValidator(t *testing.T) { - customThemes := []string{"custom1", "custom2", "custom3"} - validator := NewValidator(customThemes) - - // Should accept custom theme IDs - err := validator.validateThemeID("custom1") - if err != nil { - t.Errorf("Expected custom1 to be valid, got error: %v", err) - } - - // Should reject themes not in the custom list - err = validator.validateThemeID("fire") - if err == nil { - t.Errorf("Expected fire to be invalid for custom validator, but got no error") - } -} - -func TestDefaultValidator(t *testing.T) { - validator := DefaultValidator() - - // Should have all standard embedded themes - standardThemes := []string{ - "cyan-purple", - "dracula", - "fire", - "gruvbox", - "matrix", - "monokai", - "nord", - "ocean", - "rainbow", - "solarized", - } - - for _, theme := range standardThemes { - err := validator.validateThemeID(theme) - if err != nil { - t.Errorf("Expected %s to be valid in default validator, got error: %v", theme, err) - } - } -} diff --git a/pkg/ui/service.go b/pkg/ui/service.go deleted file mode 100644 index 342217f..0000000 --- a/pkg/ui/service.go +++ /dev/null @@ -1,261 +0,0 @@ -package ui - -import ( - "fmt" - "io" - "os" - - "github.com/charmbracelet/lipgloss" - "golang.org/x/term" - - "github.com/arc-framework/arc-cli/pkg/log" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// Service provides high-level UI operations with theme support. -// It acts as a middleware between commands and lipgloss styling. -type Service struct { - theme *themes.Theme - logger log.Logger - writer io.Writer -} - -// NewService creates a new UI service with the given theme and logger. -func NewService(theme *themes.Theme, logger log.Logger) *Service { - return &Service{ - theme: theme, - logger: logger, - writer: os.Stdout, - } -} - -// SetWriter sets the output writer (useful for testing). -func (s *Service) SetWriter(w io.Writer) { - s.writer = w -} - -// Theme returns the current theme. -func (s *Service) Theme() *themes.Theme { - return s.theme -} - -// SetTheme updates the current theme. -func (s *Service) SetTheme(theme *themes.Theme) { - s.theme = theme -} - -// styledMessage renders a colored message with a symbol, writes it, and logs via the provided function. -type logFn func(string, ...any) - -func (s *Service) styledMessage(symbol string, color lipgloss.Color, bold bool, log logFn, label, format string, args ...interface{}) { - message := fmt.Sprintf(format, args...) - - style := lipgloss.NewStyle().Foreground(color) - if bold { - style = style.Bold(true) - } - output := fmt.Sprintf("%s %s", style.Render(symbol), message) - - if _, err := fmt.Fprintln(s.writer, output); err != nil { - s.logger.Error("Failed to write "+label+" message", "error", err, "message", message) - return - } - - log(label, "message", message) -} - -// Success prints a success message with the success color and symbol. -func (s *Service) Success(format string, args ...interface{}) { - s.styledMessage(s.theme.Symbols.Success, s.theme.Colors.SuccessColor(), true, s.logger.Info, "Success", format, args...) -} - -// Error prints an error message with the error color and symbol. -func (s *Service) Error(format string, args ...interface{}) { - s.styledMessage(s.theme.Symbols.Error, s.theme.Colors.ErrorColor(), true, s.logger.Error, "Error", format, args...) -} - -// Warning prints a warning message with the warning color and symbol. -func (s *Service) Warning(format string, args ...interface{}) { - s.styledMessage(s.theme.Symbols.Warning, s.theme.Colors.WarningColor(), true, s.logger.Warn, "Warning", format, args...) -} - -// Info prints an informational message with the info color and symbol. -func (s *Service) Info(format string, args ...interface{}) { - s.styledMessage(s.theme.Symbols.Info, s.theme.Colors.InfoColor(), false, s.logger.Info, "Info", format, args...) -} - -// Status prints a status message without a symbol (plain text). -func (s *Service) Status(format string, args ...interface{}) { - message := fmt.Sprintf(format, args...) - if _, err := fmt.Fprintln(s.writer, message); err != nil { - s.logger.Error("Failed to write status message", "error", err, "message", message) - } -} - -// StatusBuilder creates a new status builder for fluent API usage. -// Example: -// -// service.StatusBuilder("Processing...").Do(func() error { -// // Long-running operation -// return nil -// }) -func (s *Service) StatusBuilder(message string) *Builder { - return &Builder{ - service: s, - message: message, - } -} - -// Builder provides a fluent API for creating status messages. -type Builder struct { - service *Service - message string -} - -// Do executes the given function and displays the status message. -// Displays success or error message based on the function result. -func (b *Builder) Do(fn func() error) error { - if _, err := fmt.Fprintln(b.service.writer, b.message); err != nil { - b.service.logger.Error("Failed to write builder message", "error", err, "message", b.message) - } - - err := fn() - if err != nil { - b.service.Error("Failed: %v", err) - return err - } - - b.service.Success("Done") - return nil -} - -// Print executes a function and prints the status without success/error handling. -func (b *Builder) Print(fn func()) { - if _, err := fmt.Fprintln(b.service.writer, b.message); err != nil { - b.service.logger.Error("Failed to write builder message", "error", err, "message", b.message) - } - fn() -} - -// Primary returns a styled string with the primary theme color. -func (s *Service) Primary(text string) string { - style := lipgloss.NewStyle().Foreground(s.theme.Colors.PrimaryColor()) - return style.Render(text) -} - -// Secondary returns a styled string with the secondary theme color. -func (s *Service) Secondary(text string) string { - style := lipgloss.NewStyle().Foreground(s.theme.Colors.SecondaryColor()) - return style.Render(text) -} - -// Muted returns a styled string with the muted theme color. -func (s *Service) Muted(text string) string { - style := lipgloss.NewStyle().Foreground(s.theme.Colors.MutedColor()) - return style.Render(text) -} - -// Bold returns a bold styled string. -func (s *Service) Bold(text string) string { - style := lipgloss.NewStyle().Bold(true) - return style.Render(text) -} - -// Italic returns an italic styled string. -func (s *Service) Italic(text string) string { - style := lipgloss.NewStyle().Italic(true) - return style.Render(text) -} - -// Underline returns an underlined styled string. -func (s *Service) Underline(text string) string { - style := lipgloss.NewStyle().Underline(true) - return style.Render(text) -} - -// ErrorBox renders a themed error box and returns the original error. -// This allows chaining: return ctx.UI.ErrorBox(err, "Context", "Hint") -// -// Usage: -// -// if err := doSomething(); err != nil { -// return ctx.UI.ErrorBox(err, "Failed to do something", "Try doing it differently") -// } -func (s *Service) ErrorBox(err error, context, hint string) error { - opts := components.ErrorOptions{ - Severity: components.SeverityError, - Context: context, - Hint: hint, - Theme: s.theme, - IsTTY: s.isTTY(), - } - - output := components.ErrorBox(err, opts) - if _, writeErr := fmt.Fprintln(s.writer, output); writeErr != nil { - s.logger.Error("Failed to write error box", "error", writeErr, "original_error", err) - } - - // Log the error - s.logger.Error("Error", "error", err, "context", context, "hint", hint) - - return err -} - -// WarningBox renders a themed warning box. -// -// Usage: -// -// ctx.UI.WarningBox("Configuration incomplete", "Some settings will use defaults") -func (s *Service) WarningBox(message, hint string) { - // Create a dummy error from the message - err := fmt.Errorf("%s", message) - - opts := components.ErrorOptions{ - Severity: components.SeverityWarning, - Hint: hint, - Theme: s.theme, - IsTTY: s.isTTY(), - } - - output := components.ErrorBox(err, opts) - if _, writeErr := fmt.Fprintln(s.writer, output); writeErr != nil { - s.logger.Error("Failed to write warning box", "error", writeErr, "message", message) - } - - // Log the warning - s.logger.Warn("Warning", "message", message, "hint", hint) -} - -// InfoBox renders a themed informational box. -// -// Usage: -// -// ctx.UI.InfoBox("Workspace initialized", "You can now run 'arc workspace run'") -func (s *Service) InfoBox(message, hint string) { - // Create a dummy error from the message - err := fmt.Errorf("%s", message) - - opts := components.ErrorOptions{ - Severity: components.SeverityInfo, - Hint: hint, - Theme: s.theme, - IsTTY: s.isTTY(), - } - - output := components.ErrorBox(err, opts) - if _, writeErr := fmt.Fprintln(s.writer, output); writeErr != nil { - s.logger.Error("Failed to write info box", "error", writeErr, "message", message) - } - - // Log the info - s.logger.Info("Info", "message", message, "hint", hint) -} - -// isTTY detects if output is to a terminal. -func (s *Service) isTTY() bool { - if f, ok := s.writer.(*os.File); ok { - return term.IsTerminal(int(f.Fd())) - } - return false -} diff --git a/pkg/ui/service_test.go b/pkg/ui/service_test.go deleted file mode 100644 index 1bff61a..0000000 --- a/pkg/ui/service_test.go +++ /dev/null @@ -1,454 +0,0 @@ -package ui - -import ( - "bytes" - "errors" - "fmt" - "strings" - "testing" - - "github.com/arc-framework/arc-cli/pkg/log" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -func TestNewService(t *testing.T) { - // Note: Not using t.Parallel() - - theme, err := themes.GetDefault() - if err != nil { - t.Fatalf("Failed to get default theme: %v", err) - } - - logger := log.Default() - service := NewService(theme, logger) - - if service == nil { - t.Fatal("NewService returned nil") - } - if service.Theme() != theme { - t.Error("Service theme does not match provided theme") - } -} - -func TestServiceSuccess(t *testing.T) { - // Note: Not using t.Parallel() - - theme, _ := themes.GetDefault() - logger := log.Default() - service := NewService(theme, logger) - - buf := &bytes.Buffer{} - service.SetWriter(buf) - - service.Success("Operation completed") - - output := buf.String() - if output == "" { - t.Error("Success() produced no output") - } - if !strings.Contains(output, "Operation completed") { - t.Errorf("Success() output missing message: %s", output) - } -} - -func TestServiceError(t *testing.T) { - // Note: Not using t.Parallel() - - theme, _ := themes.GetDefault() - logger := log.Default() - service := NewService(theme, logger) - - buf := &bytes.Buffer{} - service.SetWriter(buf) - - service.Error("Operation failed") - - output := buf.String() - if output == "" { - t.Error("Error() produced no output") - } - if !strings.Contains(output, "Operation failed") { - t.Errorf("Error() output missing message: %s", output) - } -} - -func TestServiceWarning(t *testing.T) { - // Note: Not using t.Parallel() - - theme, _ := themes.GetDefault() - logger := log.Default() - service := NewService(theme, logger) - - buf := &bytes.Buffer{} - service.SetWriter(buf) - - service.Warning("Be careful") - - output := buf.String() - if output == "" { - t.Error("Warning() produced no output") - } - if !strings.Contains(output, "Be careful") { - t.Errorf("Warning() output missing message: %s", output) - } -} - -func TestServiceInfo(t *testing.T) { - // Note: Not using t.Parallel() - - theme, _ := themes.GetDefault() - logger := log.Default() - service := NewService(theme, logger) - - buf := &bytes.Buffer{} - service.SetWriter(buf) - - service.Info("For your information") - - output := buf.String() - if output == "" { - t.Error("Info() produced no output") - } - if !strings.Contains(output, "For your information") { - t.Errorf("Info() output missing message: %s", output) - } -} - -func TestServiceStatus(t *testing.T) { - // Note: Not using t.Parallel() - - theme, _ := themes.GetDefault() - logger := log.Default() - service := NewService(theme, logger) - - buf := &bytes.Buffer{} - service.SetWriter(buf) - - service.Status("Processing...") - - output := buf.String() - if output == "" { - t.Error("Status() produced no output") - } - if !strings.Contains(output, "Processing...") { - t.Errorf("Status() output missing message: %s", output) - } -} - -func TestStatusBuilder(t *testing.T) { - // Note: Not using t.Parallel() - - theme, _ := themes.GetDefault() - logger := log.Default() - service := NewService(theme, logger) - - buf := &bytes.Buffer{} - service.SetWriter(buf) - - builder := service.StatusBuilder("Testing...") - if builder == nil { - t.Fatal("StatusBuilder returned nil") - } - if builder.message != "Testing..." { - t.Errorf("StatusBuilder message = %q, want %q", builder.message, "Testing...") - } -} - -func TestBuilderDo_Success(t *testing.T) { - // Note: Not using t.Parallel() - - theme, _ := themes.GetDefault() - logger := log.Default() - service := NewService(theme, logger) - - buf := &bytes.Buffer{} - service.SetWriter(buf) - - executed := false - err := service.StatusBuilder("Working...").Do(func() error { - executed = true - return nil - }) - if err != nil { - t.Errorf("Do() returned error: %v", err) - } - if !executed { - t.Error("Do() did not execute function") - } - - output := buf.String() - if !strings.Contains(output, "Working...") { - t.Error("Do() output missing status message") - } - if !strings.Contains(output, "Done") { - t.Error("Do() output missing success message") - } -} - -func TestBuilderDo_Error(t *testing.T) { - // Note: Not using t.Parallel() - - theme, _ := themes.GetDefault() - logger := log.Default() - service := NewService(theme, logger) - - buf := &bytes.Buffer{} - service.SetWriter(buf) - - executed := false - testErr := fmt.Errorf("test error") - err := service.StatusBuilder("Working...").Do(func() error { - executed = true - return testErr - }) - - if !errors.Is(err, testErr) { - t.Errorf("Do() returned error %v, want %v", err, testErr) - } - if !executed { - t.Error("Do() did not execute function") - } - - output := buf.String() - if !strings.Contains(output, "Working...") { - t.Error("Do() output missing status message") - } - if !strings.Contains(output, "Failed") { - t.Error("Do() output missing error message") - } -} - -func TestBuilderPrint(t *testing.T) { - // Note: Not using t.Parallel() - - theme, _ := themes.GetDefault() - logger := log.Default() - service := NewService(theme, logger) - - buf := &bytes.Buffer{} - service.SetWriter(buf) - - executed := false - service.StatusBuilder("Printing...").Print(func() { - executed = true - }) - - if !executed { - t.Error("Print() did not execute function") - } - - output := buf.String() - if !strings.Contains(output, "Printing...") { - t.Error("Print() output missing status message") - } -} - -func TestServiceStyleHelpers(t *testing.T) { - // Note: Not using t.Parallel() - - theme, _ := themes.GetDefault() - logger := log.Default() - service := NewService(theme, logger) - - tests := []struct { - name string - method func(string) string - input string - }{ - {"Primary", service.Primary, "test"}, - {"Secondary", service.Secondary, "test"}, - {"Muted", service.Muted, "test"}, - {"Bold", service.Bold, "test"}, - {"Italic", service.Italic, "test"}, - {"Underline", service.Underline, "test"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.method(tt.input) - if result == "" { - t.Errorf("%s() returned empty string", tt.name) - } - // The result should contain the input text (possibly with ANSI codes) - // We can't easily test the exact output due to ANSI codes, - // so we just verify it's not empty - }) - } -} - -func TestSetTheme(t *testing.T) { - // Note: Not using t.Parallel() - - theme1, _ := themes.GetDefault() - logger := log.Default() - service := NewService(theme1, logger) - - // Load a different theme - loader := themes.NewLoader() - theme2, err := loader.Load("monokai") - if err != nil { - t.Skipf("Monokai theme not available: %v", err) - } - - service.SetTheme(theme2) - if service.Theme() != theme2 { - t.Error("SetTheme() did not update theme") - } -} - -func TestServiceErrorBox(t *testing.T) { - // Note: Not using t.Parallel() - - theme, _ := themes.GetDefault() - logger := log.Default() - service := NewService(theme, logger) - - buf := &bytes.Buffer{} - service.SetWriter(buf) - - testErr := errors.New("test error") - context := "Failed to do something" - hint := "Try doing it differently" - - returnedErr := service.ErrorBox(testErr, context, hint) - - // Should return the original error - if !errors.Is(returnedErr, testErr) { - t.Errorf("ErrorBox() returned error %v, want %v", returnedErr, testErr) - } - - output := buf.String() - if output == "" { - t.Error("ErrorBox() produced no output") - } - - // Output should contain the error message - if !strings.Contains(output, "test error") { - t.Errorf("ErrorBox() output missing error message: %s", output) - } -} - -func TestServiceErrorBox_WithContext(t *testing.T) { - // Note: Not using t.Parallel() - - theme, _ := themes.GetDefault() - logger := log.Default() - service := NewService(theme, logger) - - buf := &bytes.Buffer{} - service.SetWriter(buf) - - testErr := errors.New("connection refused") - context := "Failed to connect to database" - - service.ErrorBox(testErr, context, "") - - output := buf.String() - // Since we can't easily test for styled output, we just verify output exists - if output == "" { - t.Error("ErrorBox() with context produced no output") - } -} - -func TestServiceErrorBox_WithHint(t *testing.T) { - // Note: Not using t.Parallel() - - theme, _ := themes.GetDefault() - logger := log.Default() - service := NewService(theme, logger) - - buf := &bytes.Buffer{} - service.SetWriter(buf) - - testErr := errors.New("file not found") - hint := "Check the file path" - - service.ErrorBox(testErr, "", hint) - - output := buf.String() - if output == "" { - t.Error("ErrorBox() with hint produced no output") - } -} - -func TestServiceWarningBox(t *testing.T) { - // Note: Not using t.Parallel() - - theme, _ := themes.GetDefault() - logger := log.Default() - service := NewService(theme, logger) - - buf := &bytes.Buffer{} - service.SetWriter(buf) - - message := "Configuration incomplete" - hint := "Some settings will use defaults" - - service.WarningBox(message, hint) - - output := buf.String() - if output == "" { - t.Error("WarningBox() produced no output") - } -} - -func TestServiceWarningBox_NoHint(t *testing.T) { - // Note: Not using t.Parallel() - - theme, _ := themes.GetDefault() - logger := log.Default() - service := NewService(theme, logger) - - buf := &bytes.Buffer{} - service.SetWriter(buf) - - message := "Deprecated feature used" - - service.WarningBox(message, "") - - output := buf.String() - if output == "" { - t.Error("WarningBox() without hint produced no output") - } -} - -func TestServiceInfoBox(t *testing.T) { - // Note: Not using t.Parallel() - - theme, _ := themes.GetDefault() - logger := log.Default() - service := NewService(theme, logger) - - buf := &bytes.Buffer{} - service.SetWriter(buf) - - message := "Workspace initialized" - hint := "You can now run 'arc workspace run'" - - service.InfoBox(message, hint) - - output := buf.String() - if output == "" { - t.Error("InfoBox() produced no output") - } -} - -func TestServiceInfoBox_NoHint(t *testing.T) { - // Note: Not using t.Parallel() - - theme, _ := themes.GetDefault() - logger := log.Default() - service := NewService(theme, logger) - - buf := &bytes.Buffer{} - service.SetWriter(buf) - - message := "Operation successful" - - service.InfoBox(message, "") - - output := buf.String() - if output == "" { - t.Error("InfoBox() without hint produced no output") - } -} diff --git a/pkg/ui/shell/executor.go b/pkg/ui/shell/executor.go new file mode 100644 index 0000000..b37f7be --- /dev/null +++ b/pkg/ui/shell/executor.go @@ -0,0 +1,76 @@ +package shell + +import ( + "bytes" + "context" + "os/exec" + "time" +) + +// Result holds the outcome of a shell command execution. +type Result struct { + Stdout string + Stderr string + ExitCode int + Duration time.Duration + Err error +} + +// Success reports whether the command exited with code 0. +func (r Result) Success() bool { + return r.ExitCode == 0 && r.Err == nil +} + +// Executor runs shell commands with timeout, stderr capture, and exit code reporting. +type Executor struct { + DefaultTimeout time.Duration +} + +// New creates an Executor with the given default timeout. +func New(defaultTimeout time.Duration) *Executor { + return &Executor{DefaultTimeout: defaultTimeout} +} + +// Run executes the given command synchronously. +// Returns a Result with stdout, stderr, exit code, and duration. +func (e *Executor) Run(ctx context.Context, name string, args ...string) Result { + timeout := e.DefaultTimeout + if timeout <= 0 { + timeout = 30 * time.Second + } + + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, name, args...) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + start := time.Now() + err := cmd.Run() + dur := time.Since(start) + + exitCode := 0 + if cmd.ProcessState != nil { + exitCode = cmd.ProcessState.ExitCode() + } + + return Result{ + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: exitCode, + Duration: dur, + Err: err, + } +} + +// RunWithTimeout executes a command with an explicit timeout override. +func (e *Executor) RunWithTimeout(ctx context.Context, timeout time.Duration, name string, args ...string) Result { + orig := e.DefaultTimeout + e.DefaultTimeout = timeout + result := e.Run(ctx, name, args...) + e.DefaultTimeout = orig + return result +} diff --git a/pkg/ui/styles/colors.go b/pkg/ui/styles/colors.go deleted file mode 100644 index be0b5cf..0000000 --- a/pkg/ui/styles/colors.go +++ /dev/null @@ -1,91 +0,0 @@ -// Package styles provides UI styling utilities including colors, emoji, and output formatting. -// Deprecated: This package is being phased out in favor of ProfileContext-based theming. -// New code should use pkg/ui/themes.Theme and pkg/ui.ComponentFactory instead. -package styles - -import ( - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// GetCurrentTheme loads the current theme and returns styles. -// Deprecated: Use ProfileContext.Theme() instead for proper profile-aware theming. -// This function returns Enterprise theme colors as a fallback. -func GetCurrentTheme() ThemeStyles { - // Try to load default theme first - theme, _ := themes.GetDefault() - if theme == nil { - // Try enterprise theme as fallback - loader := themes.NewLoader() - theme, _ = loader.Load("enterprise") - } - if theme == nil { - // Ultimate fallback if theme loading fails - return defaultThemeStyles() - } - - return ThemeStyles{ - Primary: lipgloss.NewStyle().Foreground(theme.Colors.PrimaryColor()), - Secondary: lipgloss.NewStyle().Foreground(theme.Colors.SecondaryColor()), - Success: lipgloss.NewStyle().Foreground(theme.Colors.SuccessColor()), - Error: lipgloss.NewStyle().Foreground(theme.Colors.ErrorColor()), - Warning: lipgloss.NewStyle().Foreground(theme.Colors.WarningColor()), - Info: lipgloss.NewStyle().Foreground(theme.Colors.InfoColor()), - } -} - -// defaultThemeStyles returns hardcoded fallback styles (enterprise theme colors). -func defaultThemeStyles() ThemeStyles { - return ThemeStyles{ - Primary: lipgloss.NewStyle().Foreground(lipgloss.Color("#00ADD8")), - Secondary: lipgloss.NewStyle().Foreground(lipgloss.Color("#6272A4")), - Success: lipgloss.NewStyle().Foreground(lipgloss.Color("#00E091")), - Error: lipgloss.NewStyle().Foreground(lipgloss.Color("#FF4444")), - Warning: lipgloss.NewStyle().Foreground(lipgloss.Color("#FFB86C")), - Info: lipgloss.NewStyle().Foreground(lipgloss.Color("#BD93F9")), - } -} - -// ThemeStyles holds all the styled versions. -// Deprecated: Use themes.Theme.Colors directly instead. -type ThemeStyles struct { - Primary lipgloss.Style - Secondary lipgloss.Style - Success lipgloss.Style - Error lipgloss.Style - Warning lipgloss.Style - Info lipgloss.Style -} - -// Styles - Public API (backward compatible) -// Deprecated: These global variables bypass profile-based theming. -// New code should use ProfileContext.Theme() and create styles dynamically. -var ( - currentStyles = GetCurrentTheme() - PrimaryStyle = currentStyles.Primary - SecondaryStyle = currentStyles.Secondary - SuccessStyle = currentStyles.Success - ErrorStyle = currentStyles.Error - WarningStyle = currentStyles.Warning - InfoStyle = currentStyles.Info - - // CodeStyle for code snippets and commands. - // Deprecated: Use ComponentFactory.CodeBlock() instead. - CodeStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#50FA7B")). - Background(lipgloss.Color("#282A36")). - Padding(0, 1) -) - -// UpdateStylesFromTheme updates all styles based on a theme scheme. -// Deprecated: This function mutates global state. Use ProfileContext-aware components instead. -// Call this after loading state to sync colors with the active theme. -func UpdateStylesFromTheme(primary, secondary, success, errorColor, warning, info lipgloss.Color) { - PrimaryStyle = lipgloss.NewStyle().Foreground(primary) - SecondaryStyle = lipgloss.NewStyle().Foreground(secondary) - SuccessStyle = lipgloss.NewStyle().Foreground(success) - ErrorStyle = lipgloss.NewStyle().Foreground(errorColor) - WarningStyle = lipgloss.NewStyle().Foreground(warning) - InfoStyle = lipgloss.NewStyle().Foreground(info) -} diff --git a/pkg/ui/styles/colors_test.go b/pkg/ui/styles/colors_test.go deleted file mode 100644 index ca1544a..0000000 --- a/pkg/ui/styles/colors_test.go +++ /dev/null @@ -1,320 +0,0 @@ -package styles - -import ( - "strings" - "testing" - - "github.com/charmbracelet/lipgloss" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGetCurrentTheme(t *testing.T) { - theme := GetCurrentTheme() - - // Test that all styles are initialized - assert.NotNil(t, theme.Primary, "Primary style should not be nil") - assert.NotNil(t, theme.Secondary, "Secondary style should not be nil") - assert.NotNil(t, theme.Success, "Success style should not be nil") - assert.NotNil(t, theme.Error, "Error style should not be nil") - assert.NotNil(t, theme.Warning, "Warning style should not be nil") - assert.NotNil(t, theme.Info, "Info style should not be nil") -} - -func TestDefaultThemeStyles(t *testing.T) { - styles := defaultThemeStyles() - - // Test that default styles are created - require.NotNil(t, styles.Primary) - require.NotNil(t, styles.Secondary) - require.NotNil(t, styles.Success) - require.NotNil(t, styles.Error) - require.NotNil(t, styles.Warning) - require.NotNil(t, styles.Info) - - // Test that styles can render text (basic functionality) - testText := "test" - - primaryText := styles.Primary.Render(testText) - assert.Contains(t, primaryText, testText, "should render text") - - successText := styles.Success.Render(testText) - assert.Contains(t, successText, testText, "should render text") -} - -func TestGlobalStyleVariables(t *testing.T) { - // Test that global style variables are initialized - assert.NotNil(t, PrimaryStyle, "PrimaryStyle should be initialized") - assert.NotNil(t, SecondaryStyle, "SecondaryStyle should be initialized") - assert.NotNil(t, SuccessStyle, "SuccessStyle should be initialized") - assert.NotNil(t, ErrorStyle, "ErrorStyle should be initialized") - assert.NotNil(t, WarningStyle, "WarningStyle should be initialized") - assert.NotNil(t, InfoStyle, "InfoStyle should be initialized") -} - -func TestGlobalStyles_Rendering(t *testing.T) { - testText := "test" - - tests := []struct { - name string - style lipgloss.Style - }{ - {"Primary", PrimaryStyle}, - {"Secondary", SecondaryStyle}, - {"Success", SuccessStyle}, - {"Error", ErrorStyle}, - {"Warning", WarningStyle}, - {"Info", InfoStyle}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rendered := tt.style.Render(testText) - // Should render successfully - assert.NotEmpty(t, rendered, "rendered text should not be empty") - }) - } -} - -func TestUpdateStylesFromTheme(t *testing.T) { - // Save original styles - origPrimary := PrimaryStyle - origSecondary := SecondaryStyle - origSuccess := SuccessStyle - origError := ErrorStyle - origWarning := WarningStyle - origInfo := InfoStyle - - // Restore at end of test - defer func() { - PrimaryStyle = origPrimary - SecondaryStyle = origSecondary - SuccessStyle = origSuccess - ErrorStyle = origError - WarningStyle = origWarning - InfoStyle = origInfo - }() - - // Test updating styles with new colors - testColors := struct { - primary lipgloss.Color - secondary lipgloss.Color - success lipgloss.Color - error lipgloss.Color - warning lipgloss.Color - info lipgloss.Color - }{ - primary: lipgloss.Color("#FF0000"), - secondary: lipgloss.Color("#00FF00"), - success: lipgloss.Color("#0000FF"), - error: lipgloss.Color("#FFFF00"), - warning: lipgloss.Color("#FF00FF"), - info: lipgloss.Color("#00FFFF"), - } - - UpdateStylesFromTheme( - testColors.primary, - testColors.secondary, - testColors.success, - testColors.error, - testColors.warning, - testColors.info, - ) - - // Verify styles were updated - assert.NotNil(t, PrimaryStyle, "PrimaryStyle should not be nil after update") - assert.NotNil(t, SecondaryStyle, "SecondaryStyle should not be nil after update") - assert.NotNil(t, SuccessStyle, "SuccessStyle should not be nil after update") - assert.NotNil(t, ErrorStyle, "ErrorStyle should not be nil after update") - assert.NotNil(t, WarningStyle, "WarningStyle should not be nil after update") - assert.NotNil(t, InfoStyle, "InfoStyle should not be nil after update") - - // Test that styles can still render - testText := "test" - rendered := PrimaryStyle.Render(testText) - assert.Contains(t, rendered, testText, "updated style should render text") -} - -func TestUpdateStylesFromTheme_MultipleUpdates(t *testing.T) { - // Save original styles - origPrimary := PrimaryStyle - defer func() { PrimaryStyle = origPrimary }() - - // Update multiple times - colors := []lipgloss.Color{ - lipgloss.Color("#FF0000"), - lipgloss.Color("#00FF00"), - lipgloss.Color("#0000FF"), - } - - for _, color := range colors { - UpdateStylesFromTheme(color, color, color, color, color, color) - - // Verify update worked - assert.NotNil(t, PrimaryStyle, "style should remain valid after update") - - testText := "test" - rendered := PrimaryStyle.Render(testText) - assert.NotEmpty(t, rendered, "style should render after update") - } -} - -func TestThemeStyles_Structure(t *testing.T) { - // Test that ThemeStyles struct can be created - testStyles := ThemeStyles{ - Primary: lipgloss.NewStyle().Foreground(lipgloss.Color("#000000")), - Secondary: lipgloss.NewStyle().Foreground(lipgloss.Color("#111111")), - Success: lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF00")), - Error: lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")), - Warning: lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFF00")), - Info: lipgloss.NewStyle().Foreground(lipgloss.Color("#0000FF")), - } - - assert.NotNil(t, testStyles.Primary) - assert.NotNil(t, testStyles.Secondary) - assert.NotNil(t, testStyles.Success) - assert.NotNil(t, testStyles.Error) - assert.NotNil(t, testStyles.Warning) - assert.NotNil(t, testStyles.Info) -} - -func TestStyles_ConsistentRendering(t *testing.T) { - // Test that styles render consistently - testText := "Hello World" - - styles := defaultThemeStyles() - - // Test each style renders the same text consistently - for i := 0; i < 3; i++ { - rendered1 := styles.Primary.Render(testText) - rendered2 := styles.Primary.Render(testText) - assert.Equal(t, rendered1, rendered2, - "style should render same text consistently (iteration %d)", i) - } -} - -func TestStyles_DifferentColors(t *testing.T) { - // Test that different styles produce different outputs (when colors are enabled) - testText := "test" - styles := defaultThemeStyles() - - primaryRendered := styles.Primary.Render(testText) - successRendered := styles.Success.Render(testText) - errorRendered := styles.Error.Render(testText) - - // All styles should contain the test text - assert.Contains(t, primaryRendered, testText) - assert.Contains(t, successRendered, testText) - assert.Contains(t, errorRendered, testText) -} - -func TestDefaultThemeStyles_Colors(t *testing.T) { - // Test that default theme uses expected colors - styles := defaultThemeStyles() - - testText := "test" - - // All styles should be able to render - outputs := []string{ - styles.Primary.Render(testText), - styles.Secondary.Render(testText), - styles.Success.Render(testText), - styles.Error.Render(testText), - styles.Warning.Render(testText), - styles.Info.Render(testText), - } - - for i, output := range outputs { - assert.NotEmpty(t, output, "style %d should produce non-empty output", i) - assert.Contains(t, output, testText, "style %d should contain original text", i) - } -} - -func TestUpdateStylesFromTheme_EmptyColors(t *testing.T) { - // Test that updating with empty colors doesn't crash - origPrimary := PrimaryStyle - defer func() { PrimaryStyle = origPrimary }() - - // This should not panic - UpdateStylesFromTheme( - lipgloss.Color(""), - lipgloss.Color(""), - lipgloss.Color(""), - lipgloss.Color(""), - lipgloss.Color(""), - lipgloss.Color(""), - ) - - // Styles should still exist - assert.NotNil(t, PrimaryStyle) - - // Should still be able to render (even if without color) - testText := "test" - rendered := PrimaryStyle.Render(testText) - assert.NotEmpty(t, rendered) -} - -func TestStyles_NoColorProfile(t *testing.T) { - // Test rendering works properly - styles := defaultThemeStyles() - testText := "test" - - // Should contain the text - rendered := styles.Primary.Render(testText) - assert.Contains(t, rendered, testText) -} - -func TestCurrentStyles_Initialization(t *testing.T) { - // Test that currentStyles variable is properly initialized - assert.NotNil(t, currentStyles.Primary) - assert.NotNil(t, currentStyles.Secondary) - assert.NotNil(t, currentStyles.Success) - assert.NotNil(t, currentStyles.Error) - assert.NotNil(t, currentStyles.Warning) - assert.NotNil(t, currentStyles.Info) - - // Test rendering with currentStyles - testText := "test" - rendered := currentStyles.Primary.Render(testText) - assert.NotEmpty(t, rendered) -} - -func TestStyles_StringContent(t *testing.T) { - styles := defaultThemeStyles() - - tests := []struct { - name string - style lipgloss.Style - text string - }{ - {"empty string", styles.Primary, ""}, - {"single char", styles.Success, "a"}, - {"word", styles.Error, "word"}, - {"sentence", styles.Warning, "This is a test sentence"}, - {"multiline", styles.Info, "line1\nline2\nline3"}, - {"special chars", styles.Primary, "!@#$%^&*()"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rendered := tt.style.Render(tt.text) - - // Should not panic - assert.NotPanics(t, func() { - _ = tt.style.Render(tt.text) - }) - - // Should contain original text (or be empty if input was empty) - if tt.text != "" { - // For multiline text, check that each line is present - lines := strings.Split(tt.text, "\n") - for _, line := range lines { - if line != "" { - assert.Contains(t, rendered, line, - "rendered output should contain line: %s", line) - } - } - } - }) - } -} diff --git a/pkg/ui/styles/emoji.go b/pkg/ui/styles/emoji.go deleted file mode 100644 index 3a9901d..0000000 --- a/pkg/ui/styles/emoji.go +++ /dev/null @@ -1,23 +0,0 @@ -package styles - -const ( - // Brand & Primary - EmojiBrand = "🌀" // Brand logo, primary headers - - // Status & Feedback - EmojiSuccess = "✅" // Successful operations - EmojiError = "❌" // Errors, failures - EmojiWarning = "⚠️" // Warnings, cautions - EmojiInfo = "ℹ️" // General information - - // Actions - EmojiDeploy = "🚀" // Deploy, start, launch - EmojiInspect = "🔍" // Inspect, debug, search - - // Resources - EmojiBox = "📦" // Resources, containers - - // Special - EmojiAgent = "🤖" // AI agent output - EmojiWait = "⏳" // Loading, processing -) diff --git a/pkg/ui/styles/emoji_test.go b/pkg/ui/styles/emoji_test.go deleted file mode 100644 index 8efba98..0000000 --- a/pkg/ui/styles/emoji_test.go +++ /dev/null @@ -1,186 +0,0 @@ -package styles - -import ( - "testing" -) - -func TestEmojiConstants(t *testing.T) { - emojis := map[string]string{ - "Brand": EmojiBrand, - "Success": EmojiSuccess, - "Error": EmojiError, - "Warning": EmojiWarning, - "Info": EmojiInfo, - "Deploy": EmojiDeploy, - "Inspect": EmojiInspect, - "Box": EmojiBox, - "Agent": EmojiAgent, - "Wait": EmojiWait, - } - - for name, emoji := range emojis { - t.Run(name, func(t *testing.T) { - if emoji == "" { - t.Errorf("Emoji%s is empty", name) - } - if len(emoji) == 0 { - t.Errorf("Emoji%s has zero length", name) - } - }) - } -} - -func TestEmojiUniqueness(t *testing.T) { - emojis := []string{ - EmojiBrand, - EmojiSuccess, - EmojiError, - EmojiWarning, - EmojiInfo, - EmojiDeploy, - EmojiInspect, - EmojiBox, - EmojiAgent, - EmojiWait, - } - - seen := make(map[string]bool) - duplicates := []string{} - - for _, emoji := range emojis { - if seen[emoji] { - duplicates = append(duplicates, emoji) - } - seen[emoji] = true - } - - if len(duplicates) > 0 { - t.Errorf("Found duplicate emojis: %v", duplicates) - } -} - -func TestEmojiRendering(t *testing.T) { - // Test that emojis don't cause panics when used - tests := []struct { - name string - emoji string - }{ - {"brand", EmojiBrand}, - {"success", EmojiSuccess}, - {"error", EmojiError}, - {"warning", EmojiWarning}, - {"info", EmojiInfo}, - {"deploy", EmojiDeploy}, - {"inspect", EmojiInspect}, - {"box", EmojiBox}, - {"agent", EmojiAgent}, - {"wait", EmojiWait}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Should not panic - _ = tt.emoji + " message" - }) - } -} - -func TestEmojiCategoryBrand(t *testing.T) { - if EmojiBrand == "" { - t.Error("EmojiBrand should not be empty") - } -} - -func TestEmojiCategoryStatus(t *testing.T) { - statusEmojis := []string{ - EmojiSuccess, - EmojiError, - EmojiWarning, - EmojiInfo, - } - - for _, emoji := range statusEmojis { - if emoji == "" { - t.Error("Status emoji should not be empty") - } - } -} - -func TestEmojiCategoryActions(t *testing.T) { - actionEmojis := []string{ - EmojiDeploy, - EmojiInspect, - } - - for _, emoji := range actionEmojis { - if emoji == "" { - t.Error("Action emoji should not be empty") - } - } -} - -func TestEmojiCategoryResources(t *testing.T) { - if EmojiBox == "" { - t.Error("EmojiBox should not be empty") - } -} - -func TestEmojiCategorySpecial(t *testing.T) { - specialEmojis := []string{ - EmojiAgent, - EmojiWait, - } - - for _, emoji := range specialEmojis { - if emoji == "" { - t.Error("Special emoji should not be empty") - } - } -} - -func TestEmojiStringLength(t *testing.T) { - // Emojis can be multiple bytes but should have reasonable length - emojis := map[string]string{ - "Brand": EmojiBrand, - "Success": EmojiSuccess, - "Error": EmojiError, - "Warning": EmojiWarning, - "Info": EmojiInfo, - "Deploy": EmojiDeploy, - "Inspect": EmojiInspect, - "Box": EmojiBox, - "Agent": EmojiAgent, - "Wait": EmojiWait, - } - - for name, emoji := range emojis { - length := len([]rune(emoji)) - if length == 0 { - t.Errorf("%s has zero rune length", name) - } - if length > 10 { - t.Errorf("%s has unexpectedly long rune length: %d", name, length) - } - } -} - -func TestEmojiCombinations(t *testing.T) { - // Test that emojis can be combined with text - tests := []struct { - emoji string - text string - }{ - {EmojiSuccess, "Operation successful"}, - {EmojiError, "Operation failed"}, - {EmojiWarning, "Warning message"}, - {EmojiInfo, "Information"}, - {EmojiDeploy, "Deploying..."}, - } - - for _, tt := range tests { - combined := tt.emoji + " " + tt.text - if len(combined) == 0 { - t.Error("Combined emoji+text should not be empty") - } - } -} diff --git a/pkg/ui/styles/output.go b/pkg/ui/styles/output.go deleted file mode 100644 index b5cfac5..0000000 --- a/pkg/ui/styles/output.go +++ /dev/null @@ -1,49 +0,0 @@ -package styles - -import ( - "fmt" - "os" - - "github.com/charmbracelet/lipgloss" -) - -// Output level control -var ( - NoColor = false // Set to true to disable colors -) - -// Success prints a success message -func Success(msg string, args ...interface{}) { - printMsg(EmojiSuccess, &SuccessStyle, msg, args...) -} - -// Error prints an error message -func Error(msg string, args ...interface{}) { - printMsg(EmojiError, &ErrorStyle, msg, args...) -} - -// Info prints an info message -func Info(msg string, args ...interface{}) { - printMsg(EmojiInfo, &InfoStyle, msg, args...) -} - -// Warn prints a warning message -func Warn(msg string, args ...interface{}) { - printMsg(EmojiWarning, &WarningStyle, msg, args...) -} - -// Debug prints a debug message (dimmed) -func Debug(msg string, args ...interface{}) { - style := SecondaryStyle.Faint(true) - printMsg(EmojiInspect, &style, msg, args...) -} - -func printMsg(emoji string, style *lipgloss.Style, msg string, args ...interface{}) { - text := fmt.Sprintf(msg, args...) - - if NoColor { - _, _ = fmt.Fprintf(os.Stdout, "%s %s\n", emoji, text) - } else { - _, _ = fmt.Fprintf(os.Stdout, "%s %s\n", emoji, style.Render(text)) - } -} diff --git a/pkg/ui/styles/output_test.go b/pkg/ui/styles/output_test.go deleted file mode 100644 index 4479325..0000000 --- a/pkg/ui/styles/output_test.go +++ /dev/null @@ -1,354 +0,0 @@ -package styles - -import ( - "bytes" - "io" - "os" - "strings" - "testing" -) - -func TestSuccess(t *testing.T) { - // Capture stdout - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - Success("test message") - - w.Close() - os.Stdout = old - - var buf bytes.Buffer - io.Copy(&buf, r) - output := buf.String() - - if !strings.Contains(output, "test message") { - t.Error("Success() output should contain message") - } - if !strings.Contains(output, EmojiSuccess) { - t.Error("Success() output should contain success emoji") - } -} - -func TestError(t *testing.T) { - // Capture stdout - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - Error("error message") - - w.Close() - os.Stdout = old - - var buf bytes.Buffer - io.Copy(&buf, r) - output := buf.String() - - if !strings.Contains(output, "error message") { - t.Error("Error() output should contain message") - } - if !strings.Contains(output, EmojiError) { - t.Error("Error() output should contain error emoji") - } -} - -func TestInfo(t *testing.T) { - // Capture stdout - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - Info("info message") - - w.Close() - os.Stdout = old - - var buf bytes.Buffer - io.Copy(&buf, r) - output := buf.String() - - if !strings.Contains(output, "info message") { - t.Error("Info() output should contain message") - } - if !strings.Contains(output, EmojiInfo) { - t.Error("Info() output should contain info emoji") - } -} - -func TestWarn(t *testing.T) { - // Capture stdout - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - Warn("warning message") - - w.Close() - os.Stdout = old - - var buf bytes.Buffer - io.Copy(&buf, r) - output := buf.String() - - if !strings.Contains(output, "warning message") { - t.Error("Warn() output should contain message") - } - if !strings.Contains(output, EmojiWarning) { - t.Error("Warn() output should contain warning emoji") - } -} - -func TestDebug(t *testing.T) { - // Capture stdout - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - Debug("debug message") - - w.Close() - os.Stdout = old - - var buf bytes.Buffer - io.Copy(&buf, r) - output := buf.String() - - if !strings.Contains(output, "debug message") { - t.Error("Debug() output should contain message") - } - if !strings.Contains(output, EmojiInspect) { - t.Error("Debug() output should contain inspect emoji") - } -} - -func TestSuccessWithFormatting(t *testing.T) { - // Capture stdout - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - Success("test %s %d", "message", 123) - - w.Close() - os.Stdout = old - - var buf bytes.Buffer - io.Copy(&buf, r) - output := buf.String() - - if !strings.Contains(output, "test message 123") { - t.Error("Success() should support format strings") - } -} - -func TestErrorWithFormatting(t *testing.T) { - // Capture stdout - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - Error("error: %v", "something failed") - - w.Close() - os.Stdout = old - - var buf bytes.Buffer - io.Copy(&buf, r) - output := buf.String() - - if !strings.Contains(output, "error: something failed") { - t.Error("Error() should support format strings") - } -} - -func TestNoColorFlag(t *testing.T) { - // Save original NoColor value - originalNoColor := NoColor - defer func() { NoColor = originalNoColor }() - - // Test with NoColor enabled - NoColor = true - - // Capture stdout - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - Success("no color message") - - w.Close() - os.Stdout = old - - var buf bytes.Buffer - io.Copy(&buf, r) - output := buf.String() - - if !strings.Contains(output, "no color message") { - t.Error("Output should contain message even with NoColor") - } - if !strings.Contains(output, EmojiSuccess) { - t.Error("Output should contain emoji even with NoColor") - } -} - -func TestOutputFunctions(t *testing.T) { - // Save original NoColor value - originalNoColor := NoColor - defer func() { NoColor = originalNoColor }() - - NoColor = true // Simplify output checking - - tests := []struct { - name string - fn func(string, ...interface{}) - message string - wantEmoji string - }{ - {"Success", Success, "success test", EmojiSuccess}, - {"Error", Error, "error test", EmojiError}, - {"Info", Info, "info test", EmojiInfo}, - {"Warn", Warn, "warn test", EmojiWarning}, - {"Debug", Debug, "debug test", EmojiInspect}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Capture stdout - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - tt.fn(tt.message) - - w.Close() - os.Stdout = old - - var buf bytes.Buffer - io.Copy(&buf, r) - output := buf.String() - - if !strings.Contains(output, tt.message) { - t.Errorf("%s() output should contain message", tt.name) - } - if !strings.Contains(output, tt.wantEmoji) { - t.Errorf("%s() output should contain expected emoji", tt.name) - } - }) - } -} - -func TestEmptyMessage(t *testing.T) { - // Capture stdout - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - Success("") - - w.Close() - os.Stdout = old - - var buf bytes.Buffer - io.Copy(&buf, r) - output := buf.String() - - if !strings.Contains(output, EmojiSuccess) { - t.Error("Success() with empty message should still show emoji") - } -} - -func TestMultipleArgs(t *testing.T) { - // Capture stdout - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - Info("Count: %d, Name: %s, Value: %v", 42, "test", true) - - w.Close() - os.Stdout = old - - var buf bytes.Buffer - io.Copy(&buf, r) - output := buf.String() - - if !strings.Contains(output, "Count: 42") { - t.Error("Info() should format integer") - } - if !strings.Contains(output, "Name: test") { - t.Error("Info() should format string") - } - if !strings.Contains(output, "Value: true") { - t.Error("Info() should format boolean") - } -} - -func TestColorFallback(t *testing.T) { - // Save original value - originalNoColor := NoColor - defer func() { NoColor = originalNoColor }() - - // Test with color - NoColor = false - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - Success("with color") - w.Close() - os.Stdout = old - var buf1 bytes.Buffer - io.Copy(&buf1, r) - withColor := buf1.String() - - // Test without color - NoColor = true - r, w, _ = os.Pipe() - os.Stdout = w - Success("without color") - w.Close() - os.Stdout = old - var buf2 bytes.Buffer - io.Copy(&buf2, r) - withoutColor := buf2.String() - - // Both should contain emoji and message - if !strings.Contains(withColor, EmojiSuccess) || !strings.Contains(withColor, "with color") { - t.Error("With color output should contain emoji and message") - } - if !strings.Contains(withoutColor, EmojiSuccess) || !strings.Contains(withoutColor, "without color") { - t.Error("Without color output should contain emoji and message") - } -} - -func TestThemeIntegration(t *testing.T) { - // Test that output functions work with theme system - // This is a smoke test to ensure no panics - originalNoColor := NoColor - defer func() { NoColor = originalNoColor }() - - NoColor = false - - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - // Call all output functions - Success("success") - Error("error") - Info("info") - Warn("warning") - Debug("debug") - - w.Close() - os.Stdout = old - - var buf bytes.Buffer - io.Copy(&buf, r) - output := buf.String() - - if len(output) == 0 { - t.Error("Theme integration test produced no output") - } -} diff --git a/pkg/ui/theme/context.go b/pkg/ui/theme/context.go new file mode 100644 index 0000000..a73c33b --- /dev/null +++ b/pkg/ui/theme/context.go @@ -0,0 +1,194 @@ +package theme + +import ( + "github.com/charmbracelet/lipgloss" +) + +// Context is the unified theme container passed to all components. +// It combines Theme (colors) + Profile (branding) + Skin (layout) + Registry (cache). +// This is the single source of truth for all visual styling. +type Context struct { + theme *Theme + profile *Profile + skin *Skin + registry *Registry +} + +// NewContext creates a new theme Context. +// The returned Context owns its Registry for caching lipgloss.Style objects. +func NewContext(theme *Theme, profile *Profile, skin *Skin) *Context { + return &Context{ + theme: theme, + profile: profile, + skin: skin, + registry: NewRegistry(), + } +} + +// Theme returns the current theme (colors). +func (c *Context) Theme() *Theme { + return c.theme +} + +// Profile returns the current profile (branding). +func (c *Context) Profile() *Profile { + return c.profile +} + +// Skin returns the current skin (layout). +func (c *Context) Skin() *Skin { + return c.skin +} + +// Registry returns the style cache. +func (c *Context) Registry() *Registry { + return c.registry +} + +// Update changes the theme and invalidates cache. +// Returns the updated context (fluent interface). +func (c *Context) WithTheme(theme *Theme) *Context { + c.theme = theme + c.registry.Invalidate() // Clear cache when theme changes + return c +} + +// WithProfile changes the profile and returns the updated context. +func (c *Context) WithProfile(profile *Profile) *Context { + c.profile = profile + return c +} + +// WithSkin changes the skin and returns the updated context. +func (c *Context) WithSkin(skin *Skin) *Context { + c.skin = skin + return c +} + +// Validate checks if the context is properly initialized and valid. +func (c *Context) Validate() error { + if c.theme == nil || c.profile == nil || c.skin == nil { + return ErrContextNotInitialized + } + if err := c.theme.Validate(); err != nil { + return err + } + if err := c.profile.Validate(); err != nil { + return err + } + return c.skin.Validate() +} + +// Style helpers - Cached style creation for common patterns + +// PrimaryStyle returns a cached style with primary color foreground. +func (c *Context) PrimaryStyle() lipgloss.Style { + return c.registry.GetStyle("primary", func() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(c.theme.Colors.ToLipglossColor("primary")) + }) +} + +// SecondaryStyle returns a cached style with secondary color foreground. +func (c *Context) SecondaryStyle() lipgloss.Style { + return c.registry.GetStyle("secondary", func() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(c.theme.Colors.ToLipglossColor("secondary")) + }) +} + +// AccentStyle returns a cached style with accent color foreground. +func (c *Context) AccentStyle() lipgloss.Style { + return c.registry.GetStyle("accent", func() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(c.theme.Colors.ToLipglossColor("accent")) + }) +} + +// SuccessStyle returns a cached style with success color foreground. +func (c *Context) SuccessStyle() lipgloss.Style { + return c.registry.GetStyle("success", func() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(c.theme.Colors.ToLipglossColor("success")) + }) +} + +// WarningStyle returns a cached style with warning color foreground. +func (c *Context) WarningStyle() lipgloss.Style { + return c.registry.GetStyle("warning", func() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(c.theme.Colors.ToLipglossColor("warning")) + }) +} + +// ErrorStyle returns a cached style with error color foreground. +func (c *Context) ErrorStyle() lipgloss.Style { + return c.registry.GetStyle("error", func() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(c.theme.Colors.ToLipglossColor("error")) + }) +} + +// MutedStyle returns a cached style with muted color foreground. +func (c *Context) MutedStyle() lipgloss.Style { + return c.registry.GetStyle("muted", func() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(c.theme.Colors.ToLipglossColor("muted")) + }) +} + +// BorderStyle returns a cached border style based on skin configuration. +func (c *Context) BorderStyle() lipgloss.Border { + switch c.skin.Borders.Style { + case BorderRounded: + return lipgloss.RoundedBorder() + case BorderSquare: + return lipgloss.NormalBorder() + case BorderThick: + return lipgloss.ThickBorder() + case BorderDouble: + return lipgloss.DoubleBorder() + default: + return lipgloss.RoundedBorder() + } +} + +// CardStyle returns a cached style for card components with border and padding. +func (c *Context) CardStyle(width int) lipgloss.Style { + return c.registry.GetStyle("card", func() lipgloss.Style { + padV, padH := c.skin.GetPadding() + return lipgloss.NewStyle(). + Border(c.BorderStyle()). + BorderForeground(c.theme.Colors.ToLipglossColor("border")). + Padding(padV, padH). + Width(width) + }) +} + +// PanelStyle returns a cached style for panel components. +func (c *Context) PanelStyle() lipgloss.Style { + return c.registry.GetStyle("panel", func() lipgloss.Style { + padV, padH := c.skin.GetPadding() + return lipgloss.NewStyle(). + Border(c.BorderStyle()). + BorderForeground(c.theme.Colors.ToLipglossColor("border")). + Padding(padV, padH) + }) +} + +// TitleStyle returns a cached style for titles (bold, primary color). +func (c *Context) TitleStyle() lipgloss.Style { + return c.registry.GetStyle("title", func() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(c.theme.Colors.ToLipglossColor("primary")). + Bold(true) + }) +} + +// SubtitleStyle returns a cached style for subtitles (muted, no bold). +func (c *Context) SubtitleStyle() lipgloss.Style { + return c.registry.GetStyle("subtitle", func() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(c.theme.Colors.ToLipglossColor("muted")) + }) +} diff --git a/pkg/ui/theme/embedded/profiles/.gitkeep b/pkg/ui/theme/embedded/profiles/.gitkeep new file mode 100644 index 0000000..aaea7fb --- /dev/null +++ b/pkg/ui/theme/embedded/profiles/.gitkeep @@ -0,0 +1,3 @@ +# Profile files will be ported from pkg/ui.legacy/profiles/embedded/ +# This directory should contain 10 profile YAML files with branding definitions +# See data-model.md for Profile structure diff --git a/pkg/ui/theme/embedded/profiles/ai.yaml b/pkg/ui/theme/embedded/profiles/ai.yaml new file mode 100644 index 0000000..95255e1 --- /dev/null +++ b/pkg/ui/theme/embedded/profiles/ai.yaml @@ -0,0 +1,18 @@ +id: ai +name: AI Reasoning +description: AI reasoning model stages for ML engineers and AI builders +tier_names: + - Think + - Reason + - Ultra Instinct +theme_id: default +logo: | + ┌─────────────────────┐ + │ │ + │ ▄▀█ █▀█ █▀▀ │ + │ █▀█ █▀▄ █▄▄ │ + │ │ + │ ◈ AI Reasoning ◈ │ + └─────────────────────┘ +primary_color: "#00ADD8" +secondary_color: "#BD93F9" diff --git a/pkg/ui/profiles/embedded/bending.yaml b/pkg/ui/theme/embedded/profiles/bending.yaml similarity index 100% rename from pkg/ui/profiles/embedded/bending.yaml rename to pkg/ui/theme/embedded/profiles/bending.yaml diff --git a/pkg/ui/profiles/embedded/crystal.yaml b/pkg/ui/theme/embedded/profiles/crystal.yaml similarity index 81% rename from pkg/ui/profiles/embedded/crystal.yaml rename to pkg/ui/theme/embedded/profiles/crystal.yaml index 270ca12..3645d1c 100644 --- a/pkg/ui/profiles/embedded/crystal.yaml +++ b/pkg/ui/theme/embedded/profiles/crystal.yaml @@ -8,10 +8,10 @@ tier_names: theme_id: monokai logo: | ✦━━━━━━━━━━━━━━━✦ - - ▄▀█ █▀█ █▀▀ - █▀█ █▀▄ █▄▄ - + + ▄▀█ █▀█ █▀▀ + █▀█ █▀▄ █▄▄ + ✦━━━━━━━━━━━━━━━✦ Crystal Core primary_color: "#AE81FF" diff --git a/pkg/ui/theme/embedded/profiles/default.yaml b/pkg/ui/theme/embedded/profiles/default.yaml new file mode 100644 index 0000000..10a8c2d --- /dev/null +++ b/pkg/ui/theme/embedded/profiles/default.yaml @@ -0,0 +1,24 @@ +id: default +name: Default Profile +description: Default profile for Arc CLI +tier_names: + - Think + - Reason + - Ultra Instinct +theme_id: default +logo: | + ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ + + ______ _______ ______ + / \ / \ / \ + /$$$$$$ |$$$$$$$ |/$$$$$$ | + $$ |__$$ |$$ |__$$ |$$ | $$/ + $$ $$ |$$ $$< $$ | + $$$$$$$$ |$$$$$$$ |$$ | __ + $$ | $$ |$$ | $$ |$$ \__/ | + $$ | $$ |$$ | $$ | $$ $$/ + $$/ $$/ $$/ $$/ $$$$$$/ + + + ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ + Agent Runtime Core diff --git a/pkg/ui/profiles/embedded/enterprise.yaml b/pkg/ui/theme/embedded/profiles/enterprise.yaml similarity index 99% rename from pkg/ui/profiles/embedded/enterprise.yaml rename to pkg/ui/theme/embedded/profiles/enterprise.yaml index 4835ce9..bcdefa5 100644 --- a/pkg/ui/profiles/embedded/enterprise.yaml +++ b/pkg/ui/theme/embedded/profiles/enterprise.yaml @@ -13,7 +13,7 @@ logo: | ██╔══██║██╔══██╗██║ ██║ ██║██║ ██║╚██████╗ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ - + Agentic Reasoning Core primary_color: "#4A90E2" secondary_color: "#7B68EE" diff --git a/pkg/ui/profiles/embedded/horcrux.yaml b/pkg/ui/theme/embedded/profiles/horcrux.yaml similarity index 82% rename from pkg/ui/profiles/embedded/horcrux.yaml rename to pkg/ui/theme/embedded/profiles/horcrux.yaml index d413ad9..424a1c2 100644 --- a/pkg/ui/profiles/embedded/horcrux.yaml +++ b/pkg/ui/theme/embedded/profiles/horcrux.yaml @@ -9,8 +9,8 @@ theme_id: dracula logo: | ═══════════════════ ║ ║ - ║ ▄▀█ █▀█ █▀▀ ║ - ║ █▀█ █▀▄ █▄▄ ║ + ║ ▄▀█ █▀█ █▀▀ ║ + ║ █▀█ █▀▄ █▄▄ ║ ║ ║ ║ ⚡ Hogwarts ⚡ ║ ═══════════════════ diff --git a/pkg/ui/profiles/embedded/jedi.yaml b/pkg/ui/theme/embedded/profiles/jedi.yaml similarity index 57% rename from pkg/ui/profiles/embedded/jedi.yaml rename to pkg/ui/theme/embedded/profiles/jedi.yaml index 00856ea..779d31d 100644 --- a/pkg/ui/profiles/embedded/jedi.yaml +++ b/pkg/ui/theme/embedded/profiles/jedi.yaml @@ -7,12 +7,12 @@ tier_names: - Master theme_id: nord logo: | - ╔════════════════════════╗ - ║ ║ - ║ ▄▀█ █▀█ █▀▀ ║ - ║ █▀█ █▀▄ █▄▄ ║ - ║ ║ - ║ ═══⚔ Force ⚔═══ ║ - ╚════════════════════════╝ + ╔════════════════════╗ + ║ ║ + ║ ▄▀█ █▀█ █▀▀ ║ + ║ █▀█ █▀▄ █▄▄ ║ + ║ ║ + ║ ═══⚔ Force ⚔═══ ║ + ╚════════════════════╝ primary_color: "#81A1C1" secondary_color: "#5E81AC" diff --git a/pkg/ui/profiles/embedded/pirate.yaml b/pkg/ui/theme/embedded/profiles/pirate.yaml similarity index 78% rename from pkg/ui/profiles/embedded/pirate.yaml rename to pkg/ui/theme/embedded/profiles/pirate.yaml index 2c3eee9..309937c 100644 --- a/pkg/ui/profiles/embedded/pirate.yaml +++ b/pkg/ui/theme/embedded/profiles/pirate.yaml @@ -8,11 +8,11 @@ tier_names: theme_id: ocean logo: | ⚓━━━━━━━━━━━━━━━━⚓ - - ▄▀█ █▀█ █▀▀ - █▀█ █▀▄ █▄▄ - + + ▄▀█ █▀█ █▀▀ + █▀█ █▀▄ █▄▄ + ⚓━━━━━━━━━━━━━━━━⚓ - Grand Line + Grand Line primary_color: "#0077BE" secondary_color: "#00A6D6" diff --git a/pkg/ui/theme/embedded/profiles/pokemon.yaml b/pkg/ui/theme/embedded/profiles/pokemon.yaml new file mode 100644 index 0000000..e2bb9b4 --- /dev/null +++ b/pkg/ui/theme/embedded/profiles/pokemon.yaml @@ -0,0 +1,18 @@ +id: pokemon +name: Pokémon +description: Pokémon evolution stages for collectors +tier_names: + - Basic + - Stage 1 + - Stage 2 +theme_id: rainbow +logo: | + ╔════════════════╗ + ║ ║ + ║ ⚪ A.R.C. ⚪ ║ + ║ ║ + ║ ◉─◉─◉ ║ + ║ Evolution ║ + ╚════════════════╝ +primary_color: "#FFCB05" +secondary_color: "#3D7DCA" diff --git a/pkg/ui/theme/embedded/profiles/saiyan.yaml b/pkg/ui/theme/embedded/profiles/saiyan.yaml new file mode 100644 index 0000000..2873245 --- /dev/null +++ b/pkg/ui/theme/embedded/profiles/saiyan.yaml @@ -0,0 +1,25 @@ +id: saiyan +name: Saiyan +description: Dragon Ball Z transformation levels for power users +tier_names: + - Super Saiyan + - Super Saiyan Blue + - Ultra Instinct +theme_id: fire +logo: | + ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ + + ______ _______ ______ + / \ / \ / \ + /$$$$$$ |$$$$$$$ |/$$$$$$ | + $$ |__$$ |$$ |__$$ |$$ | $$/ + $$ $$ |$$ $$< $$ | + $$$$$$$$ |$$$$$$$ |$$ | __ + $$ | $$ |$$ | $$ |$$ \__/ | + $$ | $$ |$$ | $$ | $$ $$/ + $$/ $$/ $$/ $$/ $$$$$$/ + + POWER ▰▰▰▰▰▰▰▰▰▰ MAX + ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ ⚡ +primary_color: "#FFD700" +secondary_color: "#FF6B6B" diff --git a/pkg/ui/profiles/embedded/shinobi.yaml b/pkg/ui/theme/embedded/profiles/shinobi.yaml similarity index 100% rename from pkg/ui/profiles/embedded/shinobi.yaml rename to pkg/ui/theme/embedded/profiles/shinobi.yaml diff --git a/pkg/ui/profiles/embedded/triforce.yaml b/pkg/ui/theme/embedded/profiles/triforce.yaml similarity index 100% rename from pkg/ui/profiles/embedded/triforce.yaml rename to pkg/ui/theme/embedded/profiles/triforce.yaml diff --git a/pkg/ui/theme/embedded/skins/.gitkeep b/pkg/ui/theme/embedded/skins/.gitkeep new file mode 100644 index 0000000..05898da --- /dev/null +++ b/pkg/ui/theme/embedded/skins/.gitkeep @@ -0,0 +1,3 @@ +# Skin files define layout rules (navigation, borders, density) +# This directory should contain 2 skin YAML files: gh-dash.yaml and minimal.yaml +# See data-model.md for Skin structure diff --git a/pkg/ui/theme/embedded/skins/arc.yaml b/pkg/ui/theme/embedded/skins/arc.yaml new file mode 100644 index 0000000..095072f --- /dev/null +++ b/pkg/ui/theme/embedded/skins/arc.yaml @@ -0,0 +1,10 @@ +id: arc +name: Arc +description: Sidebar navigation inspired by GitHub's dashboard layout +navigation: + style: sidebar + position: left +borders: + style: rounded + width: thin +density: comfortable diff --git a/pkg/ui/theme/embedded/skins/default.yaml b/pkg/ui/theme/embedded/skins/default.yaml new file mode 100644 index 0000000..3a7b829 --- /dev/null +++ b/pkg/ui/theme/embedded/skins/default.yaml @@ -0,0 +1,9 @@ +id: default +name: Default Skin +navigation: + style: tab-bar + position: top +borders: + style: rounded + width: normal +density: comfortable diff --git a/pkg/ui/theme/embedded/skins/minimal.yaml b/pkg/ui/theme/embedded/skins/minimal.yaml new file mode 100644 index 0000000..f47b70c --- /dev/null +++ b/pkg/ui/theme/embedded/skins/minimal.yaml @@ -0,0 +1,10 @@ +id: minimal +name: Minimal +description: Clean minimalist interface with compact spacing +navigation: + style: tab-bar + position: top +borders: + style: square + width: thin +density: compact diff --git a/pkg/ui/theme/embedded/themes/.gitkeep b/pkg/ui/theme/embedded/themes/.gitkeep new file mode 100644 index 0000000..2b3f6c8 --- /dev/null +++ b/pkg/ui/theme/embedded/themes/.gitkeep @@ -0,0 +1,3 @@ +# Theme files will be ported from pkg/ui.legacy/themes/embedded/ +# This directory should contain 10 theme YAML files with ColorSet definitions +# See data-model.md for Theme structure diff --git a/pkg/ui/themes/embedded/cyan-purple.yaml b/pkg/ui/theme/embedded/themes/cyan-purple.yaml similarity index 100% rename from pkg/ui/themes/embedded/cyan-purple.yaml rename to pkg/ui/theme/embedded/themes/cyan-purple.yaml diff --git a/pkg/ui/theme/embedded/themes/default.yaml b/pkg/ui/theme/embedded/themes/default.yaml new file mode 100644 index 0000000..81929e0 --- /dev/null +++ b/pkg/ui/theme/embedded/themes/default.yaml @@ -0,0 +1,13 @@ +id: default +name: Default Theme +colors: + primary: "#00ADD8" + secondary: "#6272A4" + accent: "#BD93F9" + background: "#000000" + foreground: "#FFFFFF" + success: "#00E091" + warning: "#FFB86C" + error: "#FF4444" + muted: "#6272A4" + border: "#8FA9DD" diff --git a/pkg/ui/themes/embedded/dracula.yaml b/pkg/ui/theme/embedded/themes/dracula.yaml similarity index 100% rename from pkg/ui/themes/embedded/dracula.yaml rename to pkg/ui/theme/embedded/themes/dracula.yaml diff --git a/pkg/ui/themes/embedded/fire.yaml b/pkg/ui/theme/embedded/themes/fire.yaml similarity index 100% rename from pkg/ui/themes/embedded/fire.yaml rename to pkg/ui/theme/embedded/themes/fire.yaml diff --git a/pkg/ui/themes/embedded/gruvbox.yaml b/pkg/ui/theme/embedded/themes/gruvbox.yaml similarity index 100% rename from pkg/ui/themes/embedded/gruvbox.yaml rename to pkg/ui/theme/embedded/themes/gruvbox.yaml diff --git a/pkg/ui/themes/embedded/matrix.yaml b/pkg/ui/theme/embedded/themes/matrix.yaml similarity index 100% rename from pkg/ui/themes/embedded/matrix.yaml rename to pkg/ui/theme/embedded/themes/matrix.yaml diff --git a/pkg/ui/themes/embedded/monokai.yaml b/pkg/ui/theme/embedded/themes/monokai.yaml similarity index 100% rename from pkg/ui/themes/embedded/monokai.yaml rename to pkg/ui/theme/embedded/themes/monokai.yaml diff --git a/pkg/ui/themes/embedded/nord.yaml b/pkg/ui/theme/embedded/themes/nord.yaml similarity index 100% rename from pkg/ui/themes/embedded/nord.yaml rename to pkg/ui/theme/embedded/themes/nord.yaml diff --git a/pkg/ui/themes/embedded/ocean.yaml b/pkg/ui/theme/embedded/themes/ocean.yaml similarity index 100% rename from pkg/ui/themes/embedded/ocean.yaml rename to pkg/ui/theme/embedded/themes/ocean.yaml diff --git a/pkg/ui/themes/embedded/rainbow.yaml b/pkg/ui/theme/embedded/themes/rainbow.yaml similarity index 100% rename from pkg/ui/themes/embedded/rainbow.yaml rename to pkg/ui/theme/embedded/themes/rainbow.yaml diff --git a/pkg/ui/themes/embedded/solarized.yaml b/pkg/ui/theme/embedded/themes/solarized.yaml similarity index 100% rename from pkg/ui/themes/embedded/solarized.yaml rename to pkg/ui/theme/embedded/themes/solarized.yaml diff --git a/pkg/ui/theme/errors.go b/pkg/ui/theme/errors.go new file mode 100644 index 0000000..3d91293 --- /dev/null +++ b/pkg/ui/theme/errors.go @@ -0,0 +1,46 @@ +package theme + +import "errors" + +// Theme validation errors +var ( + ErrInvalidThemeID = errors.New("theme ID is required and must be lowercase alphanumeric with hyphens") + ErrInvalidThemeName = errors.New("theme name is required") + ErrIncompleteColorSet = errors.New("all 10 color fields are required in ColorSet") + ErrThemeNotFound = errors.New("theme not found") +) + +// Profile validation errors +var ( + ErrInvalidProfileID = errors.New("profile ID is required and must be lowercase alphanumeric with hyphens") + ErrInvalidProfileName = errors.New("profile name is required") + ErrInvalidTierCount = errors.New("profile must have exactly 3 tier names") + ErrMissingThemeID = errors.New("profile must reference a theme ID") + ErrProfileNotFound = errors.New("profile not found") +) + +// Skin validation errors +var ( + ErrInvalidSkinID = errors.New("skin ID is required") + ErrInvalidSkinName = errors.New("skin name is required") + ErrInvalidNavigationStyle = errors.New("navigation style must be 'tab-bar' or 'sidebar'") + ErrInvalidNavigationPosition = errors.New("navigation position must be 'top' or 'left'") + ErrInvalidBorderStyle = errors.New("border style must be 'rounded', 'square', 'thick', or 'double'") + ErrInvalidBorderWidth = errors.New("border width must be 'thin', 'normal', or 'thick'") + ErrInvalidDensity = errors.New("density must be 'compact', 'comfortable', or 'spacious'") + ErrSkinNotFound = errors.New("skin not found") +) + +// Loader errors +var ( + ErrFailedToLoadThemes = errors.New("failed to load themes from embedded filesystem") + ErrFailedToLoadProfiles = errors.New("failed to load profiles from embedded filesystem") + ErrFailedToLoadSkins = errors.New("failed to load skins from embedded filesystem") + ErrInvalidYAML = errors.New("invalid YAML format") +) + +// Context errors +var ( + ErrContextNotInitialized = errors.New("theme context not initialized") + ErrInvalidContext = errors.New("theme context is invalid") +) diff --git a/pkg/ui/theme/loader.go b/pkg/ui/theme/loader.go new file mode 100644 index 0000000..279c171 --- /dev/null +++ b/pkg/ui/theme/loader.go @@ -0,0 +1,287 @@ +package theme + +import ( + "embed" + "fmt" + "io/fs" + "path" + "strings" + + "gopkg.in/yaml.v3" +) + +//go:embed embedded/themes/*.yaml +var themesFS embed.FS + +//go:embed embedded/profiles/*.yaml +var profilesFS embed.FS + +//go:embed embedded/skins/*.yaml +var skinsFS embed.FS + +// Loader handles loading themes, profiles, and skins from embedded YAML files. +type Loader struct { + themes map[string]*Theme + profiles map[string]*Profile + skins map[string]*Skin +} + +// NewLoader creates a new loader and loads all embedded YAML files. +func NewLoader() (*Loader, error) { + l := &Loader{ + themes: make(map[string]*Theme), + profiles: make(map[string]*Profile), + skins: make(map[string]*Skin), + } + + if err := l.loadThemes(); err != nil { + return nil, fmt.Errorf("failed to load themes: %w", err) + } + + if err := l.loadProfiles(); err != nil { + return nil, fmt.Errorf("failed to load profiles: %w", err) + } + + if err := l.loadSkins(); err != nil { + return nil, fmt.Errorf("failed to load skins: %w", err) + } + + return l, nil +} + +// loadThemes loads all theme YAML files from embedded/themes/ +func (l *Loader) loadThemes() error { + return fs.WalkDir(themesFS, "embedded/themes", func(filePath string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip directories and non-YAML files + if d.IsDir() || !strings.HasSuffix(filePath, ".yaml") { + return nil + } + + data, err := themesFS.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read %s: %w", filePath, err) + } + + var theme Theme + if err = yaml.Unmarshal(data, &theme); err != nil { + return fmt.Errorf("failed to parse %s: %w", filePath, err) + } + + // Derive ID from filename for legacy YAMLs that lack an id: field. + if theme.ID == "" { + base := path.Base(filePath) // e.g., "cyan-purple.yaml" + theme.ID = strings.TrimSuffix(base, ".yaml") + } + // Derive display name from ID if name is missing or duplicates the ID. + if theme.Name == "" { + theme.Name = theme.ID + } + // Fall back accent to secondary when not set (legacy format used 'info'). + if theme.Colors.Accent == "" { + theme.Colors.Accent = theme.Colors.Secondary + } + // Fall back muted to secondary, border to muted when not set. + if theme.Colors.Muted == "" { + theme.Colors.Muted = theme.Colors.Secondary + } + if theme.Colors.Border == "" { + theme.Colors.Border = theme.Colors.Muted + } + + if err = theme.Validate(); err != nil { + return fmt.Errorf("invalid theme %s: %w", filePath, err) + } + + l.themes[theme.ID] = &theme + return nil + }) +} + +// loadProfiles loads all profile YAML files from embedded/profiles/ +func (l *Loader) loadProfiles() error { + return fs.WalkDir(profilesFS, "embedded/profiles", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip directories, README, and non-YAML files + if d.IsDir() || strings.HasSuffix(path, "README.md") || !strings.HasSuffix(path, ".yaml") { + return nil + } + + data, err := profilesFS.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read %s: %w", path, err) + } + + var profile Profile + if err = yaml.Unmarshal(data, &profile); err != nil { + return fmt.Errorf("failed to parse %s: %w", path, err) + } + + if err = profile.Validate(); err != nil { + return fmt.Errorf("invalid profile %s: %w", path, err) + } + + l.profiles[profile.ID] = &profile + return nil + }) +} + +// loadSkins loads all skin YAML files from embedded/skins/ +func (l *Loader) loadSkins() error { + return fs.WalkDir(skinsFS, "embedded/skins", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip directories and non-YAML files + if d.IsDir() || !strings.HasSuffix(path, ".yaml") { + return nil + } + + data, err := skinsFS.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read %s: %w", path, err) + } + + var skin Skin + if err = yaml.Unmarshal(data, &skin); err != nil { + return fmt.Errorf("failed to parse %s: %w", path, err) + } + + if err = skin.Validate(); err != nil { + return fmt.Errorf("invalid skin %s: %w", path, err) + } + + l.skins[skin.ID] = &skin + return nil + }) +} + +// GetTheme returns a theme by ID. +func (l *Loader) GetTheme(id string) (*Theme, error) { + theme, exists := l.themes[id] + if !exists { + return nil, fmt.Errorf("%w: %s", ErrThemeNotFound, id) + } + return theme, nil +} + +// GetProfile returns a profile by ID. +func (l *Loader) GetProfile(id string) (*Profile, error) { + profile, exists := l.profiles[id] + if !exists { + return nil, fmt.Errorf("%w: %s", ErrProfileNotFound, id) + } + return profile, nil +} + +// GetSkin returns a skin by ID. +func (l *Loader) GetSkin(id string) (*Skin, error) { + skin, exists := l.skins[id] + if !exists { + return nil, fmt.Errorf("%w: %s", ErrSkinNotFound, id) + } + return skin, nil +} + +// ListThemes returns all available theme IDs. +func (l *Loader) ListThemes() []string { + ids := make([]string, 0, len(l.themes)) + for id := range l.themes { + ids = append(ids, id) + } + return ids +} + +// ListProfiles returns all available profile IDs. +func (l *Loader) ListProfiles() []string { + ids := make([]string, 0, len(l.profiles)) + for id := range l.profiles { + ids = append(ids, id) + } + return ids +} + +// ListSkins returns all available skin IDs. +func (l *Loader) ListSkins() []string { + ids := make([]string, 0, len(l.skins)) + for id := range l.skins { + ids = append(ids, id) + } + return ids +} + +// GetThemes returns all themes. +func (l *Loader) GetThemes() map[string]*Theme { + return l.themes +} + +// GetProfiles returns all profiles. +func (l *Loader) GetProfiles() map[string]*Profile { + return l.profiles +} + +// GetSkins returns all skins. +func (l *Loader) GetSkins() map[string]*Skin { + return l.skins +} + +// LoadContext creates a theme Context from profile/theme/skin IDs. +// This is a convenience method that resolves all references and validates. +func (l *Loader) LoadContext(profileID, skinID string) (*Context, error) { + profile, err := l.GetProfile(profileID) + if err != nil { + return nil, err + } + + theme, err := l.GetTheme(profile.ThemeID) + if err != nil { + return nil, fmt.Errorf("profile %s references invalid theme %s: %w", profileID, profile.ThemeID, err) + } + + skin, err := l.GetSkin(skinID) + if err != nil { + return nil, err + } + + ctx := NewContext(theme, profile, skin) + if err = ctx.Validate(); err != nil { + return nil, fmt.Errorf("context validation failed: %w", err) + } + + return ctx, nil +} + +// DefaultContext returns a context with default theme/profile/skin. +// Falls back to the first available of each if defaults don't exist. +func (l *Loader) DefaultContext() (*Context, error) { + // Try to load default profile (jedi is common in old system) + profileID := "jedi" + if _, err := l.GetProfile(profileID); err != nil { + // Fallback to first available profile + profiles := l.ListProfiles() + if len(profiles) == 0 { + return nil, ErrFailedToLoadProfiles + } + profileID = profiles[0] + } + + // Try to load default skin + skinID := "default" + if _, err := l.GetSkin(skinID); err != nil { + // Fallback to first available skin + skins := l.ListSkins() + if len(skins) == 0 { + return nil, ErrFailedToLoadSkins + } + skinID = skins[0] + } + + return l.LoadContext(profileID, skinID) +} diff --git a/pkg/ui/theme/loader_test.go b/pkg/ui/theme/loader_test.go new file mode 100644 index 0000000..fdb7396 --- /dev/null +++ b/pkg/ui/theme/loader_test.go @@ -0,0 +1,194 @@ +package theme_test + +// T045: Golden file tests for theme system (10 profiles x 2 skins = 20 tests). +// Spec: 018-ui-design, Phase 2 Theme Switching. +// +// Run with -update-golden to regenerate golden files: +// +// go test ./pkg/ui/theme/... -update-golden + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/arc-framework/arc-cli/pkg/ui/theme" +) + +var updateGolden = flag.Bool("update-golden", false, "regenerate golden files instead of comparing") + +// goldenProfiles lists the 10 standard profiles for golden tests (matches spec scope). +var goldenProfiles = []string{ + "enterprise", + "saiyan", + "jedi", + "pirate", + "horcrux", + "pokemon", + "shinobi", + "triforce", + "bending", + "crystal", +} + +// goldenSkins lists the 2 skins tested per profile. +var goldenSkins = []string{"arc", "minimal"} + +// goldenPath returns the path to a golden file stored alongside testdata/. +func goldenPath(dir, name string) string { + return filepath.Join("testdata", "golden", dir, name+".golden") +} + +// compareOrUpdate compares actual output against a golden file. +// When -update-golden is set it writes/overwrites the golden file instead. +func compareOrUpdate(t *testing.T, path, actual string) { + t.Helper() + if *updateGolden { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("create golden dir: %v", err) + } + if err := os.WriteFile(path, []byte(actual), 0o600); err != nil { + t.Fatalf("write golden %s: %v", path, err) + } + t.Logf("updated golden: %s", path) + return + } + expected, err := os.ReadFile(path) + if err != nil { + t.Fatalf("golden file %s not found - run: go test ./pkg/ui/theme/... -update-golden", path) + } + if string(expected) != actual { + t.Errorf("golden mismatch for %s\n--- want ---\n%s\n--- got ---\n%s", path, string(expected), actual) + } +} + +// contextSummary produces a deterministic plain-text digest of a theme.Context. +// This is what the golden files store. +func contextSummary(loader *theme.Loader, profileID, skinID string) (string, error) { + ctx, err := loader.LoadContext(profileID, skinID) + if err != nil { + return "", fmt.Errorf("LoadContext(%s, %s): %w", profileID, skinID, err) + } + + p := ctx.Profile() + th := ctx.Theme() + sk := ctx.Skin() + + var b strings.Builder + fmt.Fprintf(&b, "profile_id: %s\n", p.ID) + fmt.Fprintf(&b, "profile_name: %s\n", p.Name) + fmt.Fprintf(&b, "theme_id: %s\n", th.ID) + fmt.Fprintf(&b, "skin_id: %s\n", sk.ID) + fmt.Fprintf(&b, "skin_nav: %s\n", sk.Navigation.Style) + fmt.Fprintf(&b, "colors:\n") + fmt.Fprintf(&b, " primary: %s\n", th.Colors.Primary) + fmt.Fprintf(&b, " secondary: %s\n", th.Colors.Secondary) + fmt.Fprintf(&b, " accent: %s\n", th.Colors.Accent) + fmt.Fprintf(&b, " background: %s\n", th.Colors.Background) + fmt.Fprintf(&b, " foreground: %s\n", th.Colors.Foreground) + fmt.Fprintf(&b, " success: %s\n", th.Colors.Success) + fmt.Fprintf(&b, " warning: %s\n", th.Colors.Warning) + fmt.Fprintf(&b, " error: %s\n", th.Colors.Error) + fmt.Fprintf(&b, " muted: %s\n", th.Colors.Muted) + fmt.Fprintf(&b, " border: %s\n", th.Colors.Border) + return b.String(), nil +} + +// TestLoader_GoldenContextSummary runs 10 profiles x 2 skins = 20 golden tests. +// Each test verifies that LoadContext produces the expected profile/theme/skin combination. +func TestLoader_GoldenContextSummary(t *testing.T) { + loader, err := theme.NewLoader() + require.NoError(t, err, "NewLoader should succeed") + + for _, profileID := range goldenProfiles { + for _, skinID := range goldenSkins { + name := fmt.Sprintf("%s-%s", profileID, skinID) + t.Run(name, func(t *testing.T) { + actual, err := contextSummary(loader, profileID, skinID) + require.NoError(t, err) + compareOrUpdate(t, goldenPath("themes", name), actual) + }) + } + } +} + +// TestLoader_AllProfilesLoad verifies every embedded profile loads with the arc skin. +func TestLoader_AllProfilesLoad(t *testing.T) { + loader, err := theme.NewLoader() + require.NoError(t, err) + + for _, id := range loader.ListProfiles() { + t.Run(id, func(t *testing.T) { + ctx, err := loader.LoadContext(id, "arc") + require.NoError(t, err, "profile %s should load with arc", id) + require.NotNil(t, ctx) + require.NoError(t, ctx.Validate()) + }) + } +} + +// TestLoader_AllThemesLoad verifies every embedded theme YAML is syntactically valid. +func TestLoader_AllThemesLoad(t *testing.T) { + loader, err := theme.NewLoader() + require.NoError(t, err) + + themes := loader.GetThemes() + require.NotEmpty(t, themes, "must have at least one theme") + + for id, th := range themes { + t.Run(id, func(t *testing.T) { + require.NotEmpty(t, th.ID) + require.NoError(t, th.Validate(), "theme %s must be valid", id) + }) + } +} + +// TestLoader_AllSkinsLoad verifies every embedded skin YAML is syntactically valid. +func TestLoader_AllSkinsLoad(t *testing.T) { + loader, err := theme.NewLoader() + require.NoError(t, err) + + skins := loader.GetSkins() + require.NotEmpty(t, skins, "must have at least one skin") + + for id, sk := range skins { + t.Run(id, func(t *testing.T) { + require.NotEmpty(t, sk.ID) + require.NoError(t, sk.Validate(), "skin %s must be valid", id) + }) + } +} + +// TestLoader_DefaultContext verifies DefaultContext returns a usable, fully-validated context. +func TestLoader_DefaultContext(t *testing.T) { + loader, err := theme.NewLoader() + require.NoError(t, err) + + ctx, err := loader.DefaultContext() + require.NoError(t, err) + require.NotNil(t, ctx) + require.NoError(t, ctx.Validate()) +} + +// TestLoader_MissingProfile verifies that unknown profile IDs return an error. +func TestLoader_MissingProfile(t *testing.T) { + loader, err := theme.NewLoader() + require.NoError(t, err) + + _, err = loader.LoadContext("does-not-exist", "arc") + require.Error(t, err, "unknown profile should fail") +} + +// TestLoader_MissingSkin verifies that unknown skin IDs return an error. +func TestLoader_MissingSkin(t *testing.T) { + loader, err := theme.NewLoader() + require.NoError(t, err) + + _, err = loader.LoadContext("enterprise", "does-not-exist") + require.Error(t, err, "unknown skin should fail") +} diff --git a/pkg/ui/theme/profile.go b/pkg/ui/theme/profile.go new file mode 100644 index 0000000..68ae62c --- /dev/null +++ b/pkg/ui/theme/profile.go @@ -0,0 +1,43 @@ +package theme + +// Profile defines branding (logo, tier names, theme association). +// Profiles are loaded from YAML files in embedded/profiles/ directory. +type Profile struct { + ID string `yaml:"id"` // Unique identifier (e.g., "jedi") + Name string `yaml:"name"` // Display name (e.g., "Jedi") + Description string `yaml:"description"` // Optional description + TierNames []string `yaml:"tier_names"` // Exactly 3 tier names [Tier1, Tier2, Tier3] + Logo string `yaml:"logo"` // ASCII logo (multiline string) + ThemeID string `yaml:"theme_id"` // Reference to Theme.ID + + // Legacy fields from old profiles (for compatibility during migration) + PrimaryColor string `yaml:"primary_color,omitempty"` // Will be removed after migration + SecondaryColor string `yaml:"secondary_color,omitempty"` // Will be removed after migration +} + +// Validate checks if the Profile is valid. +func (p *Profile) Validate() error { + if p.ID == "" { + return ErrInvalidProfileID + } + if p.Name == "" { + return ErrInvalidProfileName + } + if len(p.TierNames) != 3 { + return ErrInvalidTierCount + } + if p.ThemeID == "" { + return ErrMissingThemeID + } + // Logo is optional but recommended + return nil +} + +// GetTier returns the tier name for a given tier level (0-2). +// Returns "Tier N" if tier level is out of bounds. +func (p *Profile) GetTier(level int) string { + if level < 0 || level >= len(p.TierNames) { + return "Tier " + string(rune('1'+level)) + } + return p.TierNames[level] +} diff --git a/pkg/ui/theme/registry.go b/pkg/ui/theme/registry.go new file mode 100644 index 0000000..ee89593 --- /dev/null +++ b/pkg/ui/theme/registry.go @@ -0,0 +1,107 @@ +package theme + +import ( + "sync" + + "github.com/charmbracelet/lipgloss" +) + +// Registry is a thread-safe cache for lipgloss.Style objects. +// Styles are expensive to create, so we cache them by key. +// The cache is invalidated when the theme changes. +type Registry struct { + mu sync.RWMutex + cache map[string]lipgloss.Style +} + +// NewRegistry creates a new style registry with an empty cache. +func NewRegistry() *Registry { + return &Registry{ + cache: make(map[string]lipgloss.Style), + } +} + +// GetStyle returns a cached style or creates it using the factory function. +// If the style doesn't exist in cache, factory() is called to create it. +// The factory function is only called once per key until cache is invalidated. +func (r *Registry) GetStyle(key string, factory func() lipgloss.Style) lipgloss.Style { + // Try to get from cache first (read lock) + r.mu.RLock() + if style, exists := r.cache[key]; exists { + r.mu.RUnlock() + return style + } + r.mu.RUnlock() + + // Not in cache, acquire write lock and create + r.mu.Lock() + defer r.mu.Unlock() + + // Double-check in case another goroutine created it while we waited + if style, exists := r.cache[key]; exists { + return style + } + + // Create and cache the style + style := factory() + r.cache[key] = style + return style +} + +// Set stores a style in the cache with the given key. +// This is useful for pre-caching styles or overriding cached values. +func (r *Registry) Set(key string, style lipgloss.Style) { + r.mu.Lock() + defer r.mu.Unlock() + r.cache[key] = style +} + +// Get retrieves a style from the cache. +// Returns the style and true if found, or zero value and false if not found. +func (r *Registry) Get(key string) (lipgloss.Style, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + style, exists := r.cache[key] + return style, exists +} + +// Has checks if a style exists in the cache without retrieving it. +func (r *Registry) Has(key string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + _, exists := r.cache[key] + return exists +} + +// Delete removes a style from the cache. +func (r *Registry) Delete(key string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.cache, key) +} + +// Invalidate clears the entire cache. +// This should be called when the theme changes to force style recreation. +func (r *Registry) Invalidate() { + r.mu.Lock() + defer r.mu.Unlock() + r.cache = make(map[string]lipgloss.Style) +} + +// Size returns the number of cached styles. +func (r *Registry) Size() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.cache) +} + +// Keys returns all cache keys (for debugging/testing). +func (r *Registry) Keys() []string { + r.mu.RLock() + defer r.mu.RUnlock() + keys := make([]string, 0, len(r.cache)) + for k := range r.cache { + keys = append(keys, k) + } + return keys +} diff --git a/pkg/ui/theme/skin.go b/pkg/ui/theme/skin.go new file mode 100644 index 0000000..5a73157 --- /dev/null +++ b/pkg/ui/theme/skin.go @@ -0,0 +1,131 @@ +package theme + +// Skin defines layout rules (navigation style, borders, density). +// Skins are loaded from YAML files in embedded/skins/ directory. +type Skin struct { + ID string `yaml:"id"` // Unique identifier (e.g., "arc", "minimal") + Name string `yaml:"name"` // Display name (e.g "GitHub Dashboard") + Navigation NavigationStyle `yaml:"navigation"` // Navigation layout configuration + Borders BorderStyle `yaml:"borders"` // Border styling configuration + Density DensityLevel `yaml:"density"` // Spacing/density configuration +} + +// NavigationStyle defines how navigation is rendered (tabs vs sidebar). +type NavigationStyle struct { + Style string `yaml:"style"` // "tab-bar" | "sidebar" + Position string `yaml:"position"` // "top" | "left" +} + +// BorderStyle defines border rendering style. +type BorderStyle struct { + Style string `yaml:"style"` // "rounded" | "square" | "thick" | "double" + Width string `yaml:"width"` // "thin" | "normal" | "thick" +} + +// DensityLevel defines spacing/padding density. +type DensityLevel string + +const ( + DensityCompact DensityLevel = "compact" // Minimal padding, tight spacing + DensityComfortable DensityLevel = "comfortable" // Balanced padding (default) + DensitySpacious DensityLevel = "spacious" // Generous padding, loose spacing +) + +// Navigation style constants +const ( + NavigationTabBar = "tab-bar" // Horizontal tabs at top + NavigationSidebar = "sidebar" // Vertical sidebar at left +) + +// Navigation position constants +const ( + PositionTop = "top" // Top position (for tab-bar) + PositionLeft = "left" // Left position (for sidebar) +) + +// Border style constants +const ( + BorderRounded = "rounded" // Rounded corners + BorderSquare = "square" // Square corners + BorderThick = "thick" // Thick borders + BorderDouble = "double" // Double-line borders +) + +// Border width constants +const ( + BorderThin = "thin" // Thin borders + BorderNormal = "normal" // Normal borders (default) +) + +// Validate checks if the Skin is valid. +func (s *Skin) Validate() error { //nolint:gocyclo,cyclop // validates all skin optional fields with individual nil/bounds checks + if s.ID == "" { + return ErrInvalidSkinID + } + if s.Name == "" { + return ErrInvalidSkinName + } + + // Validate navigation style + if s.Navigation.Style != NavigationTabBar && s.Navigation.Style != NavigationSidebar { + return ErrInvalidNavigationStyle + } + + // Validate navigation position + if s.Navigation.Position != PositionTop && s.Navigation.Position != PositionLeft { + return ErrInvalidNavigationPosition + } + + // Validate border style + if s.Borders.Style != BorderRounded && s.Borders.Style != BorderSquare && + s.Borders.Style != BorderThick && s.Borders.Style != BorderDouble { + return ErrInvalidBorderStyle + } + + // Validate border width + if s.Borders.Width != BorderThin && s.Borders.Width != BorderNormal && s.Borders.Width != BorderThick { + return ErrInvalidBorderWidth + } + + // Validate density + if s.Density != DensityCompact && s.Density != DensityComfortable && s.Density != DensitySpacious { + return ErrInvalidDensity + } + + return nil +} + +// IsTabBar returns true if the navigation style is tab-bar. +func (s *Skin) IsTabBar() bool { + return s.Navigation.Style == NavigationTabBar +} + +// IsSidebar returns true if the navigation style is sidebar. +func (s *Skin) IsSidebar() bool { + return s.Navigation.Style == NavigationSidebar +} + +// GetPadding returns padding values based on density level. +// Returns (vertical, horizontal) padding in terminal cells. +func (s *Skin) GetPadding() (int, int) { + switch s.Density { + case DensityCompact: + return 0, 1 // minimal padding + case DensitySpacious: + return 2, 4 // generous padding + default: // DensityComfortable + return 1, 2 // balanced padding + } +} + +// GetGap returns gap/margin values based on density level. +func (s *Skin) GetGap() int { + switch s.Density { + case DensityCompact: + return 0 + case DensitySpacious: + return 2 + default: // DensityComfortable + return 1 + } +} diff --git a/pkg/ui/theme/testdata/golden/themes/bending-arc.golden b/pkg/ui/theme/testdata/golden/themes/bending-arc.golden new file mode 100644 index 0000000..cbdabfe --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/bending-arc.golden @@ -0,0 +1,16 @@ +profile_id: bending +profile_name: Bending +theme_id: rainbow +skin_id: arc +skin_nav: sidebar +colors: + primary: #FF00FF + secondary: #CCCCCC + accent: #CCCCCC + background: #000000 + foreground: #FFFFFF + success: #00FF00 + warning: #FFFF00 + error: #FF0000 + muted: #999999 + border: #7F00FF diff --git a/pkg/ui/theme/testdata/golden/themes/bending-gh-dash.golden b/pkg/ui/theme/testdata/golden/themes/bending-gh-dash.golden new file mode 100644 index 0000000..aed5a94 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/bending-gh-dash.golden @@ -0,0 +1,16 @@ +profile_id: bending +profile_name: Bending +theme_id: rainbow +skin_id: gh-dash +skin_nav: sidebar +colors: + primary: #FF00FF + secondary: #CCCCCC + accent: #CCCCCC + background: #000000 + foreground: #FFFFFF + success: #00FF00 + warning: #FFFF00 + error: #FF0000 + muted: #999999 + border: #7F00FF diff --git a/pkg/ui/theme/testdata/golden/themes/bending-minimal.golden b/pkg/ui/theme/testdata/golden/themes/bending-minimal.golden new file mode 100644 index 0000000..44da8f1 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/bending-minimal.golden @@ -0,0 +1,16 @@ +profile_id: bending +profile_name: Bending +theme_id: rainbow +skin_id: minimal +skin_nav: tab-bar +colors: + primary: #FF00FF + secondary: #CCCCCC + accent: #CCCCCC + background: #000000 + foreground: #FFFFFF + success: #00FF00 + warning: #FFFF00 + error: #FF0000 + muted: #999999 + border: #7F00FF diff --git a/pkg/ui/theme/testdata/golden/themes/crystal-arc.golden b/pkg/ui/theme/testdata/golden/themes/crystal-arc.golden new file mode 100644 index 0000000..e20a551 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/crystal-arc.golden @@ -0,0 +1,16 @@ +profile_id: crystal +profile_name: Crystal +theme_id: monokai +skin_id: arc +skin_nav: sidebar +colors: + primary: #F92672 + secondary: #66D9EF + accent: #66D9EF + background: #272822 + foreground: #F8F8F2 + success: #A6E22E + warning: #E6DB74 + error: #F92672 + muted: #75715E + border: #3E3D32 diff --git a/pkg/ui/theme/testdata/golden/themes/crystal-gh-dash.golden b/pkg/ui/theme/testdata/golden/themes/crystal-gh-dash.golden new file mode 100644 index 0000000..91660d0 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/crystal-gh-dash.golden @@ -0,0 +1,16 @@ +profile_id: crystal +profile_name: Crystal +theme_id: monokai +skin_id: gh-dash +skin_nav: sidebar +colors: + primary: #F92672 + secondary: #66D9EF + accent: #66D9EF + background: #272822 + foreground: #F8F8F2 + success: #A6E22E + warning: #E6DB74 + error: #F92672 + muted: #75715E + border: #3E3D32 diff --git a/pkg/ui/theme/testdata/golden/themes/crystal-minimal.golden b/pkg/ui/theme/testdata/golden/themes/crystal-minimal.golden new file mode 100644 index 0000000..e929036 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/crystal-minimal.golden @@ -0,0 +1,16 @@ +profile_id: crystal +profile_name: Crystal +theme_id: monokai +skin_id: minimal +skin_nav: tab-bar +colors: + primary: #F92672 + secondary: #66D9EF + accent: #66D9EF + background: #272822 + foreground: #F8F8F2 + success: #A6E22E + warning: #E6DB74 + error: #F92672 + muted: #75715E + border: #3E3D32 diff --git a/pkg/ui/theme/testdata/golden/themes/enterprise-arc.golden b/pkg/ui/theme/testdata/golden/themes/enterprise-arc.golden new file mode 100644 index 0000000..72e35bd --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/enterprise-arc.golden @@ -0,0 +1,16 @@ +profile_id: enterprise +profile_name: Enterprise +theme_id: cyan-purple +skin_id: arc +skin_nav: sidebar +colors: + primary: #00ADD8 + secondary: #6272A4 + accent: #6272A4 + background: #000000 + foreground: #FFFFFF + success: #00E091 + warning: #FFB86C + error: #FF4444 + muted: #6272A4 + border: #8FA9DD diff --git a/pkg/ui/theme/testdata/golden/themes/enterprise-gh-dash.golden b/pkg/ui/theme/testdata/golden/themes/enterprise-gh-dash.golden new file mode 100644 index 0000000..4334206 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/enterprise-gh-dash.golden @@ -0,0 +1,16 @@ +profile_id: enterprise +profile_name: Enterprise +theme_id: cyan-purple +skin_id: gh-dash +skin_nav: sidebar +colors: + primary: #00ADD8 + secondary: #6272A4 + accent: #6272A4 + background: #000000 + foreground: #FFFFFF + success: #00E091 + warning: #FFB86C + error: #FF4444 + muted: #6272A4 + border: #8FA9DD diff --git a/pkg/ui/theme/testdata/golden/themes/enterprise-minimal.golden b/pkg/ui/theme/testdata/golden/themes/enterprise-minimal.golden new file mode 100644 index 0000000..66e7b09 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/enterprise-minimal.golden @@ -0,0 +1,16 @@ +profile_id: enterprise +profile_name: Enterprise +theme_id: cyan-purple +skin_id: minimal +skin_nav: tab-bar +colors: + primary: #00ADD8 + secondary: #6272A4 + accent: #6272A4 + background: #000000 + foreground: #FFFFFF + success: #00E091 + warning: #FFB86C + error: #FF4444 + muted: #6272A4 + border: #8FA9DD diff --git a/pkg/ui/theme/testdata/golden/themes/horcrux-arc.golden b/pkg/ui/theme/testdata/golden/themes/horcrux-arc.golden new file mode 100644 index 0000000..0a64425 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/horcrux-arc.golden @@ -0,0 +1,16 @@ +profile_id: horcrux +profile_name: Horcrux +theme_id: dracula +skin_id: arc +skin_nav: sidebar +colors: + primary: #BD93F9 + secondary: #FF79C6 + accent: #FF79C6 + background: #282A36 + foreground: #F8F8F2 + success: #50FA7B + warning: #FFB86C + error: #FF5555 + muted: #6272A4 + border: #44475A diff --git a/pkg/ui/theme/testdata/golden/themes/horcrux-gh-dash.golden b/pkg/ui/theme/testdata/golden/themes/horcrux-gh-dash.golden new file mode 100644 index 0000000..edfe3ce --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/horcrux-gh-dash.golden @@ -0,0 +1,16 @@ +profile_id: horcrux +profile_name: Horcrux +theme_id: dracula +skin_id: gh-dash +skin_nav: sidebar +colors: + primary: #BD93F9 + secondary: #FF79C6 + accent: #FF79C6 + background: #282A36 + foreground: #F8F8F2 + success: #50FA7B + warning: #FFB86C + error: #FF5555 + muted: #6272A4 + border: #44475A diff --git a/pkg/ui/theme/testdata/golden/themes/horcrux-minimal.golden b/pkg/ui/theme/testdata/golden/themes/horcrux-minimal.golden new file mode 100644 index 0000000..f76a117 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/horcrux-minimal.golden @@ -0,0 +1,16 @@ +profile_id: horcrux +profile_name: Horcrux +theme_id: dracula +skin_id: minimal +skin_nav: tab-bar +colors: + primary: #BD93F9 + secondary: #FF79C6 + accent: #FF79C6 + background: #282A36 + foreground: #F8F8F2 + success: #50FA7B + warning: #FFB86C + error: #FF5555 + muted: #6272A4 + border: #44475A diff --git a/pkg/ui/theme/testdata/golden/themes/jedi-arc.golden b/pkg/ui/theme/testdata/golden/themes/jedi-arc.golden new file mode 100644 index 0000000..0538afc --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/jedi-arc.golden @@ -0,0 +1,16 @@ +profile_id: jedi +profile_name: Jedi +theme_id: nord +skin_id: arc +skin_nav: sidebar +colors: + primary: #88C0D0 + secondary: #81A1C1 + accent: #81A1C1 + background: #2E3440 + foreground: #ECEFF4 + success: #A3BE8C + warning: #EBCB8B + error: #BF616A + muted: #4C566A + border: #434C5E diff --git a/pkg/ui/theme/testdata/golden/themes/jedi-gh-dash.golden b/pkg/ui/theme/testdata/golden/themes/jedi-gh-dash.golden new file mode 100644 index 0000000..c94e4bf --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/jedi-gh-dash.golden @@ -0,0 +1,16 @@ +profile_id: jedi +profile_name: Jedi +theme_id: nord +skin_id: gh-dash +skin_nav: sidebar +colors: + primary: #88C0D0 + secondary: #81A1C1 + accent: #81A1C1 + background: #2E3440 + foreground: #ECEFF4 + success: #A3BE8C + warning: #EBCB8B + error: #BF616A + muted: #4C566A + border: #434C5E diff --git a/pkg/ui/theme/testdata/golden/themes/jedi-minimal.golden b/pkg/ui/theme/testdata/golden/themes/jedi-minimal.golden new file mode 100644 index 0000000..c561968 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/jedi-minimal.golden @@ -0,0 +1,16 @@ +profile_id: jedi +profile_name: Jedi +theme_id: nord +skin_id: minimal +skin_nav: tab-bar +colors: + primary: #88C0D0 + secondary: #81A1C1 + accent: #81A1C1 + background: #2E3440 + foreground: #ECEFF4 + success: #A3BE8C + warning: #EBCB8B + error: #BF616A + muted: #4C566A + border: #434C5E diff --git a/pkg/ui/theme/testdata/golden/themes/pirate-arc.golden b/pkg/ui/theme/testdata/golden/themes/pirate-arc.golden new file mode 100644 index 0000000..dc1b6dd --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/pirate-arc.golden @@ -0,0 +1,16 @@ +profile_id: pirate +profile_name: Pirate +theme_id: ocean +skin_id: arc +skin_nav: sidebar +colors: + primary: #03A9F4 + secondary: #81D4FA + accent: #81D4FA + background: #000000 + foreground: #FFFFFF + success: #00E091 + warning: #FFB86C + error: #FF6B6B + muted: #0288D1 + border: #29B6F6 diff --git a/pkg/ui/theme/testdata/golden/themes/pirate-gh-dash.golden b/pkg/ui/theme/testdata/golden/themes/pirate-gh-dash.golden new file mode 100644 index 0000000..ce534e6 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/pirate-gh-dash.golden @@ -0,0 +1,16 @@ +profile_id: pirate +profile_name: Pirate +theme_id: ocean +skin_id: gh-dash +skin_nav: sidebar +colors: + primary: #03A9F4 + secondary: #81D4FA + accent: #81D4FA + background: #000000 + foreground: #FFFFFF + success: #00E091 + warning: #FFB86C + error: #FF6B6B + muted: #0288D1 + border: #29B6F6 diff --git a/pkg/ui/theme/testdata/golden/themes/pirate-minimal.golden b/pkg/ui/theme/testdata/golden/themes/pirate-minimal.golden new file mode 100644 index 0000000..2b394f9 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/pirate-minimal.golden @@ -0,0 +1,16 @@ +profile_id: pirate +profile_name: Pirate +theme_id: ocean +skin_id: minimal +skin_nav: tab-bar +colors: + primary: #03A9F4 + secondary: #81D4FA + accent: #81D4FA + background: #000000 + foreground: #FFFFFF + success: #00E091 + warning: #FFB86C + error: #FF6B6B + muted: #0288D1 + border: #29B6F6 diff --git a/pkg/ui/theme/testdata/golden/themes/pokemon-arc.golden b/pkg/ui/theme/testdata/golden/themes/pokemon-arc.golden new file mode 100644 index 0000000..a730e7d --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/pokemon-arc.golden @@ -0,0 +1,16 @@ +profile_id: pokemon +profile_name: Pokémon +theme_id: rainbow +skin_id: arc +skin_nav: sidebar +colors: + primary: #FF00FF + secondary: #CCCCCC + accent: #CCCCCC + background: #000000 + foreground: #FFFFFF + success: #00FF00 + warning: #FFFF00 + error: #FF0000 + muted: #999999 + border: #7F00FF diff --git a/pkg/ui/theme/testdata/golden/themes/pokemon-gh-dash.golden b/pkg/ui/theme/testdata/golden/themes/pokemon-gh-dash.golden new file mode 100644 index 0000000..d44b1f8 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/pokemon-gh-dash.golden @@ -0,0 +1,16 @@ +profile_id: pokemon +profile_name: Pokémon +theme_id: rainbow +skin_id: gh-dash +skin_nav: sidebar +colors: + primary: #FF00FF + secondary: #CCCCCC + accent: #CCCCCC + background: #000000 + foreground: #FFFFFF + success: #00FF00 + warning: #FFFF00 + error: #FF0000 + muted: #999999 + border: #7F00FF diff --git a/pkg/ui/theme/testdata/golden/themes/pokemon-minimal.golden b/pkg/ui/theme/testdata/golden/themes/pokemon-minimal.golden new file mode 100644 index 0000000..887f846 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/pokemon-minimal.golden @@ -0,0 +1,16 @@ +profile_id: pokemon +profile_name: Pokémon +theme_id: rainbow +skin_id: minimal +skin_nav: tab-bar +colors: + primary: #FF00FF + secondary: #CCCCCC + accent: #CCCCCC + background: #000000 + foreground: #FFFFFF + success: #00FF00 + warning: #FFFF00 + error: #FF0000 + muted: #999999 + border: #7F00FF diff --git a/pkg/ui/theme/testdata/golden/themes/saiyan-arc.golden b/pkg/ui/theme/testdata/golden/themes/saiyan-arc.golden new file mode 100644 index 0000000..a049327 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/saiyan-arc.golden @@ -0,0 +1,16 @@ +profile_id: saiyan +profile_name: Saiyan +theme_id: fire +skin_id: arc +skin_nav: sidebar +colors: + primary: #FF6600 + secondary: #FFCC99 + accent: #FFCC99 + background: #000000 + foreground: #FFFFFF + success: #FFFF00 + warning: #FF9900 + error: #FF0000 + muted: #CC6600 + border: #FF8000 diff --git a/pkg/ui/theme/testdata/golden/themes/saiyan-gh-dash.golden b/pkg/ui/theme/testdata/golden/themes/saiyan-gh-dash.golden new file mode 100644 index 0000000..49e5a98 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/saiyan-gh-dash.golden @@ -0,0 +1,16 @@ +profile_id: saiyan +profile_name: Saiyan +theme_id: fire +skin_id: gh-dash +skin_nav: sidebar +colors: + primary: #FF6600 + secondary: #FFCC99 + accent: #FFCC99 + background: #000000 + foreground: #FFFFFF + success: #FFFF00 + warning: #FF9900 + error: #FF0000 + muted: #CC6600 + border: #FF8000 diff --git a/pkg/ui/theme/testdata/golden/themes/saiyan-minimal.golden b/pkg/ui/theme/testdata/golden/themes/saiyan-minimal.golden new file mode 100644 index 0000000..b248fd2 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/saiyan-minimal.golden @@ -0,0 +1,16 @@ +profile_id: saiyan +profile_name: Saiyan +theme_id: fire +skin_id: minimal +skin_nav: tab-bar +colors: + primary: #FF6600 + secondary: #FFCC99 + accent: #FFCC99 + background: #000000 + foreground: #FFFFFF + success: #FFFF00 + warning: #FF9900 + error: #FF0000 + muted: #CC6600 + border: #FF8000 diff --git a/pkg/ui/theme/testdata/golden/themes/shinobi-arc.golden b/pkg/ui/theme/testdata/golden/themes/shinobi-arc.golden new file mode 100644 index 0000000..e2db3b6 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/shinobi-arc.golden @@ -0,0 +1,16 @@ +profile_id: shinobi +profile_name: Shinobi +theme_id: gruvbox +skin_id: arc +skin_nav: sidebar +colors: + primary: #FE8019 + secondary: #FABD2F + accent: #FABD2F + background: #282828 + foreground: #EBDBB2 + success: #B8BB26 + warning: #FABD2F + error: #FB4934 + muted: #928374 + border: #504945 diff --git a/pkg/ui/theme/testdata/golden/themes/shinobi-gh-dash.golden b/pkg/ui/theme/testdata/golden/themes/shinobi-gh-dash.golden new file mode 100644 index 0000000..ac79277 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/shinobi-gh-dash.golden @@ -0,0 +1,16 @@ +profile_id: shinobi +profile_name: Shinobi +theme_id: gruvbox +skin_id: gh-dash +skin_nav: sidebar +colors: + primary: #FE8019 + secondary: #FABD2F + accent: #FABD2F + background: #282828 + foreground: #EBDBB2 + success: #B8BB26 + warning: #FABD2F + error: #FB4934 + muted: #928374 + border: #504945 diff --git a/pkg/ui/theme/testdata/golden/themes/shinobi-minimal.golden b/pkg/ui/theme/testdata/golden/themes/shinobi-minimal.golden new file mode 100644 index 0000000..512fc93 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/shinobi-minimal.golden @@ -0,0 +1,16 @@ +profile_id: shinobi +profile_name: Shinobi +theme_id: gruvbox +skin_id: minimal +skin_nav: tab-bar +colors: + primary: #FE8019 + secondary: #FABD2F + accent: #FABD2F + background: #282828 + foreground: #EBDBB2 + success: #B8BB26 + warning: #FABD2F + error: #FB4934 + muted: #928374 + border: #504945 diff --git a/pkg/ui/theme/testdata/golden/themes/triforce-arc.golden b/pkg/ui/theme/testdata/golden/themes/triforce-arc.golden new file mode 100644 index 0000000..7caa61d --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/triforce-arc.golden @@ -0,0 +1,16 @@ +profile_id: triforce +profile_name: Triforce +theme_id: solarized +skin_id: arc +skin_nav: sidebar +colors: + primary: #268BD2 + secondary: #2AA198 + accent: #2AA198 + background: #002B36 + foreground: #839496 + success: #859900 + warning: #CB4B16 + error: #DC322F + muted: #586E75 + border: #073642 diff --git a/pkg/ui/theme/testdata/golden/themes/triforce-gh-dash.golden b/pkg/ui/theme/testdata/golden/themes/triforce-gh-dash.golden new file mode 100644 index 0000000..8147fd0 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/triforce-gh-dash.golden @@ -0,0 +1,16 @@ +profile_id: triforce +profile_name: Triforce +theme_id: solarized +skin_id: gh-dash +skin_nav: sidebar +colors: + primary: #268BD2 + secondary: #2AA198 + accent: #2AA198 + background: #002B36 + foreground: #839496 + success: #859900 + warning: #CB4B16 + error: #DC322F + muted: #586E75 + border: #073642 diff --git a/pkg/ui/theme/testdata/golden/themes/triforce-minimal.golden b/pkg/ui/theme/testdata/golden/themes/triforce-minimal.golden new file mode 100644 index 0000000..38a942a --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/triforce-minimal.golden @@ -0,0 +1,16 @@ +profile_id: triforce +profile_name: Triforce +theme_id: solarized +skin_id: minimal +skin_nav: tab-bar +colors: + primary: #268BD2 + secondary: #2AA198 + accent: #2AA198 + background: #002B36 + foreground: #839496 + success: #859900 + warning: #CB4B16 + error: #DC322F + muted: #586E75 + border: #073642 diff --git a/pkg/ui/theme/theme.go b/pkg/ui/theme/theme.go new file mode 100644 index 0000000..2eb63a2 --- /dev/null +++ b/pkg/ui/theme/theme.go @@ -0,0 +1,74 @@ +package theme + +import "github.com/charmbracelet/lipgloss" + +// Theme defines a color palette for visual styling. +// Themes are loaded from YAML files in embedded/themes/ directory. +type Theme struct { + ID string `yaml:"id"` // Unique identifier (e.g., "cyan-purple") + Name string `yaml:"name"` // Display name (e.g., "Cyan to Purple Gradient") + Colors ColorSet `yaml:"colors"` // Color definitions +} + +// ColorSet defines the complete color palette for a theme. +// All colors must be valid hex codes (#RRGGBB) or named ANSI colors. +type ColorSet struct { + Primary string `yaml:"primary"` // Primary brand color + Secondary string `yaml:"secondary"` // Secondary accent + Accent string `yaml:"accent"` // Highlight color + Background string `yaml:"background"` // Background color + Foreground string `yaml:"foreground"` // Text color + Success string `yaml:"success"` // Success state (green) + Warning string `yaml:"warning"` // Warning state (yellow/orange) + Error string `yaml:"error"` // Error state (red) + Muted string `yaml:"muted"` // Muted/disabled text + Border string `yaml:"border"` // Border color +} + +// ToLipglossColor converts a ColorSet field to lipgloss.Color. +func (c *ColorSet) ToLipglossColor(field string) lipgloss.Color { + var color string + switch field { + case "primary": + color = c.Primary + case "secondary": + color = c.Secondary + case "accent": + color = c.Accent + case "background": + color = c.Background + case "foreground": + color = c.Foreground + case "success": + color = c.Success + case "warning": + color = c.Warning + case "error": + color = c.Error + case "muted": + color = c.Muted + case "border": + color = c.Border + default: + color = c.Foreground // fallback to foreground + } + return lipgloss.Color(color) +} + +// Validate checks if the Theme is valid. +func (t *Theme) Validate() error { + if t.ID == "" { + return ErrInvalidThemeID + } + if t.Name == "" { + return ErrInvalidThemeName + } + // Validate all required color fields are present + if t.Colors.Primary == "" || t.Colors.Secondary == "" || t.Colors.Accent == "" || + t.Colors.Background == "" || t.Colors.Foreground == "" || + t.Colors.Success == "" || t.Colors.Warning == "" || t.Colors.Error == "" || + t.Colors.Muted == "" || t.Colors.Border == "" { + return ErrIncompleteColorSet + } + return nil +} diff --git a/pkg/ui/themes/legacy.go b/pkg/ui/themes/legacy.go deleted file mode 100644 index 1e31ef9..0000000 --- a/pkg/ui/themes/legacy.go +++ /dev/null @@ -1,149 +0,0 @@ -// Package themes provides color theme definitions for the A.R.C. CLI banner and UI elements. -package themes - -import "github.com/charmbracelet/lipgloss" - -// Scheme represents a complete color scheme for the CLI -type Scheme struct { - BannerColors []lipgloss.Color - Primary lipgloss.Color - Secondary lipgloss.Color - Success lipgloss.Color - Error lipgloss.Color - Warning lipgloss.Color - Info lipgloss.Color - Name string - Description string -} - -// newScheme is a helper function to create a Scheme -func newScheme(name, description string, bannerColors []lipgloss.Color, primary, secondary, success, err, warning, info lipgloss.Color) Scheme { - return Scheme{ - Name: name, - Description: description, - BannerColors: bannerColors, - Primary: primary, - Secondary: secondary, - Success: success, - Error: err, - Warning: warning, - Info: info, - } -} - -// Deprecated: Available returns legacy hardcoded themes. Use NewLoader().Load() instead. -// The YAML theme system supports user themes from ~/.config/arc/themes/ and has richer color options. -// This function will be removed in v2.0. -func Available() map[string]Scheme { - return map[string]Scheme{ - "cyan-purple": newScheme( - "cyan-purple", - "Cyan to Purple Gradient - Modern & Professional (default)", - []lipgloss.Color{ - lipgloss.Color("#00ADD8"), lipgloss.Color("#00B5D9"), - lipgloss.Color("#00BDD9"), lipgloss.Color("#1AC5D9"), - lipgloss.Color("#33CDD9"), lipgloss.Color("#66C8E3"), - lipgloss.Color("#7BB8E0"), lipgloss.Color("#8FA9DD"), - lipgloss.Color("#A399D9"), lipgloss.Color("#B78AD6"), - lipgloss.Color("#BD93F9"), - }, - lipgloss.Color("#00ADD8"), // Primary - lipgloss.Color("#6272A4"), // Secondary - lipgloss.Color("#00E091"), // Success - lipgloss.Color("#FF4444"), // Error - lipgloss.Color("#FFB86C"), // Warning - lipgloss.Color("#BD93F9"), // Info - ), - "rainbow": newScheme( - "rainbow", - "Rainbow - Full spectrum, vibrant colors", - []lipgloss.Color{ - lipgloss.Color("#FF0000"), lipgloss.Color("#FF7F00"), - lipgloss.Color("#FFFF00"), lipgloss.Color("#7FFF00"), - lipgloss.Color("#00FF00"), lipgloss.Color("#00FF7F"), - lipgloss.Color("#00FFFF"), lipgloss.Color("#007FFF"), - lipgloss.Color("#0000FF"), lipgloss.Color("#7F00FF"), - lipgloss.Color("#FF00FF"), - }, - lipgloss.Color("#FF00FF"), // Primary - lipgloss.Color("#CCCCCC"), // Secondary - lipgloss.Color("#00FF00"), // Success - lipgloss.Color("#FF0000"), // Error - lipgloss.Color("#FFFF00"), // Yellow - lipgloss.Color("#00FFFF"), // Info - ), - "fire": newScheme( - "fire", - "Fire - Yellow to Red gradient, hot and energetic", - []lipgloss.Color{ - lipgloss.Color("#FFFF00"), lipgloss.Color("#FFE600"), - lipgloss.Color("#FFCC00"), lipgloss.Color("#FFB300"), - lipgloss.Color("#FF9900"), lipgloss.Color("#FF8000"), - lipgloss.Color("#FF6600"), lipgloss.Color("#FF4D00"), - lipgloss.Color("#FF3300"), lipgloss.Color("#FF1A00"), - lipgloss.Color("#FF0000"), - }, - lipgloss.Color("#FF6600"), // Primary - lipgloss.Color("#FFCC99"), // Secondary - lipgloss.Color("#FFFF00"), // Success - lipgloss.Color("#FF0000"), // Error - lipgloss.Color("#FF9900"), // Warning - lipgloss.Color("#FFE600"), // Info - ), - "ocean": newScheme( - "ocean", - "Ocean - Light cyan to deep blue, cool and calm", - []lipgloss.Color{ - lipgloss.Color("#E0FFFF"), lipgloss.Color("#B3E5FC"), - lipgloss.Color("#81D4FA"), lipgloss.Color("#4FC3F7"), - lipgloss.Color("#29B6F6"), lipgloss.Color("#03A9F4"), - lipgloss.Color("#039BE5"), lipgloss.Color("#0288D1"), - lipgloss.Color("#0277BD"), lipgloss.Color("#01579B"), - lipgloss.Color("#004D7A"), - }, - lipgloss.Color("#03A9F4"), // Primary - lipgloss.Color("#81D4FA"), // Secondary - lipgloss.Color("#00E091"), // Success - lipgloss.Color("#FF6B6B"), // Error - lipgloss.Color("#FFB86C"), // Warning - lipgloss.Color("#4FC3F7"), // Info - ), - "matrix": newScheme( - "matrix", - "Matrix - Green gradient, hacker style", - []lipgloss.Color{ - lipgloss.Color("#00FF00"), lipgloss.Color("#00F500"), - lipgloss.Color("#00EB00"), lipgloss.Color("#00E100"), - lipgloss.Color("#00D700"), lipgloss.Color("#00CD00"), - lipgloss.Color("#00C300"), lipgloss.Color("#00B900"), - lipgloss.Color("#00AF00"), lipgloss.Color("#00A500"), - lipgloss.Color("#009B00"), - }, - lipgloss.Color("#00FF00"), // Primary - lipgloss.Color("#00D700"), // Secondary - lipgloss.Color("#00FF00"), // Success - lipgloss.Color("#FF0000"), // Error - lipgloss.Color("#FFFF00"), // Warning - lipgloss.Color("#00FFFF"), // Info - ), - } -} - -// GetLegacyDefault returns the default legacy theme scheme (deprecated). -// Use GetDefault() for the new YAML-based theme system. -func GetLegacyDefault() Scheme { - return Available()["cyan-purple"] -} - -// Rainbow returns rainbow colors for character-by-character coloring -func Rainbow() []lipgloss.Color { - return []lipgloss.Color{ - lipgloss.Color("#FF0000"), // Red - lipgloss.Color("#FF7F00"), // Orange - lipgloss.Color("#FFFF00"), // Yellow - lipgloss.Color("#00FF00"), // Green - lipgloss.Color("#00FFFF"), // Cyan - lipgloss.Color("#0000FF"), // Blue - lipgloss.Color("#8B00FF"), // Violet - } -} diff --git a/pkg/ui/themes/legacy_test.go b/pkg/ui/themes/legacy_test.go deleted file mode 100644 index fcbfac2..0000000 --- a/pkg/ui/themes/legacy_test.go +++ /dev/null @@ -1,245 +0,0 @@ -package themes - -import ( - "testing" - - "github.com/charmbracelet/lipgloss" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAvailable(t *testing.T) { - themes := Available() - - // Test that we have all expected themes - expectedThemes := []string{"cyan-purple", "rainbow", "fire", "ocean", "matrix"} - assert.Len(t, themes, len(expectedThemes), "should have exactly 5 themes") - - for _, themeName := range expectedThemes { - t.Run(themeName, func(t *testing.T) { - theme, exists := themes[themeName] - require.True(t, exists, "theme %s should exist", themeName) - - // Test that theme has required fields - assert.NotEmpty(t, theme.Name, "theme name should not be empty") - assert.NotEmpty(t, theme.Description, "theme description should not be empty") - assert.NotNil(t, theme.BannerColors, "banner colors should not be nil") - assert.NotEmpty(t, theme.BannerColors, "banner colors should not be empty") - assert.NotEmpty(t, theme.Primary, "primary color should not be empty") - assert.NotEmpty(t, theme.Secondary, "secondary color should not be empty") - assert.NotEmpty(t, theme.Success, "success color should not be empty") - assert.NotEmpty(t, theme.Error, "error color should not be empty") - assert.NotEmpty(t, theme.Warning, "warning color should not be empty") - assert.NotEmpty(t, theme.Info, "info color should not be empty") - - // Test that name matches key - assert.Equal(t, themeName, theme.Name, "theme name should match map key") - }) - } -} - -func TestAvailable_ThemeNames(t *testing.T) { - themes := Available() - - // Test that theme names are unique - nameSet := make(map[string]bool) - for key, theme := range themes { - assert.False(t, nameSet[theme.Name], "theme name should be unique: %s", theme.Name) - nameSet[theme.Name] = true - - // Verify name consistency - assert.Equal(t, key, theme.Name, "map key should match theme name") - } -} - -func TestAvailable_BannerColors(t *testing.T) { - themes := Available() - - for themeName, theme := range themes { - t.Run(themeName, func(t *testing.T) { - // Test banner colors count (should have multiple colors for gradient) - assert.GreaterOrEqual(t, len(theme.BannerColors), 5, - "theme %s should have at least 5 banner colors for gradient", themeName) - - // Test that colors are valid (non-empty strings) - for i, color := range theme.BannerColors { - assert.NotEmpty(t, string(color), - "banner color %d in theme %s should not be empty", i, themeName) - - // Basic validation that it looks like a hex color - colorStr := string(color) - if len(colorStr) > 0 && colorStr[0] == '#' { - assert.True(t, len(colorStr) == 7 || len(colorStr) == 4, - "hex color should be #RGB or #RRGGBB format: %s", colorStr) - } - } - }) - } -} - -func TestAvailable_ColorFields(t *testing.T) { - themes := Available() - - for themeName, theme := range themes { - t.Run(themeName, func(t *testing.T) { - // Test all color fields are set - colors := map[string]lipgloss.Color{ - "Primary": theme.Primary, - "Secondary": theme.Secondary, - "Success": theme.Success, - "Error": theme.Error, - "Warning": theme.Warning, - "Info": theme.Info, - } - - for fieldName, color := range colors { - assert.NotEmpty(t, string(color), - "%s color in theme %s should not be empty", fieldName, themeName) - } - }) - } -} - -func TestGetLegacyDefault(t *testing.T) { - defaultTheme := GetLegacyDefault() - - // Test that default theme is cyan-purple - assert.Equal(t, "cyan-purple", defaultTheme.Name, "default theme should be cyan-purple") - - // Test that default theme has all required fields - assert.NotEmpty(t, defaultTheme.Description) - assert.NotEmpty(t, defaultTheme.BannerColors) - assert.NotEmpty(t, defaultTheme.Primary) - assert.NotEmpty(t, defaultTheme.Secondary) - assert.NotEmpty(t, defaultTheme.Success) - assert.NotEmpty(t, defaultTheme.Error) - assert.NotEmpty(t, defaultTheme.Warning) - assert.NotEmpty(t, defaultTheme.Info) - - // Test that default theme exists in Available() - availableThemes := Available() - cyanPurple, exists := availableThemes["cyan-purple"] - require.True(t, exists, "cyan-purple theme should exist in Available()") - assert.Equal(t, cyanPurple.Name, defaultTheme.Name) -} - -func TestRainbow(t *testing.T) { - rainbowColors := Rainbow() - - // Test that we have rainbow colors - assert.NotEmpty(t, rainbowColors, "rainbow colors should not be empty") - - // Test that we have at least 7 colors (standard rainbow) - assert.GreaterOrEqual(t, len(rainbowColors), 7, - "should have at least 7 colors for full rainbow spectrum") - - // Test that all colors are valid - for i, color := range rainbowColors { - assert.NotEmpty(t, string(color), "rainbow color %d should not be empty", i) - } - - // Test specific rainbow colors exist (at minimum) - expectedColors := []string{"#FF0000", "#FFFF00", "#00FF00", "#0000FF"} - colorStrings := make([]string, len(rainbowColors)) - for i, c := range rainbowColors { - colorStrings[i] = string(c) - } - - for _, expected := range expectedColors { - assert.Contains(t, colorStrings, expected, - "rainbow should contain color %s", expected) - } -} - -func TestScheme_Structure(t *testing.T) { - // Test that a Scheme can be created with all fields - testScheme := Scheme{ - Name: "test", - Description: "Test theme", - BannerColors: []lipgloss.Color{lipgloss.Color("#FFFFFF")}, - Primary: lipgloss.Color("#000000"), - } - - assert.Equal(t, "test", testScheme.Name) - assert.Equal(t, "Test theme", testScheme.Description) - assert.Len(t, testScheme.BannerColors, 1) - assert.Equal(t, lipgloss.Color("#000000"), testScheme.Primary) -} - -func TestThemes_Descriptions(t *testing.T) { - themes := Available() - - for themeName, theme := range themes { - t.Run(themeName, func(t *testing.T) { - // Test that description is reasonably long - assert.GreaterOrEqual(t, len(theme.Description), 10, - "description should be reasonably descriptive") - - // Test that description is not empty - assert.NotEmpty(t, theme.Description, "description should not be empty") - }) - } -} - -func TestThemes_ConsistentBannerColorCount(t *testing.T) { - themes := Available() - - // Most themes should have 11 colors for smooth gradients - expectedCount := 11 - - for themeName, theme := range themes { - t.Run(themeName, func(t *testing.T) { - assert.Equal(t, expectedCount, len(theme.BannerColors), - "theme %s should have %d banner colors for consistent gradient", - themeName, expectedCount) - }) - } -} - -func TestThemes_SpecificThemeProperties(t *testing.T) { - themes := Available() - - tests := []struct { - name string - expectedDesc string - primaryContains string - }{ - { - name: "cyan-purple", - expectedDesc: "Modern & Professional", - primaryContains: "ADD8", - }, - { - name: "rainbow", - expectedDesc: "Full spectrum", - primaryContains: "FF00FF", - }, - { - name: "fire", - expectedDesc: "hot and energetic", - primaryContains: "FF", - }, - { - name: "ocean", - expectedDesc: "cool and calm", - primaryContains: "A9F4", - }, - { - name: "matrix", - expectedDesc: "hacker style", - primaryContains: "00FF00", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - theme, exists := themes[tt.name] - require.True(t, exists, "theme should exist") - - assert.Contains(t, theme.Description, tt.expectedDesc, - "description should contain expected text") - assert.Contains(t, string(theme.Primary), tt.primaryContains, - "primary color should contain expected hex value") - }) - } -} diff --git a/pkg/ui/themes/loader.go b/pkg/ui/themes/loader.go deleted file mode 100644 index 3f019e4..0000000 --- a/pkg/ui/themes/loader.go +++ /dev/null @@ -1,157 +0,0 @@ -package themes - -import ( - "embed" - "fmt" - "os" - "path/filepath" - - "gopkg.in/yaml.v3" - - "github.com/arc-framework/arc-cli/internal/xdg" -) - -//go:embed embedded/*.yaml -var embeddedThemes embed.FS - -const yamlExt = ".yaml" - -// Loader handles loading themes from various sources. -type Loader struct { - // cache stores loaded themes to avoid re-parsing - cache map[string]*Theme -} - -// NewLoader creates a new theme loader. -func NewLoader() *Loader { - return &Loader{ - cache: make(map[string]*Theme), - } -} - -// Load loads a theme by name. -// It searches in the following order: -// 1. User themes (~/.config/arc/themes/) -// 2. Embedded themes (built-in) -// -// Returns an error if the theme is not found or invalid. -func (l *Loader) Load(name string) (*Theme, error) { - // Check cache first - if theme, ok := l.cache[name]; ok { - return theme, nil - } - - // Try user themes first - theme, err := l.loadUserTheme(name) - if err == nil { - // Validate before caching - if validationErr := theme.Validate(); validationErr != nil { - return nil, fmt.Errorf("invalid user theme %q: %w", name, validationErr) - } - l.cache[name] = theme - return theme, nil - } - - // Fall back to embedded themes - theme, err = l.loadEmbeddedTheme(name) - if err != nil { - return nil, fmt.Errorf("theme %q not found in user or embedded themes", name) - } - - // Validate before caching - if validationErr := theme.Validate(); validationErr != nil { - return nil, fmt.Errorf("invalid embedded theme %q: %w", name, validationErr) - } - - l.cache[name] = theme - return theme, nil -} - -// loadUserTheme loads a theme from user's config directory. -func (l *Loader) loadUserTheme(name string) (*Theme, error) { - configHome := xdg.ConfigHome() - themePath := filepath.Join(configHome, "arc", "themes", name+".yaml") - - // Check if file exists - if _, err := os.Stat(themePath); os.IsNotExist(err) { - return nil, fmt.Errorf("user theme file not found: %s", themePath) - } - - // Read file - data, err := os.ReadFile(themePath) - if err != nil { - return nil, fmt.Errorf("failed to read theme file: %w", err) - } - - // Parse YAML - var theme Theme - if parseErr := yaml.Unmarshal(data, &theme); parseErr != nil { - return nil, fmt.Errorf("failed to parse theme YAML: %w", parseErr) - } - - return &theme, nil -} - -// loadEmbeddedTheme loads a theme from embedded files. -func (l *Loader) loadEmbeddedTheme(name string) (*Theme, error) { - themePath := filepath.Join("embedded", name+".yaml") - - // Read embedded file - data, err := embeddedThemes.ReadFile(themePath) - if err != nil { - return nil, fmt.Errorf("embedded theme not found: %w", err) - } - - // Parse YAML - var theme Theme - if parseErr := yaml.Unmarshal(data, &theme); parseErr != nil { - return nil, fmt.Errorf("failed to parse embedded theme YAML: %w", parseErr) - } - - return &theme, nil -} - -// List returns a list of all available themes (user + embedded). -func (l *Loader) List() ([]string, error) { - themes := make(map[string]bool) - - // List embedded themes - entries, err := embeddedThemes.ReadDir("embedded") - if err != nil { - return nil, fmt.Errorf("failed to read embedded themes: %w", err) - } - - for _, entry := range entries { - if !entry.IsDir() && filepath.Ext(entry.Name()) == yamlExt { - name := entry.Name()[:len(entry.Name())-len(yamlExt)] // Remove .yaml extension - themes[name] = true - } - } - - // List user themes - configHome := xdg.ConfigHome() - userThemesDir := filepath.Join(configHome, "arc", "themes") - - if userEntries, readErr := os.ReadDir(userThemesDir); readErr == nil { - for _, entry := range userEntries { - if !entry.IsDir() && filepath.Ext(entry.Name()) == yamlExt { - name := entry.Name()[:len(entry.Name())-len(yamlExt)] // Remove .yaml extension - themes[name] = true // User themes override embedded - } - } - } - - // Convert map to slice - result := make([]string, 0, len(themes)) - for name := range themes { - result = append(result, name) - } - - return result, nil -} - -// GetDefault returns the default theme (cyan-purple). -func GetDefault() (*Theme, error) { - loader := NewLoader() - return loader.Load("cyan-purple") -} diff --git a/pkg/ui/themes/theme.go b/pkg/ui/themes/theme.go deleted file mode 100644 index eb58f41..0000000 --- a/pkg/ui/themes/theme.go +++ /dev/null @@ -1,157 +0,0 @@ -package themes - -import "github.com/charmbracelet/lipgloss" - -// Theme represents a complete UI theme loaded from YAML. -// Themes can be embedded (built-in) or user-provided. -type Theme struct { - // Metadata - Name string `yaml:"name" json:"name"` - Description string `yaml:"description" json:"description"` - Version string `yaml:"version" json:"version"` - Author string `yaml:"author" json:"author"` - - // Color palette - Colors ColorSet `yaml:"colors" json:"colors"` - - // Styles for different UI elements - Styles StyleSet `yaml:"styles" json:"styles"` - - // Symbols and icons - Symbols SymbolSet `yaml:"symbols" json:"symbols"` -} - -// ColorSet defines the color palette for the theme. -type ColorSet struct { - // Core brand colors - Primary string `yaml:"primary" json:"primary"` // Main brand color - Secondary string `yaml:"secondary" json:"secondary"` // Accent color - - // Semantic colors - Success string `yaml:"success" json:"success"` // Green for success states - Error string `yaml:"error" json:"error"` // Red for errors - Warning string `yaml:"warning" json:"warning"` // Yellow/Orange for warnings - Info string `yaml:"info" json:"info"` // Blue for informational messages - - // UI colors - Foreground string `yaml:"foreground" json:"foreground"` // Default text color - Background string `yaml:"background" json:"background"` // Default background - Muted string `yaml:"muted" json:"muted"` // Dimmed/secondary text - Border string `yaml:"border" json:"border"` // Border color - - // Banner gradient (for ASCII art) - BannerGradient []string `yaml:"banner_gradient" json:"banner_gradient"` -} - -// StyleSet defines styling rules for different UI elements. -type StyleSet struct { - // Text styles - Bold bool `yaml:"bold" json:"bold"` - Italic bool `yaml:"italic" json:"italic"` - Underline bool `yaml:"underline" json:"underline"` - - // Border styles - BorderStyle string `yaml:"border_style" json:"border_style"` // "rounded", "normal", "thick", "double" - - // Padding and margins - PaddingTop int `yaml:"padding_top" json:"padding_top"` - PaddingRight int `yaml:"padding_right" json:"padding_right"` - PaddingBottom int `yaml:"padding_bottom" json:"padding_bottom"` - PaddingLeft int `yaml:"padding_left" json:"padding_left"` -} - -// SymbolSet defines symbols and icons used in the UI. -type SymbolSet struct { - // Status symbols - Success string `yaml:"success" json:"success"` // ✓ or ✔ - Error string `yaml:"error" json:"error"` // ✗ or ✖ - Warning string `yaml:"warning" json:"warning"` // ⚠ or ! - Info string `yaml:"info" json:"info"` // ℹ or i - - // Progress symbols - Spinner []string `yaml:"spinner" json:"spinner"` // Animation frames - - // UI elements - Bullet string `yaml:"bullet" json:"bullet"` // • or · - Arrow string `yaml:"arrow" json:"arrow"` // → or > -} - -// ToLipglossColor converts a hex color string to lipgloss.Color. -func (cs *ColorSet) ToLipglossColor(hexColor string) lipgloss.Color { - return lipgloss.Color(hexColor) -} - -// PrimaryColor returns the primary color as lipgloss.Color. -func (cs *ColorSet) PrimaryColor() lipgloss.Color { - return cs.ToLipglossColor(cs.Primary) -} - -// SecondaryColor returns the secondary color as lipgloss.Color. -func (cs *ColorSet) SecondaryColor() lipgloss.Color { - return cs.ToLipglossColor(cs.Secondary) -} - -// SuccessColor returns the success color as lipgloss.Color. -func (cs *ColorSet) SuccessColor() lipgloss.Color { - return cs.ToLipglossColor(cs.Success) -} - -// ErrorColor returns the error color as lipgloss.Color. -func (cs *ColorSet) ErrorColor() lipgloss.Color { - return cs.ToLipglossColor(cs.Error) -} - -// WarningColor returns the warning color as lipgloss.Color. -func (cs *ColorSet) WarningColor() lipgloss.Color { - return cs.ToLipglossColor(cs.Warning) -} - -// InfoColor returns the info color as lipgloss.Color. -func (cs *ColorSet) InfoColor() lipgloss.Color { - return cs.ToLipglossColor(cs.Info) -} - -// ForegroundColor returns the foreground color as lipgloss.Color. -func (cs *ColorSet) ForegroundColor() lipgloss.Color { - return cs.ToLipglossColor(cs.Foreground) -} - -// BackgroundColor returns the background color as lipgloss.Color. -func (cs *ColorSet) BackgroundColor() lipgloss.Color { - return cs.ToLipglossColor(cs.Background) -} - -// MutedColor returns the muted color as lipgloss.Color. -func (cs *ColorSet) MutedColor() lipgloss.Color { - return cs.ToLipglossColor(cs.Muted) -} - -// BorderColor returns the border color as lipgloss.Color. -func (cs *ColorSet) BorderColor() lipgloss.Color { - return cs.ToLipglossColor(cs.Border) -} - -// BannerColors returns the banner gradient as []lipgloss.Color. -func (cs *ColorSet) BannerColors() []lipgloss.Color { - colors := make([]lipgloss.Color, len(cs.BannerGradient)) - for i, hex := range cs.BannerGradient { - colors[i] = cs.ToLipglossColor(hex) - } - return colors -} - -// ToScheme converts a YAML Theme to the legacy Scheme struct. -// This is useful for compatibility with animation functions that expect Scheme. -func (t *Theme) ToScheme() Scheme { - return Scheme{ - Name: t.Name, - Description: t.Description, - BannerColors: t.Colors.BannerColors(), - Primary: t.Colors.PrimaryColor(), - Secondary: t.Colors.SecondaryColor(), - Success: t.Colors.SuccessColor(), - Error: t.Colors.ErrorColor(), - Warning: t.Colors.WarningColor(), - Info: t.Colors.InfoColor(), - } -} diff --git a/pkg/ui/themes/theme_test.go b/pkg/ui/themes/theme_test.go deleted file mode 100644 index c401c98..0000000 --- a/pkg/ui/themes/theme_test.go +++ /dev/null @@ -1,285 +0,0 @@ -package themes - -import ( - "testing" -) - -func TestLoadEmbeddedThemes(t *testing.T) { - // Note: Not using t.Parallel() - - loader := NewLoader() - - tests := []struct { - name string - themeName string - wantErr bool - }{ - { - name: "load dracula theme", - themeName: "dracula", - wantErr: false, - }, - { - name: "load monokai theme", - themeName: "monokai", - wantErr: false, - }, - { - name: "load solarized theme", - themeName: "solarized", - wantErr: false, - }, - { - name: "load non-existent theme", - themeName: "nonexistent", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - theme, err := loader.Load(tt.themeName) - if (err != nil) != tt.wantErr { - t.Errorf("Load() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !tt.wantErr && theme == nil { - t.Error("Load() returned nil theme without error") - } - if !tt.wantErr { - // Verify theme has required fields - if theme.Name == "" { - t.Error("Theme name is empty") - } - if len(theme.Colors.BannerGradient) == 0 { - t.Error("Theme has no banner gradient colors") - } - } - }) - } -} - -func TestThemeValidation(t *testing.T) { - // Note: Not using t.Parallel() - - tests := []struct { - name string - theme *Theme - wantErr bool - }{ - { - name: "valid theme", - theme: &Theme{ - Name: "test", - Colors: ColorSet{ - Primary: "#FF0000", - Secondary: "#00FF00", - Success: "#00FF00", - Error: "#FF0000", - Warning: "#FFA500", - Info: "#0000FF", - Foreground: "#FFFFFF", - Background: "#000000", - Muted: "#808080", - Border: "#404040", - BannerGradient: []string{"#FF0000", "#00FF00"}, - }, - Styles: StyleSet{ - BorderStyle: "rounded", - }, - Symbols: SymbolSet{ - Success: "✓", - Error: "✗", - Spinner: []string{"⠋", "⠙"}, - }, - }, - wantErr: false, - }, - { - name: "missing name", - theme: &Theme{ - Colors: ColorSet{ - Primary: "#FF0000", - Secondary: "#00FF00", - Success: "#00FF00", - Error: "#FF0000", - Warning: "#FFA500", - Info: "#0000FF", - Foreground: "#FFFFFF", - Background: "#000000", - Muted: "#808080", - Border: "#404040", - BannerGradient: []string{"#FF0000"}, - }, - }, - wantErr: true, - }, - { - name: "invalid hex color", - theme: &Theme{ - Name: "test", - Colors: ColorSet{ - Primary: "red", // Invalid - Secondary: "#00FF00", - Success: "#00FF00", - Error: "#FF0000", - Warning: "#FFA500", - Info: "#0000FF", - Foreground: "#FFFFFF", - Background: "#000000", - Muted: "#808080", - Border: "#404040", - BannerGradient: []string{"#FF0000"}, - }, - }, - wantErr: true, - }, - { - name: "invalid border style", - theme: &Theme{ - Name: "test", - Colors: ColorSet{ - Primary: "#FF0000", - Secondary: "#00FF00", - Success: "#00FF00", - Error: "#FF0000", - Warning: "#FFA500", - Info: "#0000FF", - Foreground: "#FFFFFF", - Background: "#000000", - Muted: "#808080", - Border: "#404040", - BannerGradient: []string{"#FF0000"}, - }, - Styles: StyleSet{ - BorderStyle: "invalid", - }, - }, - wantErr: true, - }, - { - name: "empty banner gradient", - theme: &Theme{ - Name: "test", - Colors: ColorSet{ - Primary: "#FF0000", - Secondary: "#00FF00", - Success: "#00FF00", - Error: "#FF0000", - Warning: "#FFA500", - Info: "#0000FF", - Foreground: "#FFFFFF", - Background: "#000000", - Muted: "#808080", - Border: "#404040", - BannerGradient: []string{}, // Empty - }, - }, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := tt.theme.Validate() - if (err != nil) != tt.wantErr { - t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -func TestColorConversion(t *testing.T) { - // Note: Not using t.Parallel() - - colors := ColorSet{ - Primary: "#FF0000", - Secondary: "#00FF00", - Success: "#00FF00", - Error: "#FF0000", - Warning: "#FFA500", - Info: "#0000FF", - Foreground: "#FFFFFF", - Background: "#000000", - Muted: "#808080", - Border: "#404040", - BannerGradient: []string{"#FF0000", "#00FF00", "#0000FF"}, - } - - // Test individual color conversions - if colors.PrimaryColor() != "#FF0000" { - t.Errorf("PrimaryColor() = %v, want #FF0000", colors.PrimaryColor()) - } - - // Test banner colors conversion - bannerColors := colors.BannerColors() - if len(bannerColors) != 3 { - t.Errorf("BannerColors() length = %d, want 3", len(bannerColors)) - } -} - -func TestLoaderCache(t *testing.T) { - // Note: Not using t.Parallel() - - loader := NewLoader() - - // Load theme first time - theme1, err := loader.Load("dracula") - if err != nil { - t.Fatalf("First Load() failed: %v", err) - } - - // Load same theme second time (should come from cache) - theme2, err := loader.Load("dracula") - if err != nil { - t.Fatalf("Second Load() failed: %v", err) - } - - // Should be the exact same pointer (from cache) - if theme1 != theme2 { - t.Error("Second Load() did not return cached theme") - } -} - -func TestListThemes(t *testing.T) { - // Note: Not using t.Parallel() - - loader := NewLoader() - themes, err := loader.List() - if err != nil { - t.Fatalf("List() failed: %v", err) - } - - // Should have at least the 3 embedded themes - if len(themes) < 3 { - t.Errorf("List() returned %d themes, want at least 3", len(themes)) - } - - // Check that embedded themes are included - expectedThemes := []string{"dracula", "monokai", "solarized"} - for _, expected := range expectedThemes { - found := false - for _, theme := range themes { - if theme == expected { - found = true - break - } - } - if !found { - t.Errorf("List() missing expected theme %q", expected) - } - } -} - -func TestGetDefault(t *testing.T) { - // Note: Not using t.Parallel() - - theme, err := GetDefault() - if err != nil { - t.Fatalf("GetDefault() failed: %v", err) - } - - if theme.Name != "cyan-purple" { - t.Errorf("GetDefault() returned theme %q, want cyan-purple", theme.Name) - } -} diff --git a/pkg/ui/themes/validation.go b/pkg/ui/themes/validation.go deleted file mode 100644 index e857dc7..0000000 --- a/pkg/ui/themes/validation.go +++ /dev/null @@ -1,132 +0,0 @@ -package themes - -import ( - "fmt" - "regexp" -) - -// hexColorRegex matches valid hex color codes (#RGB, #RRGGBB, #RRGGBBAA). -var hexColorRegex = regexp.MustCompile(`^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$`) - -// Validate validates the theme configuration. -// Returns an error if any required field is missing or invalid. -func (t *Theme) Validate() error { - // Validate metadata - if t.Name == "" { - return fmt.Errorf("theme name is required") - } - - // Validate colors - if err := t.Colors.Validate(); err != nil { - return fmt.Errorf("colors: %w", err) - } - - // Validate styles - if err := t.Styles.Validate(); err != nil { - return fmt.Errorf("styles: %w", err) - } - - // Validate symbols - if err := t.Symbols.Validate(); err != nil { - return fmt.Errorf("symbols: %w", err) - } - - return nil -} - -// Validate validates the color set. -func (cs *ColorSet) Validate() error { - // Validate required colors - requiredColors := map[string]string{ - "primary": cs.Primary, - "secondary": cs.Secondary, - "success": cs.Success, - "error": cs.Error, - "warning": cs.Warning, - "info": cs.Info, - "foreground": cs.Foreground, - "background": cs.Background, - "muted": cs.Muted, - "border": cs.Border, - } - - for name, color := range requiredColors { - if color == "" { - return fmt.Errorf("%s color is required", name) - } - if !isValidHexColor(color) { - return fmt.Errorf("%s color %q is not a valid hex color", name, color) - } - } - - // Validate banner gradient - if len(cs.BannerGradient) == 0 { - return fmt.Errorf("banner_gradient must have at least one color") - } - - for i, color := range cs.BannerGradient { - if !isValidHexColor(color) { - return fmt.Errorf("banner_gradient[%d] color %q is not a valid hex color", i, color) - } - } - - return nil -} - -// Validate validates the style set. -func (ss *StyleSet) Validate() error { - // Validate border style - validBorderStyles := map[string]bool{ - "": true, // Default (no border) - "normal": true, - "rounded": true, - "thick": true, - "double": true, - "hidden": true, - } - - if !validBorderStyles[ss.BorderStyle] { - return fmt.Errorf("invalid border_style %q (must be: normal, rounded, thick, double, or hidden)", ss.BorderStyle) - } - - // Validate padding values (must be non-negative) - if ss.PaddingTop < 0 { - return fmt.Errorf("padding_top must be non-negative, got %d", ss.PaddingTop) - } - if ss.PaddingRight < 0 { - return fmt.Errorf("padding_right must be non-negative, got %d", ss.PaddingRight) - } - if ss.PaddingBottom < 0 { - return fmt.Errorf("padding_bottom must be non-negative, got %d", ss.PaddingBottom) - } - if ss.PaddingLeft < 0 { - return fmt.Errorf("padding_left must be non-negative, got %d", ss.PaddingLeft) - } - - return nil -} - -// Validate validates the symbol set. -func (ss *SymbolSet) Validate() error { - // All symbols are optional, so just check they're valid UTF-8 - // (Go strings are always valid UTF-8, so this is a no-op) - - // We could add more validation here if needed, e.g.: - // - Check symbol length - // - Check for invalid characters - // - Ensure spinner has at least one frame - - if len(ss.Spinner) > 0 { - // Spinner should have at least one frame - if len(ss.Spinner) == 0 { - return fmt.Errorf("spinner must have at least one frame") - } - } - - return nil -} - -// isValidHexColor checks if a string is a valid hex color code. -func isValidHexColor(color string) bool { - return hexColorRegex.MatchString(color) -} diff --git a/pkg/ui/view/config_overview.go b/pkg/ui/view/config_overview.go new file mode 100644 index 0000000..e9176f4 --- /dev/null +++ b/pkg/ui/view/config_overview.go @@ -0,0 +1,146 @@ +package view + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" + + "github.com/arc-framework/arc-cli/pkg/ui/engine" +) + +// ConfigOverview renders live profile / skin pickers. +// On submission it dispatches engine.StateChangedMsg so the Shell +// re-renders the entire UI with the new selections. +type ConfigOverview struct { + ctx engine.ViewContext + profiles []string + skins []string + + selectedProfile string + selectedSkin string + + form *huh.Form + ready bool + changed bool + confirmed bool +} + +// NewConfigOverview creates the Config view. +// profiles and skins are the IDs available from the loader. +func NewConfigOverview(profiles, skins []string) *ConfigOverview { + return &ConfigOverview{ + profiles: profiles, + skins: skins, + } +} + +func (v *ConfigOverview) Init() tea.Cmd { return nil } + +func (v *ConfigOverview) OnEnter(ctx engine.ViewContext) tea.Cmd { + v.ctx = ctx + if tc := ctx.Theme; tc != nil { + v.selectedProfile = tc.Profile().ID + v.selectedSkin = tc.Skin().ID + } + // Rebuild the form every time so re-visiting the page works correctly. + v.changed = false + v.confirmed = false + v.buildForm() + v.ready = true + return v.form.Init() +} + +func (v *ConfigOverview) OnExit() tea.Cmd { return nil } + +func (v *ConfigOverview) Update(msg tea.Msg) (engine.View, tea.Cmd) { + if !v.ready { + return v, nil + } + // huh requires tea.WindowSizeMsg to activate keyboard handling. + // The shell forwards engine.ResizeMsg instead, so translate it here. + if r, ok := msg.(engine.ResizeMsg); ok { + msg = tea.WindowSizeMsg{Width: r.Width, Height: r.Height} + } + m, cmd := v.form.Update(msg) + if f, ok := m.(*huh.Form); ok { + v.form = f + } + if v.form.State == huh.StateCompleted && !v.changed { + v.changed = true + if !v.confirmed { + // User chose "No" — reset and rebuild so they can reselect. + v.changed = false + v.confirmed = false + v.buildForm() + return v, v.form.Init() + } + return v, func() tea.Msg { + return engine.StateChangedMsg{ + ProfileID: v.selectedProfile, + SkinID: v.selectedSkin, + } + } + } + return v, cmd +} + +func (v *ConfigOverview) View() string { + if !v.ready { + return "" + } + var primary string + if v.ctx.Theme != nil { + primary = v.ctx.Theme.Theme().Colors.Primary + } + if primary == "" { + primary = "#7C3AED" + } + title := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(primary)). + Padding(1, 2). + Render("Appearance Settings") + return lipgloss.JoinVertical(lipgloss.Left, title, v.form.View()) +} + +func (v *ConfigOverview) Name() string { return "Config" } +func (v *ConfigOverview) CapturesKeyboard() bool { + return v.ready && v.form != nil && v.form.State != huh.StateCompleted +} + +func (v *ConfigOverview) Keybindings() []engine.KeyBinding { + return []engine.KeyBinding{ + {Key: "enter", Desc: "confirm"}, + {Key: "esc", Desc: "cancel"}, + } +} + +func (v *ConfigOverview) buildForm() { + profileOpts := stringsToOptions(v.profiles) + + f := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Profile"). + Description("Personality, colors and layout tier"). + Options(profileOpts...). + Value(&v.selectedProfile), + + huh.NewConfirm(). + Title("Apply this profile?"). + Affirmative("Yes"). + Negative("No"). + Value(&v.confirmed), + ), + ).WithShowHelp(true) + v.form = f +} + +// stringsToOptions converts string IDs to huh select options. +func stringsToOptions(ids []string) []huh.Option[string] { + opts := make([]huh.Option[string], len(ids)) + for i, id := range ids { + opts[i] = huh.NewOption(id, id) + } + return opts +} diff --git a/pkg/ui/view/const.go b/pkg/ui/view/const.go new file mode 100644 index 0000000..5494516 --- /dev/null +++ b/pkg/ui/view/const.go @@ -0,0 +1,15 @@ +package view + +const ( + // emDash is the em-dash character used as a placeholder for missing values. + emDash = "—" + + // keyEnter is the key string for the Enter key used in keybindings. + keyEnter = "enter" + + // keyEsc is the key string for the Escape key used in keybindings. + keyEsc = "esc" + + // versionUnknown is the placeholder string for an unset build field. + versionUnknown = "unknown" +) diff --git a/pkg/ui/view/home.go b/pkg/ui/view/home.go new file mode 100644 index 0000000..32646ca --- /dev/null +++ b/pkg/ui/view/home.go @@ -0,0 +1,1452 @@ +package view + +import ( + "context" + "encoding/json" + "fmt" + "math" + "net" + "net/http" + "os" + "os/exec" + "runtime" + "strings" + "sync" + "time" + + "github.com/charmbracelet/bubbles/progress" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/harmonica" + "github.com/charmbracelet/lipgloss" + + "github.com/arc-framework/arc-cli/internal/branding" + "github.com/arc-framework/arc-cli/pkg/ui/component" + "github.com/arc-framework/arc-cli/pkg/ui/engine" + "github.com/arc-framework/arc-cli/pkg/ui/theme" + "github.com/arc-framework/arc-cli/pkg/version" +) + +// Column widths for the 3-panel home layout. +const ( + homeLeftW = 36 + homeCenterW = 40 + homeMinRight = 20 + + // homeLeftInner is the content width inside the left panel border+padding. + // NormalBorder(1 each side) + Padding(0,1)(1 each side) = 4 total. + homeLeftInner = homeLeftW - 4 + + homeDev = "dev" + homeScrollStep = 3 + homKVKeyW = 14 // key column width for Build/Resources label rows + homeSvcKeyW = 17 // key column width for service rows (emoji=2cols + label) + + homeSvcRunning = "running" + homeSvcStopped = "stopped" + homeSvcNotFound = "not found" + homeSvcChecking = "checking\u2026" + + homePRStateClosed = "closed" + + homeSectionCmds = 0 + homeSectionFeed = 1 + + // GitHub integration. + homeGHOrg = "arc-framework" + homeGHRepoPlatform = "arc-platform" + homeGHTokenHint = " export GITHUB_TOKEN=" +) + +// --------------------------------------------------------------------------- +// Data – commands +// --------------------------------------------------------------------------- + +type homeSub struct { + name string + short string +} + +type homeCmd struct { + name string + short string + long string + example string + navTo string + subs []homeSub +} + +func homeCmds() []homeCmd { + return []homeCmd{ + { + name: "workspace", + short: "manage workspaces", + long: "Manage ARC workspaces: initialize, run tasks, view info, and inspect execution history.", + example: "arc workspace init\narc workspace run build\narc workspace info\narc workspace history", + navTo: "Workspace", + subs: []homeSub{ + {"init", "initialize a new workspace"}, + {"run", "run a task in the workspace"}, + {"info", "show workspace metadata"}, + {"history", "view execution history"}, + }, + }, + { + name: "services", + short: "browse catalog", + long: "Browse and inspect services registered in the ARC catalog: list, view details, dependencies, and port assignments.", + example: "arc services list\narc services info api-gateway\narc services deps api-gateway\narc services ports", + navTo: "Services", + subs: []homeSub{ + {"list", "list available services"}, + {"info", "show service details"}, + {"deps", "show service dependencies"}, + {"ports", "show port assignments"}, + }, + }, + { + name: "config", + short: "manage profiles", + long: "Manage ARC configuration: switch profiles, list all profiles, and inspect the active configuration.", + example: "arc config get-profile\narc config list-profiles\narc config set-profile prod", + navTo: "Config", + subs: []homeSub{ + {"get-profile", "print active profile"}, + {"list-profiles", "list all profiles"}, + {"set-profile", "switch active profile"}, + }, + }, + { + name: "init", + short: "quick-start wizard", + long: "Run the interactive quick-start wizard to scaffold a new ARC project from scratch.", + example: "arc init", + navTo: "", + subs: nil, + }, + { + name: "version", + short: "show build info", + long: "Display the current CLI version, commit hash, build date, Go runtime, and platform details.", + example: "arc version", + navTo: "Version", + subs: nil, + }, + } +} + +// --------------------------------------------------------------------------- +// Data – activity feed (static placeholder; ready for GitHub API injection) +// --------------------------------------------------------------------------- + +type homeFeedItem struct { + kind string // "PR", "REL", "CI", etc. + label string + meta string + desc string + body []string +} + +func homeFeedItems() []homeFeedItem { + return []homeFeedItem{ + { + kind: "PR", + label: "#142 fix: memory leak in service spawner", + meta: "open \u00b7 2h ago \u00b7 @johndoe", + desc: "Fixes a significant memory leak in the service spawner component when services exit unexpectedly.", + body: []string{"Branch: fix/memory-leak", "Files: 3 changed", "Status: open"}, + }, + { + kind: "REL", + label: "v1.2.3 published to Homebrew", + meta: "release \u00b7 1d ago", + desc: "Patch release fixing CI pipeline and updating all dependencies to latest stable versions.", + body: []string{"Tag: v1.2.3", "Target: main", "Assets: 3 binaries"}, + }, + { + kind: "PR", + label: "#141 feat: new home dashboard layout", + meta: "merged \u00b7 3d ago \u00b7 @arc-bot", + desc: "Implements the new 3-column TUI home view with spring animations, system info, and activity feed.", + body: []string{"Branch: feat/home-redesign", "Files: 12 changed", "Status: merged"}, + }, + { + kind: "REL", + label: "v1.2.2 released", + meta: "release \u00b7 5d ago", + desc: "Bug fixes and performance improvements to workspace handling and service orchestration.", + body: []string{"Tag: v1.2.2", "Target: main", "Assets: 3 binaries"}, + }, + { + kind: "PR", + label: "#139 chore: update Go to 1.24.2", + meta: "merged \u00b7 1w ago \u00b7 @dependabot", + desc: "Bumps Go toolchain to 1.24.2 and refreshes all indirect dependencies to resolve security advisories.", + body: []string{"Branch: deps/go-1.24.2", "Files: 2 changed", "Status: merged"}, + }, + } +} + +// --------------------------------------------------------------------------- +// Data – GitHub PRs and packages +// --------------------------------------------------------------------------- + +type homePRItem struct { + number int + title string + repo string + author string + state string + age string +} + +type homeReleaseItem struct { + tag string + name string + age string + prerelease bool +} + +type homeCIRunItem struct { + id int64 + workflow string + status string // queued, in_progress, completed + conclusion string // success, failure, canceled, skipped + age string +} + +type homeGitHubDataMsg struct { + prs []homePRItem + releases []homeReleaseItem + ciRuns []homeCIRunItem + fetchOK bool + prStatusCode int // HTTP status for the PRs endpoint (0 = network error) +} + +// --------------------------------------------------------------------------- +// Messages +// --------------------------------------------------------------------------- + +type homeSysInfoMsg struct { + info *branding.SystemInfo +} + +type homeSpringTickMsg struct{} + +type homeClockTickMsg struct{} + +type homeServiceCheckMsg struct { + docker string + llmStudio string +} + +func homeSpringTickCmd() tea.Cmd { + return tea.Tick(time.Second/60, func(time.Time) tea.Msg { return homeSpringTickMsg{} }) +} + +func homeClockTickCmd() tea.Cmd { + return tea.Tick(time.Second, func(time.Time) tea.Msg { return homeClockTickMsg{} }) +} + +func homeFetchSysInfo() tea.Cmd { + return func() tea.Msg { + info, _ := branding.CollectSystemInfo() + return homeSysInfoMsg{info: info} + } +} + +func homeCheckServicesCmd() tea.Cmd { + return func() tea.Msg { + return homeServiceCheckMsg{ + docker: homeProbeDocker(), + llmStudio: homeProbePort("127.0.0.1:1234", "lmstudio"), + } + } +} + +func homeProbeDocker() string { + if _, err := exec.LookPath("docker"); err != nil { + return homeSvcNotFound + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if exec.CommandContext(ctx, "docker", "info").Run() == nil { + return homeSvcRunning + } + return homeSvcStopped +} + +// homeDoGet is a function type for making authenticated GitHub API requests. +type homeDoGet func(url string) (*http.Response, error) + +// homeFetchState holds mutable results shared across concurrent GitHub fetch goroutines. +type homeFetchState struct { + mu sync.Mutex + prs []homePRItem + releases []homeReleaseItem + ciRuns []homeCIRunItem + fetchOK bool + prStatusCode int +} + +func homeFetchPRs(doGet homeDoGet, st *homeFetchState) { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/pulls?state=open&per_page=5", homeGHOrg, homeGHRepoPlatform) + resp, err := doGet(url) + if err != nil { + return // network error; prStatusCode stays 0 + } + defer func() { _ = resp.Body.Close() }() + st.mu.Lock() + st.prStatusCode = resp.StatusCode + st.mu.Unlock() + if resp.StatusCode != http.StatusOK { + return + } + var items []struct { + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + CreatedAt string `json:"created_at"` + User struct { + Login string `json:"login"` + } `json:"user"` + } + if decErr := json.NewDecoder(resp.Body).Decode(&items); decErr != nil { + return + } + st.mu.Lock() + defer st.mu.Unlock() + st.fetchOK = true + for _, it := range items { + t, _ := time.Parse(time.RFC3339, it.CreatedAt) + st.prs = append(st.prs, homePRItem{ + number: it.Number, + title: it.Title, + repo: homeGHRepoPlatform, + author: it.User.Login, + state: it.State, + age: homeRelativeAge(t), + }) + } +} + +func homeFetchReleases(doGet homeDoGet, st *homeFetchState) { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases?per_page=5", homeGHOrg, homeGHRepoPlatform) + resp, err := doGet(url) + if err != nil || resp.StatusCode != http.StatusOK { + return + } + defer func() { _ = resp.Body.Close() }() + var items []struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Prerelease bool `json:"prerelease"` + PublishedAt string `json:"published_at"` + } + if decErr := json.NewDecoder(resp.Body).Decode(&items); decErr != nil { + return + } + st.mu.Lock() + defer st.mu.Unlock() + for _, it := range items { + t, _ := time.Parse(time.RFC3339, it.PublishedAt) + st.releases = append(st.releases, homeReleaseItem{ + tag: it.TagName, + name: it.Name, + age: homeRelativeAge(t), + prerelease: it.Prerelease, + }) + } +} + +func homeFetchCIRuns(doGet homeDoGet, st *homeFetchState) { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/actions/runs?per_page=5", homeGHOrg, homeGHRepoPlatform) + resp, err := doGet(url) + if err != nil || resp.StatusCode != http.StatusOK { + return + } + defer func() { _ = resp.Body.Close() }() + var payload struct { + Runs []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + UpdatedAt string `json:"updated_at"` + } `json:"workflow_runs"` + } + if decErr := json.NewDecoder(resp.Body).Decode(&payload); decErr != nil { + return + } + st.mu.Lock() + defer st.mu.Unlock() + for _, it := range payload.Runs { + t, _ := time.Parse(time.RFC3339, it.UpdatedAt) + st.ciRuns = append(st.ciRuns, homeCIRunItem{ + id: it.ID, + workflow: it.Name, + status: it.Status, + conclusion: it.Conclusion, + age: homeRelativeAge(t), + }) + } +} + +// homeFetchGitHubCmd fetches open PRs, latest releases, and recent CI runs from arc-platform. +func homeFetchGitHubCmd() tea.Cmd { + return func() tea.Msg { + token := os.Getenv("GITHUB_TOKEN") + if token == "" { + token = os.Getenv("GH_TOKEN") + } + doGet := homeDoGet(func(url string) (*http.Response, error) { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, http.NoBody) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + return http.DefaultClient.Do(req) //nolint:gosec // URL is built from internal constants only + }) + st := &homeFetchState{} + var wg sync.WaitGroup + wg.Add(3) + go func() { defer wg.Done(); homeFetchPRs(doGet, st) }() + go func() { defer wg.Done(); homeFetchReleases(doGet, st) }() + go func() { defer wg.Done(); homeFetchCIRuns(doGet, st) }() + wg.Wait() + return homeGitHubDataMsg{ + prs: st.prs, + releases: st.releases, + ciRuns: st.ciRuns, + fetchOK: st.fetchOK, + prStatusCode: st.prStatusCode, + } + } +} + +// homeRelativeAge returns a short human-readable duration since t. +func homeRelativeAge(t time.Time) string { + if t.IsZero() { + return emDash + } + d := time.Since(t) + switch { + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + case d < 7*24*time.Hour: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + default: + return fmt.Sprintf("%dw", int(d.Hours()/(24*7))) + } +} + +func homeProbePort(addr, binary string) string { + conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond) + if err == nil { + _ = conn.Close() + return homeSvcRunning + } + if _, lookErr := exec.LookPath(binary); lookErr == nil { + return homeSvcStopped + } + return homeSvcNotFound +} + +// homeOpenURL opens a URL in the system default browser. +func homeOpenURL(url string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + cmd = exec.Command("xdg-open", url) + } + _ = cmd.Start() +} + +// --------------------------------------------------------------------------- +// View +// --------------------------------------------------------------------------- + +// HomeView is the landing screen of the ARC TUI. +// It shows a 3-column layout: left (hero + build + resources in one border), +// center (commands), and right (PR table always visible). +type HomeView struct { + ctx engine.ViewContext + cmds []homeCmd + cursor int + rightScroll int + activeSection int + feedItems []homeFeedItem + feedCursor int + prItems []homePRItem + releaseItems []homeReleaseItem + ciItems []homeCIRunItem + prFetchOK bool + prStatusCode int + loadingGH bool + sysInfo *branding.SystemInfo + loadingSys bool + dockerStatus string + llmStatus string + spinner component.Spinner + memBar progress.Model + spring harmonica.Spring + springPos float64 + springVel float64 + now time.Time + ready bool +} + +// NewHome creates the HomeView. +func NewHome() *HomeView { + return &HomeView{} +} + +func (v *HomeView) Init() tea.Cmd { return nil } + +func (v *HomeView) OnEnter(ctx engine.ViewContext) tea.Cmd { + v.ctx = ctx + v.cmds = homeCmds() + v.cursor = 0 + v.springPos = 0 + v.springVel = 0 + v.spring = harmonica.NewSpring(harmonica.FPS(60), 6.0, 0.5) + v.loadingSys = true + v.dockerStatus = homeSvcChecking + v.llmStatus = homeSvcChecking + v.now = time.Now() + v.spinner = component.NewSpinner(ctx.Theme) + v.memBar = progress.New( + progress.WithoutPercentage(), + progress.WithWidth(homeLeftInner-2), + ) + v.feedItems = homeFeedItems() + v.feedCursor = 0 + v.activeSection = homeSectionCmds + v.prItems = nil + v.releaseItems = nil + v.ciItems = nil + v.prFetchOK = false + v.prStatusCode = 0 + v.loadingGH = true + v.ready = true + return tea.Batch( + v.spinner.Tick(), + homeFetchSysInfo(), + homeCheckServicesCmd(), + homeFetchGitHubCmd(), + homeClockTickCmd(), + homeSpringTickCmd(), + ) +} + +func (v *HomeView) OnExit() tea.Cmd { return nil } + +// handleOpenInBrowser opens the selected PR, release, or CI run in the system browser. +func (v *HomeView) handleOpenInBrowser() tea.Cmd { + if v.activeSection != homeSectionFeed { + return nil + } + var url string + if v.feedCursor >= 0 && v.feedCursor < len(v.prItems) { + pr := v.prItems[v.feedCursor] + url = fmt.Sprintf("https://github.com/%s/%s/pull/%d", homeGHOrg, homeGHRepoPlatform, pr.number) + } + if url == "" { + return nil + } + return func() tea.Msg { + homeOpenURL(url) + return nil + } +} + +// handleKey processes keyboard input and returns any resulting command. +// handleKeyNav moves the active section's cursor up (delta=-1) or down (delta=+1). +func (v *HomeView) handleKeyNav(delta int) tea.Cmd { + if v.activeSection == homeSectionCmds { + next := v.cursor + delta + if next >= 0 && next < len(v.cmds) { + v.cursor = next + v.rightScroll = 0 + return homeSpringTickCmd() + } + return nil + } + // feed section: navigate prItems list + maxIdx := len(v.prItems) - 1 + if maxIdx < 0 { + return nil + } + next := v.feedCursor + delta + if next >= 0 && next <= maxIdx { + v.feedCursor = next + v.rightScroll = 0 + } + return nil +} + +func (v *HomeView) handleKey(m tea.KeyMsg) tea.Cmd { + switch m.String() { + case "h", "l": + if v.activeSection == homeSectionCmds { + v.activeSection = homeSectionFeed + } else { + v.activeSection = homeSectionCmds + } + v.rightScroll = 0 + + case "up", "k": + return v.handleKeyNav(-1) + + case "down", "j": + return v.handleKeyNav(+1) + + case "[": + v.rightScroll -= homeScrollStep + if v.rightScroll < 0 { + v.rightScroll = 0 + } + + case "]": + v.rightScroll += homeScrollStep + + case keyEnter: + if v.activeSection == homeSectionCmds && v.cursor < len(v.cmds) && v.cmds[v.cursor].navTo != "" { + navTo := v.cmds[v.cursor].navTo + return func() tea.Msg { return engine.NavigateMsg{ViewName: navTo} } + } + return v.handleOpenInBrowser() + + case "o": + return v.handleOpenInBrowser() + } + return nil +} + +func (v *HomeView) Update(msg tea.Msg) (engine.View, tea.Cmd) { + var cmds []tea.Cmd + + switch m := msg.(type) { + case homeSysInfoMsg: + v.loadingSys = false + v.sysInfo = m.info + + case homeServiceCheckMsg: + v.dockerStatus = m.docker + v.llmStatus = m.llmStudio + + case homeGitHubDataMsg: + v.loadingGH = false + v.prItems = m.prs + v.releaseItems = m.releases + v.ciItems = m.ciRuns + v.prFetchOK = m.fetchOK + v.prStatusCode = m.prStatusCode + + case homeSpringTickMsg: + v.springPos, v.springVel = v.spring.Update(v.springPos, v.springVel, float64(v.cursor)) + if math.Abs(v.springPos-float64(v.cursor)) > 0.01 || math.Abs(v.springVel) > 0.01 { + cmds = append(cmds, homeSpringTickCmd()) + } + + case homeClockTickMsg: + v.now = time.Now() + cmds = append(cmds, homeClockTickCmd()) + + case tea.KeyMsg: + if cmd := v.handleKey(m); cmd != nil { + cmds = append(cmds, cmd) + } + } + + if v.loadingSys { + var spinCmd tea.Cmd + v.spinner, spinCmd = v.spinner.Update(msg) + if spinCmd != nil { + cmds = append(cmds, spinCmd) + } + } + + return v, tea.Batch(cmds...) +} + +func (v *HomeView) View() string { + if !v.ready { + return "" + } + + h := v.ctx.Height + if h <= 0 { + h = 24 + } + + tc := v.ctx.Theme + + sep := " " + + right := v.ctx.Width - homeLeftW - homeCenterW - 2 + if right < homeMinRight { + right = homeMinRight + } + + left := v.renderLeft(tc, h) + center := v.renderCenter(tc, h) + rightPane := v.renderRight(tc, right, h) + + return lipgloss.JoinHorizontal(lipgloss.Top, left, sep, center, sep, rightPane) +} + +// --------------------------------------------------------------------------- +// Left panel – single bordered container, three sections +// --------------------------------------------------------------------------- + +func (v *HomeView) renderLeft(tc *theme.Context, h int) string { + primary := lipgloss.Color("#00ADD8") + muted := lipgloss.Color("#6272A4") + if tc != nil { + primary = lipgloss.Color(tc.Theme().Colors.Primary) + muted = lipgloss.Color(tc.Theme().Colors.Muted) + } + + div := lipgloss.NewStyle(). + Foreground(muted). + Width(homeLeftInner). + Render(strings.Repeat("\u2500", homeLeftInner)) + sectionTitle := func(s string) string { + return lipgloss.NewStyle().Foreground(primary).Bold(true).Render(s) + } + + inner := lipgloss.JoinVertical(lipgloss.Left, + v.renderHeroInner(tc), + div, + sectionTitle("Build"), + v.renderBuildSection(tc), + div, + sectionTitle("Resources"), + v.renderResourceSection(tc), + ) + + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(primary). + Padding(0, 1). + Width(homeLeftInner). + Height(h - 2). + Render(inner) +} + +// renderHeroInner renders logo art and tagline without an outer border. +func (v *HomeView) renderHeroInner(tc *theme.Context) string { + primary := lipgloss.Color("#00ADD8") + muted := lipgloss.Color("#6272A4") + art := branding.Name + if tc != nil { + primary = lipgloss.Color(tc.Theme().Colors.Primary) + muted = lipgloss.Color(tc.Theme().Colors.Muted) + if p := tc.Profile(); p != nil && p.Logo != "" { + art = p.Logo + } + } + rawLines := strings.Split(art, "\n") + artLines := make([]string, 0, len(rawLines)) + for _, l := range rawLines { + // Clip each line to homeLeftInner columns so the div stays aligned. + runes := []rune(l) + if len(runes) > homeLeftInner { + l = string(runes[:homeLeftInner]) + } + artLines = append(artLines, l) + } + logo := lipgloss.NewStyle().Foreground(primary).Render(strings.Join(artLines, "\n")) + tagline := lipgloss.NewStyle().Foreground(muted).Render(branding.Tagline) + return lipgloss.JoinVertical(lipgloss.Left, logo, tagline, "") +} + +// renderBuildSection shows CLI version, commit, platform, and live date/time. +func (v *HomeView) renderBuildSection(tc *theme.Context) string { + muted := lipgloss.Color("#6272A4") + fg := lipgloss.Color("#F8F8F2") + if tc != nil { + muted = lipgloss.Color(tc.Theme().Colors.Muted) + fg = lipgloss.Color(tc.Theme().Colors.Foreground) + } + + row := func(l, val string) string { + return lipgloss.NewStyle().Foreground(muted).Width(homKVKeyW).Render(l) + + lipgloss.NewStyle().Foreground(fg).Render(val) + } + + ver := version.Version + if ver == "" { + ver = homeDev + } + + var lines []string + if tc != nil { + lines = append(lines, "", component.Badge(tc, "v"+ver, component.BadgeInfo), "") + } else { + lines = append(lines, "", "v"+ver, "") + } + + commit := version.Commit + if len(commit) > 7 { + commit = commit[:7] + } + if commit == "" { + commit = emDash + } + + buildDate := version.BuildDate + if len(buildDate) > 10 { + buildDate = buildDate[:10] + } + if buildDate == "" { + buildDate = emDash + } + + lines = append(lines, + row("commit", commit), + row("built", buildDate), + row("platform", runtime.GOOS+"/"+runtime.GOARCH), + row("runtime", runtime.Version()), + row("cores", fmt.Sprintf("%d", runtime.NumCPU())), + "", + row("date", v.now.Format("Mon 02 Jan 2006")), + row("time", v.now.Format("15:04:05")), + ) + + return strings.Join(lines, "\n") +} + +// renderResourceSection shows CPU, memory bar with capacity, and service statuses. +func (v *HomeView) renderResourceSection(tc *theme.Context) string { + if v.loadingSys { + return "\n" + v.spinner.View() + " loading\u2026" + } + + primary := lipgloss.Color("#00ADD8") + muted := lipgloss.Color("#6272A4") + fg := lipgloss.Color("#F8F8F2") + if tc != nil { + primary = lipgloss.Color(tc.Theme().Colors.Primary) + muted = lipgloss.Color(tc.Theme().Colors.Muted) + fg = lipgloss.Color(tc.Theme().Colors.Foreground) + } + + row := func(l, val string) string { + return lipgloss.NewStyle().Foreground(muted).Width(homKVKeyW).Render(l) + + lipgloss.NewStyle().Foreground(fg).Render(val) + } + + var lines []string + lines = append(lines, "", "") // two blank lines between title and content + + if v.sysInfo != nil { + cpu := v.sysInfo.CPUModel + if len(cpu) > homeLeftInner-10 { + cpu = cpu[:homeLeftInner-10] + } + lines = append(lines, row("cpu", cpu)) + + if v.sysInfo.MemoryTotal > 0 { + usedGB := float64(v.sysInfo.MemoryTotal-v.sysInfo.MemoryFree) / 1024 / 1024 / 1024 + totalGB := float64(v.sysInfo.MemoryTotal) / 1024 / 1024 / 1024 + pct := usedGB / totalGB + lines = append(lines, + row("used", fmt.Sprintf("%.1f GB", usedGB)), + row("total", fmt.Sprintf("%.1f GB", totalGB)), + lipgloss.NewStyle().Foreground(primary).Render(v.memBar.ViewAs(pct)), + ) + } + } + + lines = append(lines, + "", + homeSvcRow("docker", v.dockerStatus, primary, muted), + homeSvcRow("lm-studio", v.llmStatus, primary, muted), + ) + + return strings.Join(lines, "\n") +} + +// homeSvcRow renders a single service status row with a colored indicator and emoji. +func homeSvcRow(name, status string, primary, muted lipgloss.Color) string { + var emoji string + switch name { + case "docker": + emoji = "\U0001F433 " + case "lm-studio": + emoji = "\U0001F916 " + default: + emoji = "" + } + // Use a plain-text label for width calculation (emoji renders as 2 cols). + label := lipgloss.NewStyle().Foreground(muted).Render(emoji + name) + padded := lipgloss.NewStyle().Width(homeSvcKeyW).Render(label) + var dot string + switch status { + case homeSvcRunning: + dot = lipgloss.NewStyle().Foreground(primary).Render("\u25cf ") + case homeSvcStopped: + dot = lipgloss.NewStyle().Foreground(lipgloss.Color("#F1FA8C")).Render("\u25cb ") + default: + dot = lipgloss.NewStyle().Foreground(muted).Render("\u2014 ") + } + return padded + dot + lipgloss.NewStyle().Foreground(muted).Render(status) +} + +// --------------------------------------------------------------------------- +// Center panel – commands (bordered) +// --------------------------------------------------------------------------- + +func (v *HomeView) renderCenter(tc *theme.Context, h int) string { + primary := lipgloss.Color("#00ADD8") + muted := lipgloss.Color("#6272A4") + if tc != nil { + primary = lipgloss.Color(tc.Theme().Colors.Primary) + muted = lipgloss.Color(tc.Theme().Colors.Muted) + } + + focused := lipgloss.NewStyle().Foreground(primary).Bold(true) + dimmed := lipgloss.NewStyle().Foreground(muted) + + visualRow := int(math.Round(v.springPos)) + if visualRow < 0 { + visualRow = 0 + } + if visualRow >= len(v.cmds) { + visualRow = len(v.cmds) - 1 + } + + // innerW: homeCenterW minus border(2) and padding L+R(2) + innerW := homeCenterW - 4 + + var sectionTab string + if v.activeSection == homeSectionCmds { + sectionTab = focused.Render("\u25b8 commands") + " " + dimmed.Render("PRs [l \u2192]") + } else { + sectionTab = dimmed.Render(" commands [h \u2190]") + " " + focused.Render("\u25b8 PRs") + } + + divider := lipgloss.NewStyle().Foreground(muted).Width(innerW).Render(strings.Repeat("\u2500", innerW)) + + var cmdRows []string + for i, c := range v.cmds { + if v.activeSection == homeSectionCmds && i == visualRow { + cmdRows = append(cmdRows, focused.Render("\u25b8 "+c.name)) + } else { + cmdRows = append(cmdRows, dimmed.Render("\u00b7 "+c.name)) + } + } + + body := lipgloss.JoinVertical(lipgloss.Left, + sectionTab, + divider, + "", + strings.Join(cmdRows, "\n"), + ) + + borderColor := muted + if v.activeSection == homeSectionCmds { + borderColor = primary + } + + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Padding(0, 1). + Width(homeCenterW - 2). + Height(h - 2). + Render(body) +} + +// homeScrollLines applies scroll windowing to a slice of lines, returning visible +// lines and indicator strings for content above/below the viewport. +func homeScrollLines(lines []string, scroll, h int) (visible []string, aboveInd, belowInd string) { + total := len(lines) + hasAbove := scroll > 0 + end := scroll + h + hasBelow := end < total + if hasAbove { + end-- + } + if hasBelow { + end-- + } + if end > total { + end = total + } + start := scroll + if start > end { + start = end + } + if start < end { + visible = lines[start:end] + } + if hasAbove { + aboveInd = " \u2191 [ more above" + } + if hasBelow { + belowInd = " \u2193 ] more below" + } + return visible, aboveInd, belowInd +} + +func (v *HomeView) renderRight(tc *theme.Context, w, h int) string { + primary := lipgloss.Color("#00ADD8") + muted := lipgloss.Color("#6272A4") + if tc != nil { + primary = lipgloss.Color(tc.Theme().Colors.Primary) + muted = lipgloss.Color(tc.Theme().Colors.Muted) + } + mutedStyle := lipgloss.NewStyle().Foreground(muted) + + // Border highlights when the feed/PR section is active. + borderColor := muted + if v.activeSection == homeSectionFeed { + borderColor = primary + } + + // innerW = w minus border (2) and padding (2 each side = 2). + innerW := w - 4 + if innerW < 10 { + innerW = 10 + } + + // Route content: command detail when commands section active, PR table otherwise. + var contentLines []string + if v.activeSection == homeSectionCmds && len(v.cmds) > 0 { + contentLines = v.buildCmdRightLines(v.cmds[v.cursor], tc, innerW) + } else { + contentLines = v.buildPRTableLines(tc, innerW, v.feedCursor) + } + visible, aboveInd, belowInd := homeScrollLines(contentLines, v.rightScroll, h-2) + + var rows []string + if aboveInd != "" { + rows = append(rows, mutedStyle.Render(aboveInd)) + } + rows = append(rows, visible...) + if belowInd != "" { + rows = append(rows, mutedStyle.Render(belowInd)) + } + + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Padding(0, 1). + Width(w - 2). + Height(h - 2). + Render(strings.Join(rows, "\n")) +} + +// buildCmdRightLines assembles content lines for a command detail. +func (v *HomeView) buildCmdRightLines(cmd homeCmd, tc *theme.Context, innerW int) []string { + primary := lipgloss.Color("#00ADD8") + muted := lipgloss.Color("#6272A4") + fg := lipgloss.Color("#F8F8F2") + if tc != nil { + primary = lipgloss.Color(tc.Theme().Colors.Primary) + muted = lipgloss.Color(tc.Theme().Colors.Muted) + fg = lipgloss.Color(tc.Theme().Colors.Foreground) + } + + titleStyle := lipgloss.NewStyle().Foreground(primary).Bold(true) + mutedStyle := lipgloss.NewStyle().Foreground(muted) + fgStyle := lipgloss.NewStyle().Foreground(fg) + divider := mutedStyle.Render(strings.Repeat("\u2500", innerW)) + + var lines []string + lines = append(lines, titleStyle.Render(cmd.name), divider) + + if cmd.long != "" { + lines = append(lines, "", fgStyle.Render(wordWrap(cmd.long, innerW)), "") + } + + if len(cmd.subs) > 0 { + lines = append(lines, titleStyle.Render("Subcommands")) + for _, s := range cmd.subs { + name := lipgloss.NewStyle().Foreground(primary).Width(16).Render(s.name) + lines = append(lines, " "+name+mutedStyle.Render(s.short)) + } + lines = append(lines, "") + } + + if cmd.example != "" { + lines = append(lines, titleStyle.Render("Examples")) + for _, ex := range strings.Split(cmd.example, "\n") { + prompt := lipgloss.NewStyle().Foreground(lipgloss.Color("#50FA7B")).Bold(true).Render("$") + lines = append(lines, " "+prompt+" "+fgStyle.Render(ex)) + } + } + + return lines +} + +// wordWrap wraps text at the given column width. +func wordWrap(text string, width int) string { + if width <= 0 { + return text + } + words := strings.Fields(text) + var lines []string + line := "" + for _, w := range words { + if line == "" { + line = w + } else if len(line)+1+len(w) <= width { + line += " " + w + } else { + lines = append(lines, line) + line = w + } + } + if line != "" { + lines = append(lines, line) + } + return strings.Join(lines, "\n") +} + +// homeCell pads/truncates content inside a table column. +// colWidth includes 1 space of padding on each side. +func homeCell(content string, colWidth int) string { + inner := colWidth - 2 + if inner < 1 { + inner = 1 + } + r := []rune(content) + if len(r) > inner { + r = r[:inner-1] + r = append(r, '\u2026') + } + return " " + string(r) + strings.Repeat(" ", inner-len(r)) + " " +} + +// homeTableEdge builds a horizontal border row (top, middle, or bottom). +func homeTableEdge(left, mid, right string, colWidths []int) string { + segs := make([]string, len(colWidths)) + for i, w := range colWidths { + segs[i] = strings.Repeat("\u2500", w) + } + return left + strings.Join(segs, mid) + right +} + +// homeTableRow assembles a table row from pre-rendered (possibly ANSI-styled) cell strings. +// Each cell[i] should already be padded to colWidths[i] characters (printable). +// sep is the styled vertical bar used as column separator. +func homeTableRow(cells []string, sep string) string { + return sep + strings.Join(cells, sep) + sep +} + +// homeTableSty bundles the lipgloss styles shared across the three table sections. +type homeTableSty struct { + border lipgloss.Style + title lipgloss.Style + hdr lipgloss.Style + muted lipgloss.Style + fg lipgloss.Style + accent lipgloss.Style + sel lipgloss.Style + sep string + danger lipgloss.Color + warn lipgloss.Color + accentC lipgloss.Color // raw color for use as lipgloss.Color value + mutedC lipgloss.Color // raw color for use as lipgloss.Color value +} + +// homeRenderPRRow renders a single PR table data row. +func homeRenderPRRow(i int, pr homePRItem, selIdx int, sty homeTableSty, prColStt, prColNum, prColAge, prColTTL int) string { + numStr := fmt.Sprintf("#%d", pr.number) + if i == selIdx { + return homeTableRow([]string{ + sty.sel.Render(homeCell("●", prColStt)), + sty.sel.Render(homeCell(numStr, prColNum)), + sty.sel.Render(homeCell(pr.age, prColAge)), + sty.sel.Render(homeCell(pr.title, prColTTL)), + }, sty.sep) + } + dotColor := sty.accentC + if pr.state == homePRStateClosed { + dotColor = sty.danger + } + dot := lipgloss.NewStyle().Foreground(dotColor) + return homeTableRow([]string{ + dot.Render(homeCell("●", prColStt)), + sty.muted.Render(homeCell(numStr, prColNum)), + sty.muted.Render(homeCell(pr.age, prColAge)), + sty.fg.Render(homeCell(pr.title, prColTTL)), + }, sty.sep) +} + +// homeRenderPRSection renders the Open PRs box-drawing table section. +func homeRenderPRSection(items []homePRItem, innerW, selIdx int, sty homeTableSty, repoLabel, liveLabel string) []string { + // Columns: ● | # | age | title + const prColStt = 3 // ` ● ` + const prColNum = 6 // ` #123 ` + const prColAge = 5 // ` 14h ` + prColTTL := innerW - prColStt - prColNum - prColAge - 5 + if prColTTL < 6 { + prColTTL = 6 + } + widths := []int{prColStt, prColNum, prColAge, prColTTL} + + if len(items) > 5 { + items = items[:5] + } + + out := []string{ + sty.title.Render("Open PRs") + " " + repoLabel + " " + liveLabel, + sty.border.Render(homeTableEdge("\u256d", "\u252c", "\u256e", widths)), + homeTableRow([]string{ + sty.hdr.Render(homeCell("", prColStt)), + sty.hdr.Render(homeCell("#", prColNum)), + sty.hdr.Render(homeCell("age", prColAge)), + sty.hdr.Render(homeCell("title", prColTTL)), + }, sty.sep), + sty.border.Render(homeTableEdge("\u251c", "\u253c", "\u2524", widths)), + } + + if len(items) == 0 { + out = append(out, homeTableRow([]string{ + sty.muted.Render(homeCell("", prColStt)), + sty.fg.Render(homeCell("no open PRs", prColNum+1+prColAge+1+prColTTL)), + }, sty.sep)) + } else { + for i, pr := range items { + out = append(out, homeRenderPRRow(i, pr, selIdx, sty, prColStt, prColNum, prColAge, prColTTL)) + } + } + out = append(out, + sty.border.Render(homeTableEdge("\u2570", "\u2534", "\u256f", widths)), + sty.muted.Render(" \u2191/\u2193 navigate \u00b7 o open in browser"), + "", + ) + return out +} + +// homeRenderRelSection renders the Releases box-drawing table section. +func homeRenderRelSection(items []homeReleaseItem, innerW int, sty homeTableSty, repoLabel string) []string { + // Columns: tag | age | name + const relColTag = 10 + const relColAge = 5 + relColName := innerW - relColTag - relColAge - 4 + if relColName < 6 { + relColName = 6 + } + widths := []int{relColTag, relColAge, relColName} + + if len(items) > 5 { + items = items[:5] + } + + out := []string{ + sty.title.Render("Releases") + " " + repoLabel, + sty.border.Render(homeTableEdge("\u256d", "\u252c", "\u256e", widths)), + homeTableRow([]string{ + sty.hdr.Render(homeCell("tag", relColTag)), + sty.hdr.Render(homeCell("age", relColAge)), + sty.hdr.Render(homeCell("name", relColName)), + }, sty.sep), + sty.border.Render(homeTableEdge("\u251c", "\u253c", "\u2524", widths)), + } + if len(items) == 0 { + out = append(out, homeTableRow([]string{ + sty.muted.Render(homeCell("", relColTag)), + sty.fg.Render(homeCell("no releases found", relColAge+1+relColName)), + }, sty.sep)) + } else { + for _, r := range items { + name := r.name + if r.prerelease { + name += " (pre)" + } + out = append(out, homeTableRow([]string{ + sty.accent.Render(homeCell(r.tag, relColTag)), + sty.muted.Render(homeCell(r.age, relColAge)), + sty.fg.Render(homeCell(name, relColName)), + }, sty.sep)) + } + } + out = append(out, + sty.border.Render(homeTableEdge("\u2570", "\u2534", "\u256f", widths)), + "", + ) + return out +} + +// homeRenderCISection renders the CI Runs box-drawing table section. +func homeRenderCISection(items []homeCIRunItem, innerW int, sty homeTableSty, repoLabel string) []string { + // Columns: icon | age | workflow + const ciColIco = 3 + const ciColAge = 5 + ciColWf := innerW - ciColIco - ciColAge - 4 + if ciColWf < 6 { + ciColWf = 6 + } + widths := []int{ciColIco, ciColAge, ciColWf} + + if len(items) > 5 { + items = items[:5] + } + + out := []string{ + sty.title.Render("CI Runs") + " " + repoLabel, + sty.border.Render(homeTableEdge("\u256d", "\u252c", "\u256e", widths)), + homeTableRow([]string{ + sty.hdr.Render(homeCell("", ciColIco)), + sty.hdr.Render(homeCell("age", ciColAge)), + sty.hdr.Render(homeCell("workflow", ciColWf)), + }, sty.sep), + sty.border.Render(homeTableEdge("\u251c", "\u253c", "\u2524", widths)), + } + if len(items) == 0 { + out = append(out, homeTableRow([]string{ + sty.muted.Render(homeCell("", ciColIco)), + sty.fg.Render(homeCell("no CI runs found", ciColAge+1+ciColWf)), + }, sty.sep)) + } else { + for _, ci := range items { + icon, color := homeCIIcon(ci, sty) + out = append(out, homeTableRow([]string{ + lipgloss.NewStyle().Foreground(color).Render(homeCell(icon, ciColIco)), + sty.muted.Render(homeCell(ci.age, ciColAge)), + sty.fg.Render(homeCell(ci.workflow, ciColWf)), + }, sty.sep)) + } + } + out = append(out, sty.border.Render(homeTableEdge("\u2570", "\u2534", "\u256f", widths))) + return out +} + +// homeCIIcon returns the display icon and color for a CI run based on its conclusion/status. +func homeCIIcon(ci homeCIRunItem, sty homeTableSty) (string, lipgloss.Color) { + switch ci.conclusion { + case "success": + return "\u2713", sty.accentC + case "failure": + return "\u2717", sty.danger + case "canceled": + return "\u25a1", sty.mutedC + default: + if ci.status == "in_progress" { + return "\u25b6", sty.warn + } + return "\u25cb", sty.mutedC + } +} + +// buildPRTableLines renders open PRs, releases and CI runs as box-drawing tables. +func (v *HomeView) buildPRTableLines(tc *theme.Context, innerW, selectedIdx int) []string { + primary := lipgloss.Color("#00ADD8") + muted := lipgloss.Color("#6272A4") + fg := lipgloss.Color("#F8F8F2") + accent := lipgloss.Color("#50FA7B") + if tc != nil { + primary = lipgloss.Color(tc.Theme().Colors.Primary) + muted = lipgloss.Color(tc.Theme().Colors.Muted) + fg = lipgloss.Color(tc.Theme().Colors.Foreground) + if tc.Theme().Colors.Accent != "" { + accent = lipgloss.Color(tc.Theme().Colors.Accent) + } + } + + sty := homeTableSty{ + border: lipgloss.NewStyle().Foreground(muted), + title: lipgloss.NewStyle().Foreground(primary).Bold(true), + hdr: lipgloss.NewStyle().Foreground(muted).Bold(true), + muted: lipgloss.NewStyle().Foreground(muted), + fg: lipgloss.NewStyle().Foreground(fg), + accent: lipgloss.NewStyle().Foreground(accent), + sel: lipgloss.NewStyle().Background(lipgloss.Color("#383a59")).Foreground(fg), + danger: lipgloss.Color("#FF5555"), + warn: lipgloss.Color("#F1FA8C"), + accentC: accent, + mutedC: muted, + } + sty.sep = sty.border.Render("\u2502") + + token := os.Getenv("GITHUB_TOKEN") + if token == "" { + token = os.Getenv("GH_TOKEN") + } + liveLabel := sty.accent.Render("\u25cf live") + if token != "" { + liveLabel = sty.accent.Render("\u25cf live \u00b7 authed") + } + repoLabel := sty.muted.Render(homeGHRepoPlatform) + + if v.loadingGH { + return []string{ + sty.title.Render("arc-platform"), + sty.border.Render(strings.Repeat("\u2500", innerW)), + "", + sty.muted.Render(" loading\u2026"), + } + } + + if !v.prFetchOK { + return v.buildGHErrorLines(sty, innerW) + } + + lines := make([]string, 0, 50) + lines = append(lines, homeRenderPRSection(v.prItems, innerW, selectedIdx, sty, repoLabel, liveLabel)...) + lines = append(lines, homeRenderRelSection(v.releaseItems, innerW, sty, repoLabel)...) + lines = append(lines, homeRenderCISection(v.ciItems, innerW, sty, repoLabel)...) + return lines +} + +// buildGHErrorLines renders the error state when the GitHub fetch failed. +func (v *HomeView) buildGHErrorLines(sty homeTableSty, innerW int) []string { + var reason, hint string + switch v.prStatusCode { + case http.StatusForbidden: + reason = " rate limited (60 req/hr unauthenticated)" + hint = homeGHTokenHint + case http.StatusNotFound: + reason = " repo not found or private" + hint = homeGHTokenHint + case http.StatusUnauthorized: + reason = " bad credentials" + hint = " check your GITHUB_TOKEN value" + case 0: + reason = " network error \u2014 no response received" + default: + reason = fmt.Sprintf(" HTTP %d from GitHub API", v.prStatusCode) + hint = homeGHTokenHint + } + out := []string{ + sty.title.Render("arc-platform"), + sty.border.Render(strings.Repeat("\u2500", innerW)), + "", + sty.muted.Render(reason), + } + if hint != "" { + out = append(out, sty.muted.Render(hint)) + } + return out +} + +// --------------------------------------------------------------------------- +// Interface compliance +// --------------------------------------------------------------------------- + +func (v *HomeView) Name() string { return "Home" } + +func (v *HomeView) Keybindings() []engine.KeyBinding { + return []engine.KeyBinding{ + {Key: "↑/k", Desc: "up"}, + {Key: "↓/j", Desc: "down"}, + {Key: "h/l", Desc: "switch panel"}, + {Key: "[/]", Desc: "scroll"}, + {Key: "enter", Desc: "open"}, + {Key: "o", Desc: "open in browser"}, + } +} diff --git a/pkg/ui/view/init_wizard.go b/pkg/ui/view/init_wizard.go new file mode 100644 index 0000000..201065a --- /dev/null +++ b/pkg/ui/view/init_wizard.go @@ -0,0 +1,245 @@ +package view + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" + "github.com/spf13/afero" + + "github.com/arc-framework/arc-cli/pkg/ui/component" + "github.com/arc-framework/arc-cli/pkg/ui/engine" + "github.com/arc-framework/arc-cli/pkg/workspace" + "github.com/arc-framework/arc-cli/pkg/workspace/store/local" +) + +// initWizardPhase tracks the current stage of the init wizard. +type initWizardPhase int + +const ( + initPhaseForm initWizardPhase = iota // huh form is active + initPhaseRunning // initialization in progress + initPhaseDone // initialization succeeded + initPhaseError // initialization failed +) + +// initResultMsg is the internal async message for init completion. +type initResultMsg struct { + err error + path string +} + +// InitWizard is a form-based view that guides the user through initializing a +// new A.R.C. workspace. It collects the installation path and tier, then runs +// workspace.Initializer asynchronously. +type InitWizard struct { + ctx engine.ViewContext + phase initWizardPhase + ready bool + + // form fields + installPath string + selectedTier string + form *huh.Form + + // runtime state + spinner component.Spinner + result initResultMsg +} + +// NewInitWizard creates the InitWizard view. +func NewInitWizard() *InitWizard { + return &InitWizard{ + installPath: ".", + selectedTier: "ultra-instinct", + } +} + +func (v *InitWizard) Init() tea.Cmd { return nil } + +func (v *InitWizard) OnEnter(ctx engine.ViewContext) tea.Cmd { + v.ctx = ctx + v.phase = initPhaseForm + v.result = initResultMsg{} + v.spinner = component.NewSpinner(ctx.Theme) + v.buildForm() + v.ready = true + return v.form.Init() +} + +func (v *InitWizard) OnExit() tea.Cmd { return nil } + +func (v *InitWizard) buildForm() { + v.form = huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Installation Path"). + Description("Directory where A.R.C. workspace will be created."). + Placeholder("."). + Value(&v.installPath), + + huh.NewSelect[string](). + Title("Power Tier"). + Description("Select your workspace tier."). + Options( + huh.NewOption("Free Tier — core platform", "free"), + huh.NewOption("Super Saiyan — enhanced observability", "super-saiyan"), + huh.NewOption("Super Saiyan Blue — full security stack", "super-saiyan-blue"), + huh.NewOption("Ultra Instinct — enterprise multi-tenant", "ultra-instinct"), + ). + Value(&v.selectedTier), + ), + ).WithTheme(huh.ThemeCharm()) +} + +func (v *InitWizard) Update(msg tea.Msg) (engine.View, tea.Cmd) { + if !v.ready { + return v, nil + } + + switch v.phase { + case initPhaseForm: + m, cmd := v.form.Update(msg) + if f, ok := m.(*huh.Form); ok { + v.form = f + } + if v.form.State == huh.StateCompleted { + v.phase = initPhaseRunning + return v, tea.Batch( + v.spinner.Tick(), + v.runInitCmd(), + ) + } + return v, cmd + + case initPhaseRunning: + switch msg := msg.(type) { + case initResultMsg: + v.result = msg + if msg.err != nil { + v.phase = initPhaseError + } else { + v.phase = initPhaseDone + } + return v, nil + + case spinner.TickMsg: + sp, cmd := v.spinner.Update(msg) + v.spinner = sp + return v, cmd + } + + case initPhaseDone, initPhaseError: + if keyMsg, ok := msg.(tea.KeyMsg); ok { + if keyMsg.String() == "q" || keyMsg.String() == keyEnter { + return v, tea.Quit + } + } + } + + return v, nil +} + +// runInitCmd runs workspace initialization asynchronously. +func (v *InitWizard) runInitCmd() tea.Cmd { + path := v.installPath + tier := v.selectedTier + + return func() tea.Msg { + absPath, err := filepath.Abs(path) + if err != nil { + return initResultMsg{err: fmt.Errorf("invalid path: %w", err), path: path} + } + + if mkErr := os.MkdirAll(absPath, 0o755); mkErr != nil { + return initResultMsg{err: fmt.Errorf("failed to create directory: %w", mkErr), path: absPath} + } + + fs := afero.NewOsFs() + stateDir := filepath.Join(absPath, ".arc", "state") + stateRepo := local.NewStateRepository(fs, stateDir) + initializer := workspace.NewInitializer(fs, stateRepo) + + err = initializer.Initialize(workspace.InitializeOptions{ + Path: absPath, + Tier: tier, + Force: false, + }) + return initResultMsg{err: err, path: absPath} + } +} + +func (v *InitWizard) View() string { + if !v.ready { + return "" + } + + tc := v.ctx.Theme + + var primaryColor string + if tc != nil { + primaryColor = tc.Theme().Colors.Primary + } + + primary := lipgloss.NewStyle().Foreground(lipgloss.Color(primaryColor)) + muted := lipgloss.NewStyle().Foreground(lipgloss.Color("#8D99AE")) + + switch v.phase { + case initPhaseForm: + title := primary.Bold(true).Render("Initialize A.R.C. Workspace") + hint := muted.Render("Fill in the details below and press Enter to confirm.") + return lipgloss.JoinVertical(lipgloss.Left, + title, + hint, + "", + v.form.View(), + ) + + case initPhaseRunning: + return lipgloss.JoinVertical(lipgloss.Left, + primary.Bold(true).Render("Initializing workspace…"), + "", + v.spinner.View()+" Creating workspace structure…", + ) + + case initPhaseDone: + successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#06FFA5")).Bold(true) + body := fmt.Sprintf(" Workspace created at:\n %s\n\n Next steps:\n • Edit arc.yaml to configure services\n • Run 'arc workspace run' to launch the platform", + v.result.path, + ) + card := component.Card(tc, "Workspace Initialized", body) + return lipgloss.JoinVertical(lipgloss.Left, + successStyle.Render("✓ Workspace initialized successfully!"), + "", + card, + "", + muted.Render("Press Enter or 'q' to exit."), + ) + + case initPhaseError: + errDisplay := component.ErrorDisplay(tc, "Initialization Failed", v.result.err, "", component.SeverityError) + return lipgloss.JoinVertical(lipgloss.Left, + errDisplay, + "", + muted.Render("Press Enter or 'q' to exit."), + ) + } + + return "" +} + +func (v *InitWizard) Name() string { return "Init Wizard" } + +func (v *InitWizard) Keybindings() []engine.KeyBinding { + switch v.phase { + case initPhaseDone, initPhaseError: + return []engine.KeyBinding{ + {Key: keyEnter, Desc: "exit"}, + } + } + return nil +} diff --git a/pkg/ui/view/placeholder.go b/pkg/ui/view/placeholder.go new file mode 100644 index 0000000..29ba095 --- /dev/null +++ b/pkg/ui/view/placeholder.go @@ -0,0 +1,38 @@ +package view + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/arc-framework/arc-cli/pkg/ui/engine" +) + +// Placeholder is a minimal view for the Phase 1 milestone. +type Placeholder struct { + name string + ctx engine.ViewContext +} + +func NewPlaceholder(name string) *Placeholder { + return &Placeholder{name: name} +} + +func (p *Placeholder) Init() tea.Cmd { return nil } + +func (p *Placeholder) Update(msg tea.Msg) (engine.View, tea.Cmd) { + return p, nil +} + +func (p *Placeholder) View() string { + return lipgloss.NewStyle().Padding(2, 4).Italic(true).Faint(true). + Render("[ " + p.name + " - coming soon ]") +} + +func (p *Placeholder) OnEnter(ctx engine.ViewContext) tea.Cmd { + p.ctx = ctx + return nil +} + +func (p *Placeholder) OnExit() tea.Cmd { return nil } +func (p *Placeholder) Name() string { return p.name } +func (p *Placeholder) Keybindings() []engine.KeyBinding { return nil } diff --git a/pkg/ui/view/service_detail.go b/pkg/ui/view/service_detail.go new file mode 100644 index 0000000..3bcd64c --- /dev/null +++ b/pkg/ui/view/service_detail.go @@ -0,0 +1,148 @@ +package view + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/arc-framework/arc-cli/pkg/catalog" + "github.com/arc-framework/arc-cli/pkg/ui/component" + "github.com/arc-framework/arc-cli/pkg/ui/engine" +) + +// ServiceDetail renders a full-detail card for a single service. +// The service codename is delivered via ViewContext.Args["service"]. +type ServiceDetail struct { + ctx engine.ViewContext + service *catalog.Service + err error + ready bool +} + +// NewServiceDetail creates the ServiceDetail view. +func NewServiceDetail() *ServiceDetail { + return &ServiceDetail{} +} + +func (v *ServiceDetail) Init() tea.Cmd { return nil } + +func (v *ServiceDetail) OnEnter(ctx engine.ViewContext) tea.Cmd { + v.ctx = ctx + v.err = nil + v.service = nil + + codename := ctx.Args["service"] + if codename == "" { + v.err = fmt.Errorf("no service codename provided") + v.ready = true + return nil + } + + if ctx.Backend.Catalog == nil { + v.err = fmt.Errorf("catalog not available") + v.ready = true + return nil + } + + svc, err := ctx.Backend.Catalog.GetService(codename) + if err != nil { + v.err = err + } else { + v.service = svc + } + + v.ready = true + return nil +} + +func (v *ServiceDetail) OnExit() tea.Cmd { return nil } + +func (v *ServiceDetail) Update(msg tea.Msg) (engine.View, tea.Cmd) { + if !v.ready { + return v, nil + } + switch msg := msg.(type) { //nolint:gocritic // intentional: type switch for future message types + case tea.KeyMsg: + switch msg.String() { + case "backspace", keyEsc: + return v, func() tea.Msg { return engine.BackMsg{} } + } + } + return v, nil +} + +func (v *ServiceDetail) View() string { + if !v.ready { + return "" + } + tc := v.ctx.Theme + + if v.err != nil { + return component.ErrorDisplay(tc, "Service Error", v.err, "", component.SeverityError) + } + if v.service == nil { + return component.ErrorDisplay(tc, "Not Found", nil, "Service not found in catalog.", component.SeverityWarning) + } + + svc := v.service + + // ── Info card ───────────────────────────────────────────────────────────── + var sb strings.Builder + fmt.Fprintf(&sb, " Codename %s\n", svc.Codename) + fmt.Fprintf(&sb, " Technology %s\n", svc.Technology) + fmt.Fprintf(&sb, " Role %s\n", svc.Role) + fmt.Fprintf(&sb, " Version %s\n", svc.Version) + fmt.Fprintf(&sb, " Image %s\n", svc.Image) + if len(svc.Ports) > 0 { + var ports []string + for _, p := range svc.Ports { + ports = append(ports, fmt.Sprintf("%s:%d→%d", p.Protocol, p.Host, p.Container)) + } + fmt.Fprintf(&sb, " Ports %s\n", strings.Join(ports, ", ")) + } + if svc.Description != "" { + fmt.Fprintf(&sb, "\n %s\n", svc.Description) + } + infoCard := component.Card(tc, svc.Codename, sb.String()) + + // ── Dependency tree ─────────────────────────────────────────────────────── + depSection := "" + if len(svc.Dependencies) > 0 { + children := make([]component.TreeNode, len(svc.Dependencies)) + for i, dep := range svc.Dependencies { + children[i] = component.TreeNode{Label: dep, Status: "ok"} + } + root := component.TreeNode{ + Label: svc.Codename + " (dependencies)", + Children: children, + } + treeStr := component.Tree(tc, root) + depSection = component.Card(tc, "Dependencies", treeStr) + } + + // ── Control hint ────────────────────────────────────────────────────────── + var muted string + if tc != nil { + muted = tc.Theme().Colors.Muted + } + hint := lipgloss.NewStyle().Foreground(lipgloss.Color(muted)).Render(" [esc] back") + + if depSection != "" { + return lipgloss.JoinVertical(lipgloss.Left, hint, "", infoCard, "", depSection) + } + return lipgloss.JoinVertical(lipgloss.Left, hint, "", infoCard) +} + +func (v *ServiceDetail) Name() string { return "ServiceDetail" } + +// NavHidden marks ServiceDetail as a detail view — it is navigated to via +// NavigateMsg from ServicesList and should not appear in the top-level nav bar. +func (v *ServiceDetail) NavHidden() bool { return true } + +func (v *ServiceDetail) Keybindings() []engine.KeyBinding { + return []engine.KeyBinding{ + {Key: keyEsc, Desc: "back"}, + } +} diff --git a/pkg/ui/view/services_list.go b/pkg/ui/view/services_list.go new file mode 100644 index 0000000..782f092 --- /dev/null +++ b/pkg/ui/view/services_list.go @@ -0,0 +1,228 @@ +package view + +import ( + "strings" + + bubblestable "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/arc-framework/arc-cli/pkg/catalog" + "github.com/arc-framework/arc-cli/pkg/ui/component" + "github.com/arc-framework/arc-cli/pkg/ui/engine" +) + +// ServicesList is the services dashboard view. +// It shows a searchable table of all registered services from the catalog. +type ServicesList struct { + ctx engine.ViewContext + services []*catalog.Service + allRows []bubblestable.Row + table component.Table + search component.Search + err error + + searching bool + ready bool +} + +// NewServicesList creates the ServicesList view. +func NewServicesList() *ServicesList { + return &ServicesList{} +} + +func (v *ServicesList) Init() tea.Cmd { return nil } + +func (v *ServicesList) OnEnter(ctx engine.ViewContext) tea.Cmd { + v.ctx = ctx + v.err = nil + + // Load from catalog if available. + if ctx.Backend.Catalog != nil { + svcs, err := ctx.Backend.Catalog.ListServices(catalog.FilterAll) + if err != nil { + v.err = err + } else { + v.services = svcs + } + } + + v.allRows = buildRows(v.services) + + w := ctx.Width + if w <= 0 { + w = 120 + } + cols := tableColumns(w) + // ctx.Height is already the content area (shell chrome already subtracted). + // Subtract 2 for the hint line rendered below the table + one slack row. + tableH := ctx.Height - 2 + if tableH < 5 { + tableH = 5 + } + v.table = component.NewTable(ctx.Theme, cols, v.allRows, w, tableH) + v.search = component.NewSearch(ctx.Theme, "Filter services…") + v.searching = false + v.ready = true + return nil +} + +func (v *ServicesList) OnExit() tea.Cmd { return nil } + +func (v *ServicesList) Update(msg tea.Msg) (engine.View, tea.Cmd) { + if !v.ready { + return v, nil + } + + switch msg := msg.(type) { //nolint:gocritic // intentional: type switch for future message types + case tea.KeyMsg: + switch msg.String() { + case "/": + if !v.searching { + v.searching = true + cmd := v.search.Focus() + return v, cmd + } + + case "esc": + if v.searching { + v.searching = false + v.search.Blur() + // Reset filter. + v.table.SetRows(v.allRows) + return v, nil + } + + case "enter": + if v.searching { + // Commit search filter. + v.searching = false + v.search.Blur() + return v, nil + } + // Navigate to service detail. + row := v.table.SelectedRow() + if len(row) > 0 { + codename := row[0] + return v, func() tea.Msg { + return engine.NavigateMsg{ + ViewName: "ServiceDetail", + Args: map[string]string{"service": codename}, + } + } + } + } + } + + // Delegate to search or table depending on mode. + if v.searching { + var cmd tea.Cmd + v.search, cmd = v.search.Update(msg) + // Re-filter table. + q := strings.ToLower(v.search.Value()) + filtered := filterRows(v.allRows, q) + v.table.SetRows(filtered) + return v, cmd + } + + var cmd tea.Cmd + v.table, cmd = v.table.Update(msg) + return v, cmd +} + +func (v *ServicesList) View() string { + if !v.ready { + return "" + } + + tc := v.ctx.Theme + + // Error state. + if v.err != nil { + return component.ErrorDisplay(tc, "Catalog Error", v.err, "", component.SeverityError) + } + + // Empty state. + if len(v.services) == 0 { + msg := "No services registered. Run `arc init` to configure a workspace." + return component.ErrorDisplay(tc, "No Services", nil, msg, component.SeverityInfo) + } + + var muted string + if tc != nil { + muted = tc.Theme().Colors.Muted + } + hint := lipgloss.NewStyle().Foreground(lipgloss.Color(muted)).Render( + " [/] search [enter] details [esc] cancel search", + ) + + searchBar := "" + if v.searching { + searchBar = "\n" + v.search.View() + "\n" + } + + return lipgloss.JoinVertical( + lipgloss.Left, + hint, + searchBar, + v.table.View(), + ) +} + +func (v *ServicesList) Name() string { return "Services" } + +func (v *ServicesList) Keybindings() []engine.KeyBinding { + return []engine.KeyBinding{ + {Key: "/", Desc: "search"}, + {Key: "enter", Desc: "details"}, + {Key: "esc", Desc: "cancel"}, + } +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func tableColumns(width int) []bubblestable.Column { + // Distribute widths: codename 20%, technology 20%, role 15%, desc remaining. + codeW := width * 20 / 100 + techW := width * 20 / 100 + roleW := width * 15 / 100 + descW := width - codeW - techW - roleW - 6 // 6 for padding + if descW < 10 { + descW = 10 + } + return []bubblestable.Column{ + {Title: "Codename", Width: codeW}, + {Title: "Technology", Width: techW}, + {Title: "Role", Width: roleW}, + {Title: "Description", Width: descW}, + } +} + +func buildRows(services []*catalog.Service) []bubblestable.Row { + rows := make([]bubblestable.Row, 0, len(services)) + for _, svc := range services { + rows = append(rows, bubblestable.Row{ + svc.Codename, + string(svc.Technology), + string(svc.Role), + svc.Description, + }) + } + return rows +} + +func filterRows(rows []bubblestable.Row, query string) []bubblestable.Row { + if query == "" { + return rows + } + var out []bubblestable.Row + for _, r := range rows { + for _, cell := range r { + if strings.Contains(strings.ToLower(cell), query) { + out = append(out, r) + break + } + } + } + return out +} diff --git a/pkg/ui/view/version.go b/pkg/ui/view/version.go new file mode 100644 index 0000000..0a9adf2 --- /dev/null +++ b/pkg/ui/view/version.go @@ -0,0 +1,76 @@ +package view + +import ( + "fmt" + "runtime" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/arc-framework/arc-cli/pkg/ui/component" + "github.com/arc-framework/arc-cli/pkg/ui/engine" + "github.com/arc-framework/arc-cli/pkg/version" +) + +// VersionView renders build metadata: version, commit, build date, and Go runtime. +type VersionView struct { + ctx engine.ViewContext + ready bool +} + +// NewVersionView creates the VersionView. +func NewVersionView() *VersionView { + return &VersionView{} +} + +func (v *VersionView) Init() tea.Cmd { return nil } + +func (v *VersionView) OnEnter(ctx engine.ViewContext) tea.Cmd { + v.ctx = ctx + v.ready = true + return nil +} + +func (v *VersionView) OnExit() tea.Cmd { return nil } + +func (v *VersionView) Update(msg tea.Msg) (engine.View, tea.Cmd) { + return v, nil +} + +func (v *VersionView) View() string { + if !v.ready { + return "" + } + + tc := v.ctx.Theme + + ver := version.Version + if ver == "" { + ver = "dev" + } + commit := version.Commit + if commit == "" || commit == versionUnknown { + commit = emDash + } + buildDate := version.BuildDate + if buildDate == "" || buildDate == versionUnknown { + buildDate = emDash + } + + body := fmt.Sprintf( + " Version %s\n Commit %s\n Build Date %s\n Go Runtime %s\n Platform %s/%s", + ver, + commit, + buildDate, + runtime.Version(), + runtime.GOOS, + runtime.GOARCH, + ) + + return component.Card(tc, "A.R.C. CLI", body) +} + +func (v *VersionView) Name() string { return "Version" } + +func (v *VersionView) Keybindings() []engine.KeyBinding { + return nil +} diff --git a/pkg/ui/view/workspace_history.go b/pkg/ui/view/workspace_history.go new file mode 100644 index 0000000..3842598 --- /dev/null +++ b/pkg/ui/view/workspace_history.go @@ -0,0 +1,166 @@ +package view + +import ( + "fmt" + "path/filepath" + + bubblestable "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/afero" + + "github.com/arc-framework/arc-cli/internal/state" + "github.com/arc-framework/arc-cli/pkg/ui/component" + "github.com/arc-framework/arc-cli/pkg/ui/engine" + "github.com/arc-framework/arc-cli/pkg/workspace" + "github.com/arc-framework/arc-cli/pkg/workspace/store/local" +) + +// WorkspaceHistory renders the operation history table for the current workspace. +type WorkspaceHistory struct { + ctx engine.ViewContext + ops []*state.Operation + table component.Table + err error + ready bool +} + +// NewWorkspaceHistory creates the WorkspaceHistory view. +func NewWorkspaceHistory() *WorkspaceHistory { + return &WorkspaceHistory{} +} + +func (v *WorkspaceHistory) Init() tea.Cmd { return nil } + +func (v *WorkspaceHistory) OnEnter(ctx engine.ViewContext) tea.Cmd { + v.ctx = ctx + v.err = nil + v.ops = nil + + fs := afero.NewOsFs() + detector := workspace.NewDetector(fs) + wsRoot, err := detector.DetectRoot(".") + if err != nil { + v.err = fmt.Errorf("not in an A.R.C. workspace: %w", err) + v.ready = true + return nil + } + + stateDir := filepath.Join(wsRoot, ".arc", "state") + stateRepo := local.NewStateRepository(fs, stateDir) + manifestRepo := local.NewManifestRepository(fs) + + mgr, mgrErr := workspace.NewManager(&workspace.ManagerOptions{ + Filesystem: fs, + StateRepo: stateRepo, + ManifestRepo: manifestRepo, + }) + if mgrErr != nil { + v.err = fmt.Errorf("failed to create workspace manager: %w", mgrErr) + v.ready = true + return nil + } + + info, infoErr := mgr.Info(wsRoot) + if infoErr != nil { + v.err = fmt.Errorf("failed to read workspace info: %w", infoErr) + v.ready = true + return nil + } + + v.ops = info.OperationHistory + + w := ctx.Width + if w <= 0 { + w = 120 + } + // ctx.Height is already the content area (shell chrome already subtracted). + tableH := ctx.Height - 1 + if tableH < 5 { + tableH = 5 + } + + cols := historyColumns(w) + rows := buildHistoryRows(v.ops) + v.table = component.NewTable(ctx.Theme, cols, rows, w, tableH) + v.ready = true + return nil +} + +func (v *WorkspaceHistory) OnExit() tea.Cmd { return nil } + +func (v *WorkspaceHistory) Update(msg tea.Msg) (engine.View, tea.Cmd) { + if !v.ready { + return v, nil + } + var cmd tea.Cmd + v.table, cmd = v.table.Update(msg) + return v, cmd +} + +func (v *WorkspaceHistory) View() string { + if !v.ready { + return "" + } + + tc := v.ctx.Theme + + if v.err != nil { + return component.ErrorDisplay(tc, "History Error", v.err, "", component.SeverityWarning) + } + + if len(v.ops) == 0 { + return component.ErrorDisplay(tc, "No History", nil, "No operations recorded yet. Run `arc workspace init` to get started.", component.SeverityInfo) + } + + return v.table.View() +} + +func (v *WorkspaceHistory) Name() string { return "History" } + +func (v *WorkspaceHistory) Keybindings() []engine.KeyBinding { + return []engine.KeyBinding{ + {Key: "j/k", Desc: "scroll"}, + } +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func historyColumns(width int) []bubblestable.Column { + typeW := width * 16 / 100 + statusW := width * 14 / 100 + durationW := width * 14 / 100 + tsW := width * 22 / 100 + errW := width - typeW - statusW - durationW - tsW - 6 + if errW < 10 { + errW = 10 + } + return []bubblestable.Column{ + {Title: "Type", Width: typeW}, + {Title: "Status", Width: statusW}, + {Title: "Duration", Width: durationW}, + {Title: "Timestamp", Width: tsW}, + {Title: "Errors", Width: errW}, + } +} + +func buildHistoryRows(ops []*state.Operation) []bubblestable.Row { + rows := make([]bubblestable.Row, 0, len(ops)) + for _, op := range ops { + errStr := emDash + if len(op.Errors) > 0 { + errStr = op.Errors[0] + if len(op.Errors) > 1 { + errStr += fmt.Sprintf(" (+%d)", len(op.Errors)-1) + } + } + duration := fmt.Sprintf("%dms", op.DurationMS) + rows = append(rows, bubblestable.Row{ + string(op.OperationType), + string(op.Status), + duration, + op.Timestamp.Format("2006-01-02 15:04:05"), + errStr, + }) + } + return rows +} diff --git a/pkg/ui/view/workspace_info.go b/pkg/ui/view/workspace_info.go new file mode 100644 index 0000000..aa6c7d0 --- /dev/null +++ b/pkg/ui/view/workspace_info.go @@ -0,0 +1,162 @@ +package view + +import ( + "fmt" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/spf13/afero" + + "github.com/arc-framework/arc-cli/pkg/ui/component" + "github.com/arc-framework/arc-cli/pkg/ui/engine" + "github.com/arc-framework/arc-cli/pkg/workspace" + "github.com/arc-framework/arc-cli/pkg/workspace/store/local" +) + +const ( + wsStatusOK = "✓" + wsStatusFail = "✗" +) + +// WorkspaceInfo renders details about the current workspace. +// It detects the nearest workspace root from the working directory. +type WorkspaceInfo struct { + ctx engine.ViewContext + info *workspace.WorkspaceInfo + err error + ready bool +} + +// NewWorkspaceInfo creates the WorkspaceInfo view. +func NewWorkspaceInfo() *WorkspaceInfo { + return &WorkspaceInfo{} +} + +func (v *WorkspaceInfo) Init() tea.Cmd { return nil } + +func (v *WorkspaceInfo) OnEnter(ctx engine.ViewContext) tea.Cmd { + v.ctx = ctx + v.err = nil + v.info = nil + + fs := afero.NewOsFs() + detector := workspace.NewDetector(fs) + wsRoot, err := detector.DetectRoot(".") + if err != nil { + v.err = fmt.Errorf("not in an A.R.C. workspace: %w", err) + v.ready = true + return nil + } + + stateDir := filepath.Join(wsRoot, ".arc", "state") + stateRepo := local.NewStateRepository(fs, stateDir) + manifestRepo := local.NewManifestRepository(fs) + + mgr, mgrErr := workspace.NewManager(&workspace.ManagerOptions{ + Filesystem: fs, + StateRepo: stateRepo, + ManifestRepo: manifestRepo, + }) + if mgrErr != nil { + v.err = fmt.Errorf("failed to create workspace manager: %w", mgrErr) + v.ready = true + return nil + } + + info, infoErr := mgr.Info(wsRoot) + if infoErr != nil { + v.err = fmt.Errorf("failed to read workspace info: %w", infoErr) + v.ready = true + return nil + } + + v.info = info + v.ready = true + return nil +} + +func (v *WorkspaceInfo) OnExit() tea.Cmd { return nil } + +func (v *WorkspaceInfo) Update(msg tea.Msg) (engine.View, tea.Cmd) { + return v, nil +} + +func (v *WorkspaceInfo) View() string { + if !v.ready { + return "" + } + + tc := v.ctx.Theme + + if v.err != nil { + return component.ErrorDisplay(tc, "Workspace Error", v.err, "", component.SeverityWarning) + } + + info := v.info + + // ── Workspace card ──────────────────────────────────────────────────────── + features := emDash + if len(info.EnabledFeatures) > 0 { + features = strings.Join(info.EnabledFeatures, ", ") + } + wsBody := fmt.Sprintf( + " Root %s\n Manifest %s\n Tier %s\n Version %s\n Features %s", + info.WorkspaceRoot, + info.ManifestPath, + info.Tier, + info.ManifestVersion, + features, + ) + wsCard := component.Card(tc, "Workspace", wsBody) + + // ── State card ──────────────────────────────────────────────────────────── + stateBody := " No state data available." + if info.CurrentState != nil { + s := info.CurrentState + lastGen := emDash + if s.LastGeneration != nil { + status := wsStatusOK + if !s.LastGeneration.Success { + status = wsStatusFail + } + lastGen = fmt.Sprintf("%s %s (%d files)", + status, + s.LastGeneration.Timestamp.Format("2006-01-02 15:04"), + len(s.LastGeneration.GeneratedFiles), + ) + } + stateBody = fmt.Sprintf( + " Initialized %s\n Updated %s\n Last Generate %s", + s.InitTimestamp.Format("2006-01-02 15:04"), + s.UpdatedAt.Format("2006-01-02 15:04"), + lastGen, + ) + } + stateCard := component.Card(tc, "State", stateBody) + + // ── Hint ────────────────────────────────────────────────────────────────── + var muted string + if tc != nil { + muted = tc.Theme().Colors.Muted + } + hint := lipgloss.NewStyle().Foreground(lipgloss.Color(muted)).Render( + " Use `arc workspace history` for full operation history", + ) + + return lipgloss.JoinVertical( + lipgloss.Left, + hint, + "", + lipgloss.JoinHorizontal(lipgloss.Top, wsCard, " ", stateCard), + ) +} + +func (v *WorkspaceInfo) Name() string { return "Workspace" } + +func (v *WorkspaceInfo) Keybindings() []engine.KeyBinding { + return []engine.KeyBinding{ + {Key: "r", Desc: "refresh"}, + } +} diff --git a/pkg/ui/view/workspace_run.go b/pkg/ui/view/workspace_run.go new file mode 100644 index 0000000..2d72e70 --- /dev/null +++ b/pkg/ui/view/workspace_run.go @@ -0,0 +1,294 @@ +package view + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/spf13/afero" + + "github.com/arc-framework/arc-cli/pkg/ui/component" + "github.com/arc-framework/arc-cli/pkg/ui/engine" + "github.com/arc-framework/arc-cli/pkg/workspace" +) + +// workspaceRunPhase tracks the current stage of the workspace run view. +type workspaceRunPhase int + +const ( + wsRunPhaseDetecting workspaceRunPhase = iota // detecting workspace + wsRunPhaseReady // workspace found, ready to run + wsRunPhaseRunning // run in progress + wsRunPhaseDone // run completed + wsRunPhaseError // an error occurred +) + +// wsRunResultMsg is the internal async message for run completion. +type wsRunResultMsg struct { + err error + output string +} + +// wsDetectResultMsg carries workspace detection results. +type wsDetectResultMsg struct { + root string + name string + err error +} + +// WorkspaceRun is a focused view for running an A.R.C. workspace. +// It detects the workspace, shows info, runs the platform, and displays logs. +type WorkspaceRun struct { + ctx engine.ViewContext + phase workspaceRunPhase + ready bool + + // workspace info + workspaceRoot string + workspaceName string + wsErr error + + // run state + spinner component.Spinner + progress component.Progress + progressVal float64 + logs []string + runErr error + runOutput string + + // options (injected by command layer) + Detached bool + GenerateOnly bool + NoValidate bool +} + +// NewWorkspaceRun creates the WorkspaceRun view. +func NewWorkspaceRun() *WorkspaceRun { + return &WorkspaceRun{} +} + +func (v *WorkspaceRun) Init() tea.Cmd { return nil } + +func (v *WorkspaceRun) OnEnter(ctx engine.ViewContext) tea.Cmd { + v.ctx = ctx + v.phase = wsRunPhaseDetecting + v.logs = nil + v.runErr = nil + v.spinner = component.NewSpinner(ctx.Theme) + v.progress = component.NewProgress(ctx.Theme) + v.progressVal = 0.0 + v.ready = true + + return tea.Batch( + v.spinner.Tick(), + v.detectWorkspaceCmd(), + ) +} + +func (v *WorkspaceRun) OnExit() tea.Cmd { return nil } + +// detectWorkspaceCmd locates the nearest A.R.C. workspace root. +func (v *WorkspaceRun) detectWorkspaceCmd() tea.Cmd { + return func() tea.Msg { + fs := afero.NewOsFs() + detector := workspace.NewDetector(fs) + root, err := detector.DetectRoot(".") + if err != nil { + return wsDetectResultMsg{err: fmt.Errorf("not in an A.R.C. workspace: %w", err)} + } + return wsDetectResultMsg{root: root, name: filepath.Base(root)} + } +} + +// runWorkspaceCmd executes the workspace run steps asynchronously via subprocess. +// It delegates to the underlying arc CLI with legacy UI flag to avoid recursion. +func (v *WorkspaceRun) runWorkspaceCmd() tea.Cmd { + return func() tea.Msg { + arcBin, err := os.Executable() + if err != nil { + return wsRunResultMsg{err: fmt.Errorf("cannot resolve arc binary: %w", err)} + } + + args := []string{"workspace", "run"} + if v.GenerateOnly { + args = append(args, "--generate-only") + } + if v.NoValidate { + args = append(args, "--no-validate") + } + if v.Detached { + args = append(args, "--detached") + } + + cmd := exec.Command(arcBin, args...) + cmd.Env = append(os.Environ(), "ARC_NO_TUI=1") + out, runErr := cmd.CombinedOutput() + + return wsRunResultMsg{ + err: runErr, + output: strings.TrimSpace(string(out)), + } + } +} + +func (v *WorkspaceRun) Update(msg tea.Msg) (engine.View, tea.Cmd) { + if !v.ready { + return v, nil + } + + switch msg := msg.(type) { + case wsDetectResultMsg: + if msg.err != nil { + v.wsErr = msg.err + v.phase = wsRunPhaseError + } else { + v.workspaceRoot = msg.root + v.workspaceName = msg.name + v.phase = wsRunPhaseReady + } + return v, nil + + case wsRunResultMsg: + v.runErr = msg.err + v.runOutput = msg.output + if msg.err != nil { + v.phase = wsRunPhaseError + } else { + v.phase = wsRunPhaseDone + } + return v, nil + + case spinner.TickMsg: + sp, cmd := v.spinner.Update(msg) + v.spinner = sp + if v.phase == wsRunPhaseDetecting || v.phase == wsRunPhaseRunning { + return v, cmd + } + return v, nil + + case tea.KeyMsg: + switch msg.String() { + case "r": + if v.phase == wsRunPhaseReady { + v.phase = wsRunPhaseRunning + v.logs = nil + return v, tea.Batch( + v.spinner.Tick(), + v.runWorkspaceCmd(), + ) + } + case "q", keyEnter: + if v.phase == wsRunPhaseDone || v.phase == wsRunPhaseError { + return v, tea.Quit + } + } + } + + return v, nil +} + +func (v *WorkspaceRun) View() string { + if !v.ready { + return "" + } + + tc := v.ctx.Theme + + var primaryColor, mutedColor, successColor string + if tc != nil { + primaryColor = tc.Theme().Colors.Primary + mutedColor = tc.Theme().Colors.Muted + successColor = tc.Theme().Colors.Success + } + + primary := lipgloss.NewStyle().Foreground(lipgloss.Color(primaryColor)) + muted := lipgloss.NewStyle().Foreground(lipgloss.Color(mutedColor)) + success := lipgloss.NewStyle().Foreground(lipgloss.Color(successColor)).Bold(true) + + switch v.phase { + case wsRunPhaseDetecting: + return lipgloss.JoinVertical(lipgloss.Left, + primary.Bold(true).Render("Workspace Run"), + "", + v.spinner.View()+" Detecting workspace…", + ) + + case wsRunPhaseReady: + infoBody := fmt.Sprintf(" Root %s\n Name %s", v.workspaceRoot, v.workspaceName) + if v.GenerateOnly { + infoBody += "\n Mode generate only (Docker will not be launched)" + } + card := component.Card(tc, "Detected Workspace", infoBody) + + return lipgloss.JoinVertical(lipgloss.Left, + primary.Bold(true).Render("Workspace Run"), + "", + card, + "", + muted.Render("Press 'r' to run • 'q' to cancel"), + ) + + case wsRunPhaseRunning: + lines := []string{ + primary.Bold(true).Render("Running workspace…"), + "", + v.spinner.View() + " Executing platform orchestration…", + } + if len(v.logs) > 0 { + lines = append(lines, "") + for _, l := range v.logs { + lines = append(lines, " "+muted.Render(l)) + } + } + return strings.Join(lines, "\n") + + case wsRunPhaseDone: + lines := []string{ + success.Render("✓ Workspace platform started successfully!"), + "", + } + if v.runOutput != "" { + outputCard := component.Card(tc, "Output", " "+v.runOutput) + lines = append(lines, outputCard, "") + } + lines = append(lines, muted.Render("Press Enter or 'q' to exit.")) + return strings.Join(lines, "\n") + + case wsRunPhaseError: + var err error + if v.wsErr != nil { + err = v.wsErr + } else { + err = v.runErr + } + errDisplay := component.ErrorDisplay(tc, "Workspace Run Failed", err, "", component.SeverityError) + return lipgloss.JoinVertical(lipgloss.Left, + errDisplay, + "", + muted.Render("Press Enter or 'q' to exit."), + ) + } + + return "" +} + +func (v *WorkspaceRun) Name() string { return "Workspace Run" } + +func (v *WorkspaceRun) Keybindings() []engine.KeyBinding { + switch v.phase { + case wsRunPhaseReady: + return []engine.KeyBinding{ + {Key: "r", Desc: "run"}, + } + case wsRunPhaseDone, wsRunPhaseError: + return []engine.KeyBinding{ + {Key: keyEnter, Desc: "exit"}, + } + } + return nil +} diff --git a/pkg/ui/views/.gitkeep b/pkg/ui/views/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/pkg/ui/views/README.md b/pkg/ui/views/README.md deleted file mode 100644 index 9b7b9d3..0000000 --- a/pkg/ui/views/README.md +++ /dev/null @@ -1,371 +0,0 @@ -# View Implementation Guide - -This package contains all full-screen views for the ARC CLI TUI. Views are the primary -building blocks of the UI engine: each CLI command maps to one or more views that handle -rendering, keyboard input, and navigation. - -## The `engine.View` Interface - -Every view must implement the `engine.View` interface -(`pkg/ui/engine/view.go`): - -```go -type View interface { - // Bubble Tea lifecycle - Init() tea.Cmd - Update(msg tea.Msg) (tea.Model, tea.Cmd) - View() string - - // ARC engine lifecycle - OnEnter(ctx *ViewContext) tea.Cmd - OnExit() tea.Cmd - - // Metadata - Name() string - Keybindings() []KeyBinding -} -``` - -For JSON output support, also implement `engine.JSONExporter`: - -```go -type JSONExporter interface { - ToJSON() any -} -``` - ---- - -## View Lifecycle - -``` -engine.Render(RenderConfig{View: v, Mode: TUIMode}) - │ - ▼ - tea.NewProgram(view) - │ - ▼ - view.Init() ← Return initial commands (e.g., start spinner, fetch data) - │ - ▼ - router.Navigate() ← Called when navigating to the view - │ - ▼ - view.OnEnter(ctx) ← Receive ViewContext: profile, theme, width, height, args - │ ← Initialize state from ctx (e.g., load service name from ctx.Args) - ▼ - view.Update(msg) ← Handle tea.Msg (key presses, window resize, custom msgs) - │ ← Return (tea.Model, tea.Cmd); return tea.Quit to exit - ▼ - view.View() ← Return the rendered string for this frame - │ - ▼ (when router navigates away) - view.OnExit() ← Clean up resources, save scroll position, etc. -``` - -### `Init() tea.Cmd` - -Called once when the Bubble Tea program starts. Return commands for initial setup: - -```go -func (v *ServicesListView) Init() tea.Cmd { - if v.searchBar != nil { - return v.searchBar.Init() // start cursor blink - } - return nil -} -``` - -### `OnEnter(ctx *engine.ViewContext) tea.Cmd` - -Called by the Router each time this view becomes active (including the first time). Use it to -initialize or refresh view state using the provided context: - -```go -func (v *ServiceDetailView) OnEnter(ctx *engine.ViewContext) tea.Cmd { - v.width = ctx.Width - v.height = ctx.Height - v.theme = ctx.Theme - - // Read route parameters - if name, ok := ctx.Args["serviceName"].(string); ok { - v.service = v.loader.Load(name) - } - return nil -} -``` - -### `Update(msg tea.Msg) (tea.Model, tea.Cmd)` - -Standard Bubble Tea update method. Handle keyboard input, window resize, and any custom -messages. Return `tea.Quit` to exit the program: - -```go -func (v *MyView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - return v, nil - - case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c": - return v, tea.Quit - case "j", "down": - v.cursor++ - return v, nil - } - } - return v, nil -} -``` - -### `View() string` - -Returns the fully rendered string for the current frame. Use `lipgloss.JoinVertical` to stack -components and `lipgloss.Width()` (never `len()`) for width calculations: - -```go -func (v *MyView) View() string { - header := v.hero.Render(v.width) - body := v.table.View() - footer := v.statusBar.Render(v.width, v.Keybindings(), v.contextMessage()) - - return lipgloss.JoinVertical(lipgloss.Left, header, body, footer) -} -``` - -### `OnExit() tea.Cmd` - -Called when the Router navigates away from this view. Return nil if no cleanup is needed: - -```go -func (v *DashboardView) OnExit() tea.Cmd { - v.saveScrollPosition() - return nil -} -``` - -### `Name() string` - -Returns a unique, lowercase-with-hyphens identifier used by the Router: - -```go -func (v *ServiceDetailView) Name() string { return "service-detail" } -``` - -### `Keybindings() []engine.KeyBinding` - -Returns the keyboard shortcuts displayed in the StatusBar: - -```go -func (v *ServicesListView) Keybindings() []engine.KeyBinding { - return []engine.KeyBinding{ - {Key: "q", Description: "quit"}, - {Key: "↑/↓", Description: "navigate"}, - {Key: "enter", Description: "select"}, - {Key: "/", Description: "search"}, - } -} -``` - ---- - -## `ViewContext` Fields - -`engine.ViewContext` is passed to `OnEnter`. All fields are read-only from the view's -perspective — do not mutate the context. - -| Field | Type | Description | -|---|---|---| -| `Profile` | `*profiles.Profile` | Active user profile (logo, colors, tier names) | -| `Theme` | `*themes.Theme` | Active color theme matched to the profile | -| `Width` | `int` | Terminal width in columns (minimum 80) | -| `Height` | `int` | Terminal height in rows (typical range 24–60) | -| `Args` | `map[string]any` | Route-specific parameters (see below) | - -### Accessing `ctx.Args` - -Always use a type assertion with the two-value form to avoid panics on missing keys: - -```go -func (v *ServiceDetailView) OnEnter(ctx *engine.ViewContext) tea.Cmd { - if name, ok := ctx.Args["serviceName"].(string); ok { - v.loadService(name) - } - if showConfig, ok := ctx.Args["showConfig"].(bool); ok && showConfig { - v.showConfigPanel = true - } - return nil -} -``` - ---- - -## Implementing `ToJSON()` for JSON Output Mode - -Views that support `--json` output must implement `engine.JSONExporter`: - -```go -// JSONData is the serializable representation of the view's data. -type ServicesJSONData struct { - Services []ServiceRecord `json:"services"` - Count int `json:"count"` -} - -func (v *ServicesListView) ToJSON() any { - return ServicesJSONData{ - Services: v.services, - Count: len(v.services), - } -} -``` - -Then in the command: - -```go -mode := engine.RenderModeFromFlags(jsonFlag, noAnimationFlag) - -cfg := engine.RenderConfig{View: view, Mode: mode} -if mode == engine.JSONMode { - if exp, ok := view.(engine.JSONExporter); ok { - cfg.JSONData = exp.ToJSON() - cfg.JSONIndent = true - } -} -engine.Render(cfg) -``` - ---- - -## Minimal Working View Example - -The following is a complete, self-contained view implementation. Copy this as a starting -point and fill in your own logic. - -```go -package views - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components/status" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// MyView is a minimal example view. -type MyView struct { - factory ui.ComponentFactory - statusBar *status.StatusBar - theme *themes.Theme - width int - height int - message string -} - -// NewMyView creates a new MyView. -func NewMyView(factory ui.ComponentFactory) *MyView { - return &MyView{ - factory: factory, - width: 80, - height: 24, - message: "Hello from MyView", - } -} - -// Name returns the unique identifier for this view. -func (v *MyView) Name() string { return "my-view" } - -// Keybindings returns keyboard shortcuts shown in the status bar. -func (v *MyView) Keybindings() []engine.KeyBinding { - return []engine.KeyBinding{ - {Key: "q", Description: "quit"}, - } -} - -// Init is called once when the Bubble Tea program starts. -func (v *MyView) Init() tea.Cmd { return nil } - -// OnEnter is called when the Router navigates to this view. -func (v *MyView) OnEnter(ctx *engine.ViewContext) tea.Cmd { - v.width = ctx.Width - v.height = ctx.Height - v.theme = ctx.Theme - - // Initialize statusBar with the current theme - v.statusBar = status.NewStatusBar(ctx.Theme) - - // Read route arguments if any - if label, ok := ctx.Args["label"].(string); ok { - v.message = label - } - return nil -} - -// OnExit is called when the Router navigates away from this view. -func (v *MyView) OnExit() tea.Cmd { return nil } - -// Update handles messages. -func (v *MyView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - - case tea.KeyMsg: - if msg.String() == "q" || msg.String() == "ctrl+c" { - return v, tea.Quit - } - } - return v, nil -} - -// View renders the current frame. -func (v *MyView) View() string { - body := lipgloss.NewStyle(). - Width(v.width). - Align(lipgloss.Center). - Padding(2, 0). - Render(v.message) - - footer := "" - if v.statusBar != nil { - footer = v.statusBar.Render(v.width, v.Keybindings(), "") - } - - return lipgloss.JoinVertical(lipgloss.Left, body, footer) -} - -// ToJSON implements engine.JSONExporter for --json support. -func (v *MyView) ToJSON() any { - return map[string]any{"message": v.message} -} -``` - ---- - -## Existing Views Reference - -| View | File | Description | -|---|---|---| -| `DashboardView` | `dashboardview.go` | Home dashboard with sidebar navigation | -| `HomeView` | `homeview.go` | Profile branding landing screen | -| `InfoView` | `infoview.go` | Detailed profile info with hero banner | -| `ServicesListView` | `serviceslistview.go` | Searchable/sortable services table | -| `ServiceDetailView` | `servicedetailview.go` | Single-service detail panel | -| `ServiceDepsView` | `servicedepsview.go` | Service dependency tree | -| `PortsTableView` | `portstableview.go` | Service port mappings table | -| `ThemeListView` | `themelistview.go` | Available themes browser | -| `ProfileListView` | `profilelistview.go` | Available profiles browser | -| `ProfileSelectView` | `profileselectview.go` | Interactive profile selector | -| `VersionView` | `versionview.go` | CLI version info (compact + verbose) | -| `ConfigGetView` | `configgetview.go` | Display current configuration | -| `WorkspaceInfoView` | `workspaceinfoview.go` | Workspace status overview | -| `WorkspaceInitView` | `workspaceinitview.go` | Workspace initialization wizard | -| `WorkspaceRunView` | `workspacerunview.go` | Live workspace run output | -| `WorkspaceHistoryView` | `workspacehistoryview.go` | Workspace run history | -| `InitWizardView` | `initwizardview.go` | First-run setup wizard | diff --git a/pkg/ui/views/configgetview.go b/pkg/ui/views/configgetview.go deleted file mode 100644 index 147b662..0000000 --- a/pkg/ui/views/configgetview.go +++ /dev/null @@ -1,194 +0,0 @@ -package views - -import ( - "fmt" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/internal/preferences" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components/badge" - "github.com/arc-framework/arc-cli/pkg/ui/components/status" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -// ConfigGetView displays the current A.R.C. configuration using Badge components. -// -// Layout: -// -// ┌────────────────────────────────────────┐ -// │ Current Configuration │ -// │ │ -// │ Profile [enterprise] │ -// │ Theme [cyan-purple] │ -// │ Tier 1 [Startup] │ -// │ Tier 2 [Growth] │ -// │ Tier 3 [Enterprise] │ -// │ │ -// ├────────────────────────────────────────┤ -// │ q: quit │ -// └────────────────────────────────────────┘ -// -// Design: 017-ui-engine Phase 6 (T343) -type ConfigGetView struct { - factory ui.ComponentFactory - statusBar *status.StatusBar - config map[string]any - width int - height int -} - -// NewConfigGetView creates a new ConfigGetView with the given ComponentFactory. -func NewConfigGetView(factory ui.ComponentFactory) *ConfigGetView { - return &ConfigGetView{ - factory: factory, - width: 80, - height: 40, - config: make(map[string]any), - } -} - -// Init initializes the ConfigGetView (Bubble Tea lifecycle). -func (v *ConfigGetView) Init() tea.Cmd { - return nil -} - -// Update handles messages and updates the ConfigGetView state. -func (v *ConfigGetView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - return v, nil - - case tea.KeyMsg: - switch msg.String() { - case "q", keyCtrlC: - return v, tea.Quit - } - } - return v, nil -} - -// View renders the ConfigGetView as a string. -func (v *ConfigGetView) View() string { - if v.width <= 0 || v.height <= 0 { - return "" - } - - theme := v.factory.ProfileContext().Theme() - - // Title - titleStyle := lipgloss.NewStyle().Bold(true).Underline(true).Padding(0, 1) - title := titleStyle.Render("Current Configuration") - - // Row label style - labelStyle := lipgloss.NewStyle(). - Width(12). - Foreground(theme.Colors.SecondaryColor()). - Bold(true) - - // Build rows using Badge - rows := []string{""} - - // Profile row - profileID, _ := v.config["profile"].(string) - if profileID == "" { - profileID = "enterprise (default)" - } - profileBadge := badge.NewBadge(profileID, badge.Info, theme) - rows = append(rows, fmt.Sprintf(" %s %s", labelStyle.Render("Profile"), profileBadge.Render())) - - // Theme row - themeID, _ := v.config["theme"].(string) - if themeID == "" { - themeID = "default" - } - themeBadge := badge.NewBadge(themeID, badge.Info, theme) - rows = append(rows, fmt.Sprintf(" %s %s", labelStyle.Render("Theme"), themeBadge.Render())) - - // Tier names - if tiers, ok := v.config["tier_names"].([]string); ok { - for i, t := range tiers { - tierBadge := badge.NewBadge(t, badge.Success, theme) - label := fmt.Sprintf("Tier %d", i+1) - rows = append(rows, fmt.Sprintf(" %s %s", labelStyle.Render(label), tierBadge.Render())) - } - } - - rows = append(rows, "") - - // Status bar - var statusContent string - if v.statusBar != nil { - statusContent = v.statusBar.Render(v.width, v.Keybindings(), "") - } - - return lipgloss.JoinVertical( - lipgloss.Left, - append([]string{title}, append(rows, statusContent)...)..., - ) -} - -// OnEnter is called when the view becomes active. -// Loads current preferences and profile data. -func (v *ConfigGetView) OnEnter(ctx *engine.ViewContext) tea.Cmd { - theme := ctx.Theme - - v.statusBar = status.NewStatusBar(theme) - v.width = ctx.Width - v.height = ctx.Height - - // Load preferences - prefs, err := preferences.Load() - if err != nil { - v.config = map[string]any{"error": err.Error()} - return nil - } - - profileID := prefs.GetProfile() - themeID := prefs.GetTheme() - - v.config = map[string]any{ - "profile": profileID, - "theme": themeID, - } - - // Load tier names from the active profile - if profileID != "" { - repo, repoErr := profiles.NewRepository() - if repoErr == nil { - profile, profErr := repo.GetByID(profileID) - if profErr == nil { - v.config["profile_name"] = profile.Name - v.config["tier_names"] = profile.TierNames - } - } - } - - return nil -} - -// OnExit is called when the view is replaced. -func (v *ConfigGetView) OnExit() tea.Cmd { - return nil -} - -// Name returns the unique identifier for this view. -func (v *ConfigGetView) Name() string { - return "config-get" -} - -// Keybindings returns the keyboard shortcuts for this view. -func (v *ConfigGetView) Keybindings() []engine.KeyBinding { - return []engine.KeyBinding{ - {Key: "q", Description: "quit"}, - } -} - -// ToJSON returns the view's data for JSON output mode. -func (v *ConfigGetView) ToJSON() any { - return v.config -} diff --git a/pkg/ui/views/dashboardview.go b/pkg/ui/views/dashboardview.go deleted file mode 100644 index defafd3..0000000 --- a/pkg/ui/views/dashboardview.go +++ /dev/null @@ -1,361 +0,0 @@ -// Package views provides full-screen view components for the UI engine. -package views - -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components/sidebar" - "github.com/arc-framework/arc-cli/pkg/ui/components/splitpane" - "github.com/arc-framework/arc-cli/pkg/ui/components/status" - "github.com/arc-framework/arc-cli/pkg/ui/engine" -) - -// Key constants for keyboard handling. -const ( - keyCtrlC = "ctrl+c" - keyEnter = "enter" - keyUp = "up" - keyDown = "down" - keyEsc = "esc" - viewHome = "home" - viewDashboard = "dashboard" - viewInfo = "info" - viewServices = "services" - viewConfiguration = "configuration" - viewHelp = "help" -) - -// DashboardView implements a full-screen dashboard layout with sidebar navigation. -// -// Layout: -// -// ┌─────────────┬──────────────────────────┐ -// │ • Home │ │ -// │ Services │ Content Area │ -// │ Config │ (based on selection) │ -// │ Help │ │ -// │ │ │ -// ├─────────────┴──────────────────────────┤ -// │ ↑/↓: navigate • enter: select • q: quit│ -// └────────────────────────────────────────┘ -// -// The view uses a 30/70 split ratio between sidebar and content area. -// Keyboard navigation: arrow keys for sidebar, 'enter' to select, 'q' to quit. -type DashboardView struct { - factory ui.ComponentFactory - sidebar *sidebar.Sidebar - splitPane *splitpane.SplitPane - statusBar *status.StatusBar - width int - height int - contentView string // Current content to display based on selection -} - -// NewDashboardView creates a new DashboardView with the given factory. -// -// The view is initialized with: -// - Sidebar with Home, Services, Configuration, Help items -// - SplitPane with Horizontal orientation and 0.3 ratio -// - StatusBar for displaying keyboard shortcuts -// - Default content showing welcome message -func NewDashboardView(factory ui.ComponentFactory) *DashboardView { - theme := factory.Theme() - - // Create sidebar with menu items - items := []sidebar.MenuItem{ - {ID: viewHome, Label: "Home", Icon: "🏠"}, - {ID: viewServices, Label: "Services", Icon: "📡"}, - {ID: viewConfiguration, Label: "Configuration", Icon: "⚙️"}, - {ID: viewHelp, Label: "Help", Icon: "❓"}, - } - sidebarComponent := sidebar.NewSidebar(items, theme) - - // Create split pane with horizontal orientation (left/right split) - splitPaneComponent := splitpane.NewSplitPane(splitpane.Horizontal, 0.3, theme) - - // Create status bar - statusBarComponent := status.NewStatusBar(theme) - - return &DashboardView{ - factory: factory, - sidebar: sidebarComponent, - splitPane: splitPaneComponent, - statusBar: statusBarComponent, - width: 80, - height: 24, - contentView: "", - } -} - -// Init implements tea.Model for Bubble Tea integration. -func (v *DashboardView) Init() tea.Cmd { - return nil -} - -// Update implements tea.Model to handle user input. -// -// Handles: -// - Arrow keys (up/down, j/k) for sidebar navigation -// - Enter to select sidebar item and update content -// - 'q' to quit -// - Window resize events to adjust layout -func (v *DashboardView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "q", keyCtrlC: - return v, tea.Quit - - case keyEnter: - // Update content based on selected sidebar item - v.updateContentFromSelection() - return v, nil - - case keyUp, "k", keyDown, "j": - // Pass navigation to sidebar - updatedSidebar, cmd := v.sidebar.Update(msg) - v.sidebar = updatedSidebar.(*sidebar.Sidebar) - // Auto-update content on navigation - v.updateContentFromSelection() - return v, cmd - } - - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - return v, nil - } - - return v, nil -} - -// View implements tea.Model to render the dashboard. -// -// Renders a full-screen layout with: -// - Sidebar on the left (30% width) -// - Content area on the right (70% width) -// - Status bar at the bottom -func (v *DashboardView) View() string { - // Calculate available height for split pane. - // SplitPane.renderHorizontal applies lipgloss.Border (RoundedBorder) which adds 2 lines - // (top+bottom border) on top of the Height() inner value. Subtract 1 for status bar + 2 - // for pane borders = 3 total overhead. - availableHeight := v.height - 3 - - if availableHeight < 1 { - availableHeight = 1 - } - - // Get sidebar and content - sidebarContent := v.sidebar.View() - contentArea := v.renderContentArea() - - // Render split pane - splitPaneView := v.splitPane.Render(v.width, availableHeight, sidebarContent, contentArea) - - // Render status bar with keybindings - statusBarView := v.statusBar.Render(v.width, v.Keybindings(), "") - - // Join vertically - return lipgloss.JoinVertical(lipgloss.Left, splitPaneView, statusBarView) -} - -// OnEnter is called when this view becomes active. -// -// Initializes view dimensions from context and updates the sidebar width. -func (v *DashboardView) OnEnter(ctx *engine.ViewContext) tea.Cmd { - v.width = ctx.Width - v.height = ctx.Height - - // Update sidebar width based on split ratio - sidebarWidth := int(float64(ctx.Width) * 0.3) - v.sidebar.SetWidth(sidebarWidth) - - // Initialize content - v.updateContentFromSelection() - - return nil -} - -// OnExit is called when this view is replaced by another view. -func (v *DashboardView) OnExit() tea.Cmd { - return nil -} - -// Name returns the unique identifier for this view. -func (v *DashboardView) Name() string { - return viewDashboard -} - -// Keybindings returns the keyboard shortcuts for this view. -func (v *DashboardView) Keybindings() []engine.KeyBinding { - return []engine.KeyBinding{ - {Key: "↑/↓", Description: "navigate"}, - {Key: "enter", Description: "select"}, - {Key: "q", Description: "quit"}, - } -} - -// updateContentFromSelection updates the content area based on the selected sidebar item. -func (v *DashboardView) updateContentFromSelection() { - selectedItem := v.sidebar.SelectedItem() - - switch selectedItem.ID { - case viewHome: - v.contentView = v.renderHomeContent() - case viewServices: - v.contentView = v.renderServicesContent() - case viewConfiguration: - v.contentView = v.renderConfigurationContent() - case viewHelp: - v.contentView = v.renderHelpContent() - default: - v.contentView = v.renderHomeContent() - } -} - -// renderContentArea returns the current content area for display. -func (v *DashboardView) renderContentArea() string { - if v.contentView == "" { - return v.renderHomeContent() - } - return v.contentView -} - -// renderHomeContent returns the content for the Home section. -func (v *DashboardView) renderHomeContent() string { - theme := v.factory.Theme() - if theme == nil { - return "Welcome to ARC Dashboard\n\nSelect an item from the sidebar to view details." - } - - titleStyle := lipgloss.NewStyle(). - Foreground(theme.Colors.PrimaryColor()). - Bold(true) - - bodyStyle := lipgloss.NewStyle(). - Foreground(theme.Colors.ForegroundColor()) - - title := titleStyle.Render("Welcome to ARC Dashboard") - body := bodyStyle.Render("\nSelect an item from the sidebar to view details.\n\nUse arrow keys to navigate the menu.") - - return lipgloss.JoinVertical(lipgloss.Left, title, body) -} - -// renderServicesContent returns the content for the Services section. -func (v *DashboardView) renderServicesContent() string { - theme := v.factory.Theme() - if theme == nil { - return "Services\n\nNo services available." - } - - titleStyle := lipgloss.NewStyle(). - Foreground(theme.Colors.PrimaryColor()). - Bold(true) - - bodyStyle := lipgloss.NewStyle(). - Foreground(theme.Colors.ForegroundColor()) - - mutedStyle := lipgloss.NewStyle(). - Foreground(theme.Colors.MutedColor()) - - title := titleStyle.Render("Services") - body := bodyStyle.Render("\nManage and monitor your services here.") - hint := mutedStyle.Render("\n\nNo services configured yet.") - - return lipgloss.JoinVertical(lipgloss.Left, title, body, hint) -} - -// renderConfigurationContent returns the content for the Configuration section. -func (v *DashboardView) renderConfigurationContent() string { - theme := v.factory.Theme() - if theme == nil { - return "Configuration\n\nConfigure your settings here." - } - - titleStyle := lipgloss.NewStyle(). - Foreground(theme.Colors.PrimaryColor()). - Bold(true) - - bodyStyle := lipgloss.NewStyle(). - Foreground(theme.Colors.ForegroundColor()) - - mutedStyle := lipgloss.NewStyle(). - Foreground(theme.Colors.MutedColor()) - - title := titleStyle.Render("Configuration") - body := bodyStyle.Render("\nConfigure your ARC settings and preferences.") - - options := []string{ - "• Profile settings", - "• Theme customization", - "• Service configuration", - "• Advanced options", - } - optionsList := mutedStyle.Render("\n" + strings.Join(options, "\n")) - - return lipgloss.JoinVertical(lipgloss.Left, title, body, optionsList) -} - -// renderHelpContent returns the content for the Help section. -func (v *DashboardView) renderHelpContent() string { - theme := v.factory.Theme() - if theme == nil { - return "Help\n\nKeyboard Shortcuts:\n↑/↓: Navigate\nEnter: Select\nq: Quit" - } - - titleStyle := lipgloss.NewStyle(). - Foreground(theme.Colors.PrimaryColor()). - Bold(true) - - bodyStyle := lipgloss.NewStyle(). - Foreground(theme.Colors.ForegroundColor()) - - mutedStyle := lipgloss.NewStyle(). - Foreground(theme.Colors.MutedColor()) - - title := titleStyle.Render("Help") - body := bodyStyle.Render("\nKeyboard Shortcuts:") - - shortcuts := []string{ - "↑/↓, j/k: Navigate menu items", - "enter: Select current item", - "g: Jump to first item", - "G: Jump to last item", - "q: Quit application", - } - shortcutsList := mutedStyle.Render("\n" + strings.Join(shortcuts, "\n")) - - return lipgloss.JoinVertical(lipgloss.Left, title, body, shortcutsList) -} - -// ToJSON returns the view's data for JSON output mode. -// Returns a map describing the current dashboard state including sidebar items -// and the currently active content view. -func (v *DashboardView) ToJSON() any { - var menuItems []map[string]any - if v.sidebar != nil { - for _, item := range v.sidebar.Items() { - menuItems = append(menuItems, map[string]any{ - "id": item.ID, - "label": item.Label, - "icon": item.Icon, - }) - } - } - - selectedID := "" - if v.sidebar != nil { - selectedID = v.sidebar.SelectedItem().ID - } - - return map[string]any{ - "menu_items": menuItems, - "selected": selectedID, - "content_view": v.contentView, - } -} diff --git a/pkg/ui/views/dashboardview_test.go b/pkg/ui/views/dashboardview_test.go deleted file mode 100644 index 55d7f32..0000000 --- a/pkg/ui/views/dashboardview_test.go +++ /dev/null @@ -1,842 +0,0 @@ -package views - -import ( - "encoding/json" - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -// TestNewDashboardView verifies the constructor initializes all components correctly. -func TestNewDashboardView(t *testing.T) { - factory := createTestFactoryForDashboard(t) - view := NewDashboardView(factory) - - if view == nil { - t.Fatal("NewDashboardView returned nil") - } - - if view.factory == nil { - t.Error("factory is nil") - } - - if view.sidebar == nil { - t.Error("sidebar is nil") - } - - if view.splitPane == nil { - t.Error("splitPane is nil") - } - - if view.statusBar == nil { - t.Error("statusBar is nil") - } - - if view.width != 80 { - t.Errorf("expected default width 80, got %d", view.width) - } - - if view.height != 24 { - t.Errorf("expected default height 24, got %d", view.height) - } - - // Verify sidebar has correct items - items := view.sidebar.Items() - expectedItems := []string{"Home", "Services", "Configuration", "Help"} - if len(items) != len(expectedItems) { - t.Errorf("expected %d sidebar items, got %d", len(expectedItems), len(items)) - } - - for i, expected := range expectedItems { - if i >= len(items) { - break - } - if items[i].Label != expected { - t.Errorf("expected sidebar item %d to be %q, got %q", i, expected, items[i].Label) - } - } -} - -// TestDashboardViewInit verifies the Init method. -func TestDashboardViewInit(t *testing.T) { - factory := createTestFactoryForDashboard(t) - view := NewDashboardView(factory) - - cmd := view.Init() - if cmd != nil { - t.Error("Init() should return nil command") - } -} - -// TestDashboardViewName verifies the Name method. -func TestDashboardViewName(t *testing.T) { - factory := createTestFactoryForDashboard(t) - view := NewDashboardView(factory) - - name := view.Name() - if name != "dashboard" { - t.Errorf("expected name 'dashboard', got %q", name) - } -} - -// TestDashboardViewKeybindings verifies the Keybindings method. -func TestDashboardViewKeybindings(t *testing.T) { - factory := createTestFactoryForDashboard(t) - view := NewDashboardView(factory) - - keybindings := view.Keybindings() - expectedCount := 3 // navigate, select, quit - - if len(keybindings) != expectedCount { - t.Errorf("expected %d keybindings, got %d", expectedCount, len(keybindings)) - } - - // Verify key names - foundNavigate := false - foundSelect := false - foundQuit := false - - for _, kb := range keybindings { - if strings.Contains(kb.Description, "navigate") { - foundNavigate = true - } - if strings.Contains(kb.Description, "select") { - foundSelect = true - } - if strings.Contains(kb.Description, "quit") { - foundQuit = true - } - } - - if !foundNavigate { - t.Error("missing 'navigate' keybinding") - } - if !foundSelect { - t.Error("missing 'select' keybinding") - } - if !foundQuit { - t.Error("missing 'quit' keybinding") - } -} - -// TestDashboardViewOnEnter verifies the OnEnter lifecycle method. -func TestDashboardViewOnEnter(t *testing.T) { - factory := createTestFactoryForDashboard(t) - view := NewDashboardView(factory) - - ctx := createTestViewContextForDashboard(120, 40) - cmd := view.OnEnter(ctx) - - if cmd != nil { - t.Error("OnEnter() should return nil command") - } - - if view.width != 120 { - t.Errorf("expected width 120, got %d", view.width) - } - - if view.height != 40 { - t.Errorf("expected height 40, got %d", view.height) - } - - // Verify content was initialized - if view.contentView == "" { - t.Error("contentView should be initialized after OnEnter") - } -} - -// TestDashboardViewOnExit verifies the OnExit lifecycle method. -func TestDashboardViewOnExit(t *testing.T) { - factory := createTestFactoryForDashboard(t) - view := NewDashboardView(factory) - - cmd := view.OnExit() - if cmd != nil { - t.Error("OnExit() should return nil command") - } -} - -// TestDashboardViewUpdateQuit verifies quit key handling. -func TestDashboardViewUpdateQuit(t *testing.T) { - factory := createTestFactoryForDashboard(t) - view := NewDashboardView(factory) - - testCases := []struct { - name string - key string - }{ - {"q key", "q"}, - {"ctrl+c", "ctrl+c"}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tc.key)} - if tc.key == "ctrl+c" { - msg = tea.KeyMsg{Type: tea.KeyCtrlC} - } - - _, cmd := view.Update(msg) - if cmd == nil { - t.Errorf("expected quit command for %q", tc.key) - } - }) - } -} - -// TestDashboardViewUpdateNavigation verifies arrow key navigation. -func TestDashboardViewUpdateNavigation(t *testing.T) { - factory := createTestFactoryForDashboard(t) - view := NewDashboardView(factory) - - // Initialize with context - ctx := createTestViewContextForDashboard(80, 24) - view.OnEnter(ctx) - - // Verify initial selection is first item (Home) - initialSelection := view.sidebar.SelectedIndex() - if initialSelection != 0 { - t.Errorf("expected initial selection 0, got %d", initialSelection) - } - - testCases := []struct { - name string - key tea.KeyType - keyString string - expectedIndex int - }{ - {"down arrow", tea.KeyDown, "down", 1}, - {"j key", tea.KeyRunes, "j", 2}, - {"down arrow again", tea.KeyDown, "down", 3}, - {"up arrow", tea.KeyUp, "up", 2}, - {"k key", tea.KeyRunes, "k", 1}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - var msg tea.Msg - if tc.key == tea.KeyRunes { - msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tc.keyString)} - } else { - msg = tea.KeyMsg{Type: tc.key} - } - - _, cmd := view.Update(msg) - if cmd != nil { - // Execute command if any - cmd() - } - - newSelection := view.sidebar.SelectedIndex() - if newSelection != tc.expectedIndex { - t.Errorf("expected selection index %d, got %d", tc.expectedIndex, newSelection) - } - }) - } -} - -// TestDashboardViewUpdateEnter verifies enter key updates content. -func TestDashboardViewUpdateEnter(t *testing.T) { - factory := createTestFactoryForDashboard(t) - view := NewDashboardView(factory) - - // Initialize - ctx := createTestViewContextForDashboard(80, 24) - view.OnEnter(ctx) - - // Navigate to Services (index 1) - downMsg := tea.KeyMsg{Type: tea.KeyDown} - view.Update(downMsg) - - // Get content before enter - contentBefore := view.contentView - - // Press enter - enterMsg := tea.KeyMsg{Type: tea.KeyEnter} - _, cmd := view.Update(enterMsg) - - if cmd != nil { - cmd() - } - - // Content should be updated - contentAfter := view.contentView - if contentAfter == "" { - t.Error("content should not be empty after enter") - } - - // Content should contain "Services" since we navigated to services item - if !strings.Contains(contentAfter, "Services") { - t.Error("content should contain 'Services'") - } - - // Verify content changed (or is set properly) - _ = contentBefore // Content auto-updates on navigation now -} - -// TestDashboardViewUpdateWindowSize verifies window resize handling. -func TestDashboardViewUpdateWindowSize(t *testing.T) { - factory := createTestFactoryForDashboard(t) - view := NewDashboardView(factory) - - msg := tea.WindowSizeMsg{Width: 120, Height: 40} - _, cmd := view.Update(msg) - - if cmd != nil { - t.Error("WindowSizeMsg should not return a command") - } - - if view.width != 120 { - t.Errorf("expected width 120, got %d", view.width) - } - - if view.height != 40 { - t.Errorf("expected height 40, got %d", view.height) - } -} - -// TestDashboardViewContentUpdates verifies content updates based on sidebar selection. -func TestDashboardViewContentUpdates(t *testing.T) { - factory := createTestFactoryForDashboard(t) - view := NewDashboardView(factory) - - // Initialize - ctx := createTestViewContextForDashboard(80, 24) - view.OnEnter(ctx) - - testCases := []struct { - name string - targetIndex int - expectedContent string - }{ - {"Home content", 0, "Welcome to ARC Dashboard"}, - {"Services content", 1, "Services"}, - {"Configuration content", 2, "Configuration"}, - {"Help content", 3, "Help"}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // Navigate to target index - view.sidebar.SetSelected(tc.targetIndex) - view.updateContentFromSelection() - - if !strings.Contains(view.contentView, tc.expectedContent) { - t.Errorf("expected content to contain %q, got: %s", tc.expectedContent, view.contentView) - } - }) - } -} - -// TestDashboardViewView verifies the View rendering. -func TestDashboardViewView(t *testing.T) { - factory := createTestFactoryForDashboard(t) - view := NewDashboardView(factory) - - // Initialize - ctx := createTestViewContextForDashboard(80, 24) - view.OnEnter(ctx) - - // Render view - rendered := view.View() - - if rendered == "" { - t.Error("View() should return non-empty string") - } - - // Verify output contains expected elements - // Note: Exact content depends on rendering, so we check for key markers - if !strings.Contains(rendered, "Home") { - t.Error("rendered view should contain 'Home' sidebar item") - } - - // Status bar should be present (though exact format may vary) - // We can verify the view is multi-line (split pane + status bar) - lines := strings.Split(rendered, "\n") - if len(lines) < 2 { - t.Error("rendered view should have multiple lines (split pane + status bar)") - } -} - -// TestDashboardViewViewMinimalSize verifies rendering with minimal terminal size. -func TestDashboardViewViewMinimalSize(t *testing.T) { - factory := createTestFactoryForDashboard(t) - view := NewDashboardView(factory) - - // Set minimal size - ctx := createTestViewContextForDashboard(40, 10) - view.OnEnter(ctx) - - // Should render without panic - rendered := view.View() - if rendered == "" { - t.Error("View() should handle minimal size gracefully") - } -} - -// TestDashboardViewRenderContentArea verifies content area rendering. -func TestDashboardViewRenderContentArea(t *testing.T) { - factory := createTestFactoryForDashboard(t) - view := NewDashboardView(factory) - - // Before initialization, should return home content - content := view.renderContentArea() - if content == "" { - t.Error("renderContentArea should return default content") - } - - // After setting content - view.contentView = "Custom content" - content = view.renderContentArea() - if content != "Custom content" { - t.Errorf("expected 'Custom content', got %q", content) - } -} - -// TestDashboardViewContentRenderers verifies individual content renderers. -func TestDashboardViewContentRenderers(t *testing.T) { - factory := createTestFactoryForDashboard(t) - view := NewDashboardView(factory) - - testCases := []struct { - name string - renderer func() string - expected string - }{ - {"Home", view.renderHomeContent, "Welcome to ARC Dashboard"}, - {"Services", view.renderServicesContent, "Services"}, - {"Configuration", view.renderConfigurationContent, "Configuration"}, - {"Help", view.renderHelpContent, "Help"}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - content := tc.renderer() - if content == "" { - t.Error("content renderer should return non-empty string") - } - if !strings.Contains(content, tc.expected) { - t.Errorf("expected content to contain %q, got: %s", tc.expected, content) - } - }) - } -} - -// TestDashboardViewNilTheme verifies graceful handling of nil theme. -func TestDashboardViewNilTheme(t *testing.T) { - // Create factory with nil theme - profileCtx := profiles.GetDefaultProfileContext() - factory := ui.NewComponentFactory(profileCtx, components.BorderTierBlock) - - view := NewDashboardView(factory) - - // Should not panic - content := view.renderHomeContent() - if content == "" { - t.Error("should render content even without theme") - } -} - -// TestDashboardViewSidebarWrapping verifies sidebar navigation wraps correctly. -func TestDashboardViewSidebarWrapping(t *testing.T) { - factory := createTestFactoryForDashboard(t) - view := NewDashboardView(factory) - - // Initialize - ctx := createTestViewContextForDashboard(80, 24) - view.OnEnter(ctx) - - // Start at first item (index 0) - if view.sidebar.SelectedIndex() != 0 { - t.Error("should start at first item") - } - - // Go up - should wrap to last item - upMsg := tea.KeyMsg{Type: tea.KeyUp} - view.Update(upMsg) - - expectedLastIndex := len(view.sidebar.Items()) - 1 - if view.sidebar.SelectedIndex() != expectedLastIndex { - t.Errorf("expected wrap to last index %d, got %d", expectedLastIndex, view.sidebar.SelectedIndex()) - } - - // Go down - should wrap to first item - downMsg := tea.KeyMsg{Type: tea.KeyDown} - view.Update(downMsg) - - if view.sidebar.SelectedIndex() != 0 { - t.Error("expected wrap to first item") - } -} - -// Helper functions specific to dashboard tests - -// createTestFactoryForDashboard creates a test ComponentFactory with default profile for dashboard tests. -func createTestFactoryForDashboard(t *testing.T) ui.ComponentFactory { - t.Helper() - profileCtx := profiles.GetDefaultProfileContext() - return ui.NewComponentFactory(profileCtx, components.BorderTierBlock) -} - -// createTestViewContextForDashboard creates a test ViewContext with given dimensions for dashboard tests. -func createTestViewContextForDashboard(width, height int) *engine.ViewContext { - profileCtx := profiles.GetDefaultProfileContext() - return engine.NewViewContext( - profileCtx.Profile(), - profileCtx.Theme(), - width, - height, - nil, - ) -} - -// T222: TestDashboardNavigation_SidebarToContent verifies that selecting different -// sidebar items (Home, Services, Configuration, Help) updates the content area. -func TestDashboardNavigation_SidebarToContent(t *testing.T) { - factory := createTestFactoryForDashboard(t) - view := NewDashboardView(factory) - - ctx := createTestViewContextForDashboard(80, 24) - view.OnEnter(ctx) - - testCases := []struct { - name string - navigations int // number of 'j' presses from Home - expectedContent string - }{ - {"Home is initial content", 0, "Welcome to ARC Dashboard"}, - {"Services content after 1 j", 1, "Services"}, - {"Configuration content after 2 j", 2, "Configuration"}, - {"Help content after 3 j", 3, "Help"}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // Reset to first item (Home) - view.sidebar.SetSelected(0) - view.updateContentFromSelection() - - // Navigate down tc.navigations times using 'j' key - for i := 0; i < tc.navigations; i++ { - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")} - view.Update(msg) - } - - // Verify the View() output contains section-specific text - rendered := view.View() - if !strings.Contains(rendered, tc.expectedContent) { - t.Errorf("expected View() to contain %q after %d navigations; rendered output did not match", - tc.expectedContent, tc.navigations) - } - - // Also verify the internal contentView field - if !strings.Contains(view.contentView, tc.expectedContent) { - t.Errorf("expected contentView to contain %q, got: %s", tc.expectedContent, view.contentView) - } - }) - } -} - -// T223: TestDashboardFocusSwitching verifies focus-related key handling. -// DashboardView does not handle Tab directly; it handles j/k/up/down/enter/q. -// This test verifies that Tab (unhandled) does not crash and returns the view unchanged. -func TestDashboardFocusSwitching(t *testing.T) { - factory := createTestFactoryForDashboard(t) - view := NewDashboardView(factory) - - ctx := createTestViewContextForDashboard(80, 24) - view.OnEnter(ctx) - - initialIndex := view.sidebar.SelectedIndex() - - // Send Tab key — dashboardview does not handle Tab, so it should be a no-op - tabMsg := tea.KeyMsg{Type: tea.KeyTab} - updatedModel, cmd := view.Update(tabMsg) - - // Should not panic, should return a valid model - if updatedModel == nil { - t.Fatal("Update() returned nil model for Tab key") - } - - // No command should be issued for an unhandled key - if cmd != nil { - t.Error("Tab key should return nil cmd (unhandled key)") - } - - // Selection index should be unchanged - updatedView, ok := updatedModel.(*DashboardView) - if !ok { - t.Fatal("Update() returned wrong model type") - } - if updatedView.sidebar.SelectedIndex() != initialIndex { - t.Errorf("Tab key should not change sidebar selection: expected %d, got %d", - initialIndex, updatedView.sidebar.SelectedIndex()) - } -} - -// T224: TestDashboardKeyboardNavigation_JK verifies that j/k keys navigate the sidebar. -func TestDashboardKeyboardNavigation_JK(t *testing.T) { - factory := createTestFactoryForDashboard(t) - view := NewDashboardView(factory) - - ctx := createTestViewContextForDashboard(80, 24) - view.OnEnter(ctx) - - // Start at index 0 - if view.sidebar.SelectedIndex() != 0 { - t.Fatalf("expected initial index 0, got %d", view.sidebar.SelectedIndex()) - } - - // Press j → move to index 1 - view.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) - if view.sidebar.SelectedIndex() != 1 { - t.Errorf("after j: expected index 1, got %d", view.sidebar.SelectedIndex()) - } - - // Press j → move to index 2 - view.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) - if view.sidebar.SelectedIndex() != 2 { - t.Errorf("after second j: expected index 2, got %d", view.sidebar.SelectedIndex()) - } - - // Press k → move back to index 1 - view.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")}) - if view.sidebar.SelectedIndex() != 1 { - t.Errorf("after k: expected index 1, got %d", view.sidebar.SelectedIndex()) - } - - // Press k → move back to index 0 - view.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")}) - if view.sidebar.SelectedIndex() != 0 { - t.Errorf("after second k: expected index 0, got %d", view.sidebar.SelectedIndex()) - } -} - -// T228: TestDashboardKeyboardNavigation is a comprehensive test covering j/k, up/down -// navigation within the dashboard and verifying content updates at each step. -func TestDashboardKeyboardNavigation(t *testing.T) { - factory := createTestFactoryForDashboard(t) - view := NewDashboardView(factory) - - ctx := createTestViewContextForDashboard(80, 24) - view.OnEnter(ctx) - - steps := []struct { - key string - keyType tea.KeyType - expectedIndex int - expectedContent string - }{ - {"j", tea.KeyRunes, 1, "Services"}, - {"j", tea.KeyRunes, 2, "Configuration"}, - {"j", tea.KeyRunes, 3, "Help"}, - {"k", tea.KeyRunes, 2, "Configuration"}, - {"down", tea.KeyDown, 3, "Help"}, - {"up", tea.KeyUp, 2, "Configuration"}, - {"k", tea.KeyRunes, 1, "Services"}, - {"k", tea.KeyRunes, 0, "Welcome to ARC Dashboard"}, - } - - for _, step := range steps { - var msg tea.KeyMsg - if step.keyType == tea.KeyRunes { - msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(step.key)} - } else { - msg = tea.KeyMsg{Type: step.keyType} - } - - view.Update(msg) - - if view.sidebar.SelectedIndex() != step.expectedIndex { - t.Errorf("after pressing %q: expected sidebar index %d, got %d", - step.key, step.expectedIndex, view.sidebar.SelectedIndex()) - } - - if !strings.Contains(view.contentView, step.expectedContent) { - t.Errorf("after pressing %q: expected contentView to contain %q", - step.key, step.expectedContent) - } - } -} - -// T229: TestSidebarSelectionChangesContent verifies that moving the sidebar selection -// changes the content area rendering in View(). -func TestSidebarSelectionChangesContent(t *testing.T) { - factory := createTestFactoryForDashboard(t) - view := NewDashboardView(factory) - - ctx := createTestViewContextForDashboard(80, 24) - view.OnEnter(ctx) - - // Collect content for each sidebar item by simulating navigation - contentByItem := make(map[string]string) - - sidebarItems := view.sidebar.Items() - for i, item := range sidebarItems { - view.sidebar.SetSelected(i) - view.updateContentFromSelection() - contentByItem[item.ID] = view.View() - } - - // Each item should produce distinct content in View() - // Home should show welcome text - if !strings.Contains(contentByItem["home"], "Welcome to ARC Dashboard") { - t.Error("Home selection should render 'Welcome to ARC Dashboard' in View()") - } - - // Services should show services text - if !strings.Contains(contentByItem["services"], "Services") { - t.Error("Services selection should render 'Services' in View()") - } - - // Configuration should show configuration text - if !strings.Contains(contentByItem["configuration"], "Configuration") { - t.Error("Configuration selection should render 'Configuration' in View()") - } - - // Help should show help text - if !strings.Contains(contentByItem["help"], "Help") { - t.Error("Help selection should render 'Help' in View()") - } - - // All four content areas should be different from each other - if contentByItem["home"] == contentByItem["services"] { - t.Error("Home and Services content areas should differ") - } - if contentByItem["services"] == contentByItem["configuration"] { - t.Error("Services and Configuration content areas should differ") - } - if contentByItem["configuration"] == contentByItem["help"] { - t.Error("Configuration and Help content areas should differ") - } -} - -// T243: JSON marshaling tests for DashboardView.ToJSON -func TestDashboardView_ToJSON(t *testing.T) { - factory := createTestFactoryForDashboard(t) - view := NewDashboardView(factory) - - ctx := createTestViewContextForDashboard(80, 24) - view.OnEnter(ctx) - - t.Run("implements JSONExporter interface", func(t *testing.T) { - var _ engine.JSONExporter = view - }) - - t.Run("returns JSON-marshallable data", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - if err != nil { - t.Fatalf("json.Marshal failed: %v", err) - } - if len(data) == 0 { - t.Error("expected non-empty JSON output") - } - }) - - t.Run("contains expected top-level keys", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - if err != nil { - t.Fatalf("json.Marshal failed: %v", err) - } - var result map[string]any - if err := json.Unmarshal(data, &result); err != nil { - t.Fatalf("json.Unmarshal failed: %v", err) - } - - expectedKeys := []string{"menu_items", "selected", "content_view"} - for _, key := range expectedKeys { - if _, ok := result[key]; !ok { - t.Errorf("expected key %q not found in ToJSON output", key) - } - } - }) - - t.Run("menu_items contains all sidebar items", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - if err != nil { - t.Fatalf("json.Marshal failed: %v", err) - } - var result map[string]any - if err := json.Unmarshal(data, &result); err != nil { - t.Fatalf("json.Unmarshal failed: %v", err) - } - - items := result["menu_items"].([]any) - if len(items) != 4 { - t.Errorf("expected 4 menu items, got %d", len(items)) - } - - // Verify first item structure - first := items[0].(map[string]any) - if _, ok := first["id"]; !ok { - t.Error("menu item missing 'id' field") - } - if _, ok := first["label"]; !ok { - t.Error("menu item missing 'label' field") - } - if _, ok := first["icon"]; !ok { - t.Error("menu item missing 'icon' field") - } - }) - - t.Run("selected reflects current sidebar selection", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - if err != nil { - t.Fatalf("json.Marshal failed: %v", err) - } - var result map[string]any - if err := json.Unmarshal(data, &result); err != nil { - t.Fatalf("json.Unmarshal failed: %v", err) - } - - // Default selection should be the first item ("home") - selected := result["selected"].(string) - if selected != "home" { - t.Errorf("expected selected 'home', got %q", selected) - } - }) - - t.Run("selected changes when navigation updates sidebar", func(t *testing.T) { - view2 := NewDashboardView(factory) - view2.OnEnter(ctx) - - // Navigate to second item (services) - view2.sidebar.SetSelected(1) - view2.updateContentFromSelection() - - data, err := json.Marshal(view2.ToJSON()) - if err != nil { - t.Fatalf("json.Marshal failed: %v", err) - } - var result map[string]any - if err := json.Unmarshal(data, &result); err != nil { - t.Fatalf("json.Unmarshal failed: %v", err) - } - - selected := result["selected"].(string) - if selected != "services" { - t.Errorf("expected selected 'services', got %q", selected) - } - }) - - t.Run("works without OnEnter being called", func(t *testing.T) { - uninitView := NewDashboardView(factory) - data, err := json.Marshal(uninitView.ToJSON()) - if err != nil { - t.Fatalf("json.Marshal failed: %v", err) - } - var result map[string]any - if err := json.Unmarshal(data, &result); err != nil { - t.Fatalf("json.Unmarshal failed: %v", err) - } - if _, ok := result["menu_items"]; !ok { - t.Error("expected 'menu_items' key even without OnEnter") - } - }) -} diff --git a/pkg/ui/views/homeview.go b/pkg/ui/views/homeview.go deleted file mode 100644 index 924caf5..0000000 --- a/pkg/ui/views/homeview.go +++ /dev/null @@ -1,160 +0,0 @@ -package views - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components/hero" - "github.com/arc-framework/arc-cli/pkg/ui/components/status" - "github.com/arc-framework/arc-cli/pkg/ui/engine" -) - -// HomeView displays the profile branding hero and a status bar. -// This is the entry point view for the UI engine, showing the active -// profile's identity with keyboard shortcuts for navigation. -// -// Layout: -// -// ┌────────────────────────────────────────┐ -// │ │ -// │ [HERO/LOGO] │ -// │ Profile Branding │ -// │ │ -// │────────────────────────────────────────│ -// │ q: quit │ -// └────────────────────────────────────────┘ -// -// Design: 017-ui-engine Phase 3 (User Story 1) -type HomeView struct { - factory ui.ComponentFactory - hero *hero.Hero - statusBar *status.StatusBar - width int - height int -} - -// NewHomeView creates a new HomeView with the given ComponentFactory. -// The factory is used to access profile and theme information for rendering. -func NewHomeView(factory ui.ComponentFactory) *HomeView { - return &HomeView{ - factory: factory, - width: 80, // Default width - height: 40, // Default height - } -} - -// Init initializes the HomeView (Bubble Tea lifecycle). -// No commands are needed for this static view. -func (v *HomeView) Init() tea.Cmd { - return nil -} - -// Update handles messages and updates the HomeView state. -// Handles window resize and keyboard input (q to quit). -func (v *HomeView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - return v, nil - - case tea.KeyMsg: - switch msg.String() { - case "q", keyCtrlC: - return v, tea.Quit - } - } - - return v, nil -} - -// View renders the HomeView as a string. -// Displays the hero component centered with a status bar at the bottom. -func (v *HomeView) View() string { - if v.width <= 0 || v.height <= 0 { - return "" - } - - // If components aren't initialized, return empty - if v.hero == nil || v.statusBar == nil { - return "" - } - - // Render hero (takes most of the vertical space) - heroContent := v.hero.Render(v.width) - - // Render status bar at the bottom - statusContent := v.statusBar.Render(v.width, v.Keybindings(), "") - - // Calculate vertical spacing - heroHeight := lipgloss.Height(heroContent) - statusHeight := lipgloss.Height(statusContent) - availableSpace := v.height - heroHeight - statusHeight - - // Add vertical spacing to center the hero - var spacer string - if availableSpace > 0 { - topPadding := availableSpace / 2 - if topPadding > 0 { - spacer = lipgloss.NewStyle().Height(topPadding).Render("") - } - } - - // Join all sections vertically - return lipgloss.JoinVertical( - lipgloss.Left, - spacer, - heroContent, - statusContent, - ) -} - -// OnEnter is called when the HomeView becomes active. -// Initializes the hero and status bar components with profile/theme from context. -func (v *HomeView) OnEnter(ctx *engine.ViewContext) tea.Cmd { - // Initialize hero with profile and theme from context - v.hero = hero.NewHero(ctx.Profile, ctx.Theme) - - // Initialize status bar with theme from context - v.statusBar = status.NewStatusBar(ctx.Theme) - - // Update dimensions from context - v.width = ctx.Width - v.height = ctx.Height - - return nil -} - -// OnExit is called when the HomeView is replaced by another view. -// No cleanup needed for this view. -func (v *HomeView) OnExit() tea.Cmd { - return nil -} - -// Name returns the unique identifier for this view. -func (v *HomeView) Name() string { - return viewHome -} - -// Keybindings returns the keyboard shortcuts for the HomeView. -func (v *HomeView) Keybindings() []engine.KeyBinding { - return []engine.KeyBinding{ - {Key: "q", Description: "quit"}, - } -} - -// ToJSON returns the view's data for JSON output mode. -// Returns a map containing the available menu items for the home screen. -func (v *HomeView) ToJSON() any { - menuItems := []map[string]any{ - {"id": "services", "label": "Services", "description": "Manage and monitor your services"}, - {"id": "configuration", "label": "Configuration", "description": "Configure your ARC settings"}, - {"id": "info", "label": "Info", "description": "View system information"}, - {"id": "help", "label": "Help", "description": "Display help and keyboard shortcuts"}, - } - return map[string]any{ - "menu_items": menuItems, - "count": len(menuItems), - } -} diff --git a/pkg/ui/views/homeview_test.go b/pkg/ui/views/homeview_test.go deleted file mode 100644 index 3fac2d3..0000000 --- a/pkg/ui/views/homeview_test.go +++ /dev/null @@ -1,418 +0,0 @@ -package views - -import ( - "encoding/json" - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// createHomeTestProfile creates a test profile for HomeView tests. -func createHomeTestProfile() *profiles.Profile { - return &profiles.Profile{ - ID: "test-profile", - Name: "Test Profile", - Logo: "TEST\nLOGO", - PrimaryColor: "#FF0000", - TierNames: []string{"Basic", "Standard", "Premium"}, - } -} - -// createHomeTestTheme creates a test theme for HomeView tests. -func createHomeTestTheme() *themes.Theme { - return &themes.Theme{ - Name: "test-theme", - Colors: themes.ColorSet{ - Primary: "#FF0000", - Secondary: "#00FF00", - Background: "#000000", - Foreground: "#FFFFFF", - Muted: "#888888", - Border: "#666666", - Success: "#00FF00", - Warning: "#FFFF00", - Error: "#FF0000", - Info: "#00FFFF", - }, - } -} - -func TestNewHomeView(t *testing.T) { - view := NewHomeView(nil) - - assert.NotNil(t, view) - assert.Nil(t, view.factory) - assert.Equal(t, 80, view.width, "Default width") - assert.Equal(t, 40, view.height, "Default height") - assert.Nil(t, view.hero, "Hero not initialized until OnEnter") - assert.Nil(t, view.statusBar, "StatusBar not initialized until OnEnter") -} - -func TestHomeViewInit(t *testing.T) { - view := NewHomeView(nil) - - cmd := view.Init() - assert.Nil(t, cmd, "Init should return no command") -} - -func TestHomeViewName(t *testing.T) { - view := NewHomeView(nil) - - assert.Equal(t, "home", view.Name()) -} - -func TestHomeViewKeybindings(t *testing.T) { - view := NewHomeView(nil) - - bindings := view.Keybindings() - - require.Len(t, bindings, 1) - assert.Equal(t, "q", bindings[0].Key) - assert.Equal(t, "quit", bindings[0].Description) -} - -func TestHomeViewOnEnter(t *testing.T) { - view := NewHomeView(nil) - - profile := createHomeTestProfile() - theme := createHomeTestTheme() - ctx := engine.NewViewContext(profile, theme, 120, 60, nil) - - cmd := view.OnEnter(ctx) - - assert.Nil(t, cmd, "OnEnter should return no command") - assert.NotNil(t, view.hero, "Hero should be initialized") - assert.NotNil(t, view.statusBar, "StatusBar should be initialized") - assert.Equal(t, 120, view.width, "Width should be updated from context") - assert.Equal(t, 60, view.height, "Height should be updated from context") -} - -func TestHomeViewOnExit(t *testing.T) { - view := NewHomeView(nil) - - cmd := view.OnExit() - assert.Nil(t, cmd, "OnExit should return no command") -} - -func TestHomeViewUpdateWindowSize(t *testing.T) { - view := NewHomeView(nil) - - // Initialize view with OnEnter - profile := createHomeTestProfile() - theme := createHomeTestTheme() - ctx := engine.NewViewContext(profile, theme, 80, 40, nil) - view.OnEnter(ctx) - - // Send window resize message - msg := tea.WindowSizeMsg{Width: 160, Height: 60} - updatedModel, cmd := view.Update(msg) - - assert.Nil(t, cmd) - updatedView := updatedModel.(*HomeView) - assert.Equal(t, 160, updatedView.width) - assert.Equal(t, 60, updatedView.height) -} - -func TestHomeViewUpdateKeyPress(t *testing.T) { - view := NewHomeView(nil) - - // Initialize view with OnEnter - profile := createHomeTestProfile() - theme := createHomeTestTheme() - ctx := engine.NewViewContext(profile, theme, 80, 40, nil) - view.OnEnter(ctx) - - tests := []struct { - name string - key string - shouldQuit bool - }{ - { - name: "q key quits", - key: "q", - shouldQuit: true, - }, - { - name: "ctrl+c quits", - key: "ctrl+c", - shouldQuit: true, - }, - { - name: "other keys do nothing", - key: "a", - shouldQuit: false, - }, - { - name: "enter does nothing", - key: "enter", - shouldQuit: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - msg := tea.KeyMsg{Type: tea.KeyRunes} - // Simulate the key string - if tt.key == "ctrl+c" { - msg.Type = tea.KeyCtrlC - } - // For actual key string matching - msg.Runes = []rune(tt.key) - - updatedModel, cmd := view.Update(msg) - - assert.NotNil(t, updatedModel) - if tt.shouldQuit { - // tea.Quit returns a function, so we check if cmd is not nil - assert.NotNil(t, cmd, "Should return quit command") - } else { - assert.Nil(t, cmd, "Should not return any command") - } - }) - } -} - -func TestHomeViewView(t *testing.T) { - t.Run("before OnEnter returns empty", func(t *testing.T) { - view := NewHomeView(nil) - // Before OnEnter, hero and statusBar are nil - output := view.View() - assert.Equal(t, "", output, "Should return empty string when components not initialized") - }) - - t.Run("with zero dimensions returns empty", func(t *testing.T) { - view := NewHomeView(nil) - profile := createHomeTestProfile() - theme := createHomeTestTheme() - ctx := engine.NewViewContext(profile, theme, 0, 0, nil) - view.OnEnter(ctx) - - output := view.View() - assert.Equal(t, "", output, "Should return empty string with zero dimensions") - }) - - t.Run("after OnEnter renders content", func(t *testing.T) { - view := NewHomeView(nil) - profile := createHomeTestProfile() - theme := createHomeTestTheme() - ctx := engine.NewViewContext(profile, theme, 80, 40, nil) - view.OnEnter(ctx) - - output := view.View() - - // Output should not be empty - assert.NotEmpty(t, output, "Should render content after OnEnter") - - // Output should contain some content from the hero - // (We can't easily assert exact content due to ANSI codes, but we can check it's non-empty) - assert.Greater(t, len(output), 0, "Should have rendered output") - }) - - t.Run("renders with different dimensions", func(t *testing.T) { - profile := createHomeTestProfile() - theme := createHomeTestTheme() - - // Test with different sizes - sizes := []struct { - width int - height int - }{ - {80, 24}, - {120, 40}, - {160, 60}, - } - - for _, size := range sizes { - view := NewHomeView(nil) - ctx := engine.NewViewContext(profile, theme, size.width, size.height, nil) - view.OnEnter(ctx) - - output := view.View() - assert.NotEmpty(t, output, "Should render for size %dx%d", size.width, size.height) - } - }) -} - -func TestHomeViewIntegration(t *testing.T) { - // Test the full lifecycle: Init -> OnEnter -> Update -> View -> OnExit - view := NewHomeView(nil) - - // 1. Init - cmd := view.Init() - assert.Nil(t, cmd) - - // 2. OnEnter - profile := createHomeTestProfile() - theme := createHomeTestTheme() - ctx := engine.NewViewContext(profile, theme, 120, 40, nil) - cmd = view.OnEnter(ctx) - assert.Nil(t, cmd) - - // 3. Update with window resize - msg := tea.WindowSizeMsg{Width: 100, Height: 50} - updatedModel, cmd := view.Update(msg) - assert.Nil(t, cmd) - updatedView := updatedModel.(*HomeView) - assert.Equal(t, 100, updatedView.width) - assert.Equal(t, 50, updatedView.height) - - // 4. View renders - output := updatedView.View() - assert.NotEmpty(t, output) - - // 5. OnExit - cmd = updatedView.OnExit() - assert.Nil(t, cmd) -} - -func TestHomeViewImplementsViewInterface(t *testing.T) { - // Compile-time check that HomeView implements engine.View - var _ engine.View = (*HomeView)(nil) -} - -func TestHomeViewImplementsBubbleTeaModel(t *testing.T) { - // Compile-time check that HomeView implements tea.Model - var _ tea.Model = (*HomeView)(nil) -} - -func TestHomeViewWithNilFactory(t *testing.T) { - // Test that view can be created with nil factory (should not panic) - view := NewHomeView(nil) - assert.NotNil(t, view) - assert.Nil(t, view.factory) - - // OnEnter should still work (though hero/statusBar need profile/theme) - profile := createHomeTestProfile() - theme := createHomeTestTheme() - ctx := engine.NewViewContext(profile, theme, 80, 40, nil) - - assert.NotPanics(t, func() { - view.OnEnter(ctx) - }) -} - -func TestHomeViewOnEnterWithArgs(t *testing.T) { - view := NewHomeView(nil) - - profile := createHomeTestProfile() - theme := createHomeTestTheme() - - // Test with args (HomeView doesn't use them, but should handle gracefully) - args := map[string]any{ - "someKey": "someValue", - "anotherKey": 123, - } - ctx := engine.NewViewContext(profile, theme, 80, 40, args) - - cmd := view.OnEnter(ctx) - assert.Nil(t, cmd) - - // View should still initialize properly - assert.NotNil(t, view.hero) - assert.NotNil(t, view.statusBar) -} - -func TestHomeViewMultipleOnEnterCalls(t *testing.T) { - // Test that calling OnEnter multiple times reinitializes components - view := NewHomeView(nil) - - profile1 := createHomeTestProfile() - theme1 := createHomeTestTheme() - ctx1 := engine.NewViewContext(profile1, theme1, 80, 40, nil) - view.OnEnter(ctx1) - - assert.NotNil(t, view.hero, "Hero should be initialized") - assert.NotNil(t, view.statusBar, "StatusBar should be initialized") - assert.Equal(t, 80, view.width) - assert.Equal(t, 40, view.height) - - // Call OnEnter again with different context - profile2 := createHomeTestProfile() - profile2.Name = "Different Profile" - theme2 := createHomeTestTheme() - ctx2 := engine.NewViewContext(profile2, theme2, 120, 60, nil) - view.OnEnter(ctx2) - - // Components should be reinitialized and dimensions updated - assert.NotNil(t, view.hero, "Hero should still be initialized") - assert.NotNil(t, view.statusBar, "StatusBar should still be initialized") - assert.Equal(t, 120, view.width, "Width should be updated") - assert.Equal(t, 60, view.height, "Height should be updated") -} - -// T243: JSON marshaling tests for HomeView.ToJSON -func TestHomeView_ToJSON(t *testing.T) { - view := NewHomeView(nil) - profile := createHomeTestProfile() - theme := createHomeTestTheme() - ctx := engine.NewViewContext(profile, theme, 80, 40, nil) - view.OnEnter(ctx) - - t.Run("implements JSONExporter interface", func(t *testing.T) { - var _ engine.JSONExporter = view - }) - - t.Run("returns JSON-marshallable data", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - require.NoError(t, err) - assert.NotEmpty(t, data) - }) - - t.Run("contains expected top-level keys", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - - assert.Contains(t, result, "menu_items") - assert.Contains(t, result, "count") - }) - - t.Run("count matches menu items length", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - - count := result["count"].(float64) - items := result["menu_items"].([]any) - assert.Equal(t, float64(len(items)), count) - }) - - t.Run("menu items have required fields", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - - items := result["menu_items"].([]any) - require.NotEmpty(t, items) - - for _, rawItem := range items { - item := rawItem.(map[string]any) - assert.Contains(t, item, "id") - assert.Contains(t, item, "label") - assert.Contains(t, item, "description") - } - }) - - t.Run("works without OnEnter being called", func(t *testing.T) { - uninitView := NewHomeView(nil) - data, err := json.Marshal(uninitView.ToJSON()) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - - assert.Contains(t, result, "menu_items") - }) -} diff --git a/pkg/ui/views/infoview.go b/pkg/ui/views/infoview.go deleted file mode 100644 index 1759c22..0000000 --- a/pkg/ui/views/infoview.go +++ /dev/null @@ -1,390 +0,0 @@ -package views - -import ( - "fmt" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/internal/branding" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components/hero" - "github.com/arc-framework/arc-cli/pkg/ui/components/status" - "github.com/arc-framework/arc-cli/pkg/ui/components/tree" - "github.com/arc-framework/arc-cli/pkg/ui/engine" -) - -// InfoView displays system information in a hierarchical tree format. -// This view integrates Hero (profile branding), Tree (system info display), -// and StatusBar components to provide a comprehensive system info interface. -// -// Layout: -// -// ┌────────────────────────────────────────┐ -// │ │ -// │ [HERO/LOGO] │ -// │ Profile Branding │ -// │ │ -// ├────────────────────────────────────────┤ -// │ System Information │ -// │ ├── CLI │ -// │ │ ├── Version: 1.0.0 │ -// │ │ ├── Build Date: 2024-01-01 │ -// │ │ └── Commit: abc123 │ -// │ ├── Go Runtime │ -// │ │ ├── Version: go1.24.0 │ -// │ │ └── OS/Arch: darwin/arm64 │ -// │ ├── Hardware │ -// │ │ ├── CPU: Apple M1 Pro │ -// │ │ ├── Cores: 10 │ -// │ │ └── Memory: 32.0 GB total │ -// │ ├── System │ -// │ │ ├── Hostname: myhost │ -// │ │ ├── User: username │ -// │ │ ├── Home: /Users/username │ -// │ │ └── Working Dir: /path/to/project │ -// │ ├── Configuration │ -// │ │ ├── Config Dir: ~/.arc/config │ -// │ │ ├── State DB: ~/.arc/state.db │ -// │ │ └── DB Size: 1.2 MB │ -// │ └── Git Repository (if in repo) │ -// │ ├── Branch: main │ -// │ ├── Commit: abc123 │ -// │ ├── Status: clean │ -// │ └── Remote: git@github.com... │ -// ├────────────────────────────────────────┤ -// │ j/k: navigate • q: quit │ -// └────────────────────────────────────────┘ -// -// Features: -// - Profile branding via Hero component -// - Hierarchical system info display via Tree component -// - Keyboard navigation (j/k or arrows) -// - Theme-aware rendering -// - Responsive layout -// -// Design: 017-ui-engine Phase 5 (User Story 2) -type InfoView struct { - factory ui.ComponentFactory - hero *hero.Hero - tree *tree.Tree - statusBar *status.StatusBar - systemInfo *branding.SystemInfo - width int - height int -} - -// NewInfoView creates a new InfoView with the given ComponentFactory. -// The factory is used to access profile and theme information for rendering. -func NewInfoView(factory ui.ComponentFactory) *InfoView { - return &InfoView{ - factory: factory, - width: 80, // Default width - height: 40, // Default height - } -} - -// Init initializes the InfoView (Bubble Tea lifecycle). -// No commands are needed for this static view. -func (v *InfoView) Init() tea.Cmd { - return nil -} - -// Update handles messages and updates the InfoView state. -// Handles window resize and keyboard input for navigation and quitting. -func (v *InfoView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - return v, nil - - case tea.KeyMsg: - switch msg.String() { - case "q", keyCtrlC: - return v, tea.Quit - case "j", keyDown: - // Future: navigate tree down - return v, nil - case "k", keyUp: - // Future: navigate tree up - return v, nil - } - } - - return v, nil -} - -// View renders the InfoView as a string. -// Displays hero at top, system info tree in middle, status bar at bottom. -func (v *InfoView) View() string { - if v.width <= 0 || v.height <= 0 { - return "" - } - - // If components aren't initialized, return empty - if v.hero == nil || v.tree == nil || v.statusBar == nil { - return "" - } - - // Render hero at the top - heroContent := v.hero.Render(v.width) - - // Render tree in the middle - treeContent := v.tree.Render(v.width) - - // Render status bar at the bottom - statusContent := v.statusBar.Render(v.width, v.Keybindings(), "") - - // Calculate spacing - heroHeight := lipgloss.Height(heroContent) - treeHeight := lipgloss.Height(treeContent) - statusHeight := lipgloss.Height(statusContent) - totalHeight := heroHeight + treeHeight + statusHeight - - // Add spacing between sections if needed - var spacer string - if totalHeight < v.height { - spacer = "" - } - - // Join all sections vertically - return lipgloss.JoinVertical( - lipgloss.Left, - heroContent, - spacer, - treeContent, - "", - statusContent, - ) -} - -// OnEnter is called when the InfoView becomes active. -// Initializes all child components with profile/theme from context and -// extracts systemInfo from context args. -func (v *InfoView) OnEnter(ctx *engine.ViewContext) tea.Cmd { - // Extract system info from context args - if sysInfo, ok := ctx.Args["systemInfo"].(*branding.SystemInfo); ok { - v.systemInfo = sysInfo - } - - // Initialize hero with profile and theme from context - v.hero = hero.NewHero(ctx.Profile, ctx.Theme) - - // Build tree structure from system info - treeRoot := v.buildSystemInfoTree() - v.tree = tree.NewTree(treeRoot, ctx.Theme) - - // Initialize status bar with theme from context - v.statusBar = status.NewStatusBar(ctx.Theme) - - // Update dimensions from context - v.width = ctx.Width - v.height = ctx.Height - - return nil -} - -// OnExit is called when the InfoView is replaced by another view. -// No cleanup needed for this view. -func (v *InfoView) OnExit() tea.Cmd { - return nil -} - -// Name returns the unique identifier for this view. -func (v *InfoView) Name() string { - return viewInfo -} - -// Keybindings returns the keyboard shortcuts for the InfoView. -func (v *InfoView) Keybindings() []engine.KeyBinding { - return []engine.KeyBinding{ - {Key: "↑/↓", Description: "navigate"}, - {Key: "q", Description: "quit"}, - } -} - -// buildSystemInfoTree constructs a hierarchical tree structure from SystemInfo. -// Returns a tree root with sections for CLI, Go Runtime, Hardware, System, -// Configuration, and optionally Git Repository. -func (v *InfoView) buildSystemInfoTree() *tree.TreeNode { - if v.systemInfo == nil { - return &tree.TreeNode{ - Label: "System Information", - Value: "(no data available)", - Children: []*tree.TreeNode{}, - Expanded: true, - } - } - - info := v.systemInfo - children := []*tree.TreeNode{ - v.buildCLISection(info), - v.buildGoRuntimeSection(info), - } - - if hw := v.buildHardwareSection(info); hw != nil { - children = append(children, hw) - } - if sys := v.buildSystemSection(info); sys != nil { - children = append(children, sys) - } - if cfg := v.buildConfigSection(info); cfg != nil { - children = append(children, cfg) - } - if info.IsGitRepo { - children = append(children, v.buildGitSection(info)) - } - - return &tree.TreeNode{ - Label: "System Information", - Children: children, - Expanded: true, - } -} - -func (v *InfoView) buildCLISection(info *branding.SystemInfo) *tree.TreeNode { - children := []*tree.TreeNode{ - {Label: "Version", Value: info.CLIVersion, Expanded: true}, - {Label: "Build Date", Value: info.CLIBuildDate, Expanded: true}, - } - if info.CLICommit != "" { - children = append(children, &tree.TreeNode{Label: "Commit", Value: info.CLICommit, Expanded: true}) - } - return &tree.TreeNode{Label: "CLI", Children: children, Expanded: true} -} - -func (v *InfoView) buildGoRuntimeSection(info *branding.SystemInfo) *tree.TreeNode { - return &tree.TreeNode{ - Label: "Go Runtime", - Children: []*tree.TreeNode{ - {Label: "Version", Value: info.GoVersion, Expanded: true}, - {Label: "OS/Arch", Value: fmt.Sprintf("%s/%s", info.GoOS, info.GoArch), Expanded: true}, - }, - Expanded: true, - } -} - -func (v *InfoView) buildHardwareSection(info *branding.SystemInfo) *tree.TreeNode { - var children []*tree.TreeNode - if info.CPUModel != "" && info.CPUModel != branding.UnknownValue { - children = append(children, &tree.TreeNode{Label: "CPU", Value: info.CPUModel, Expanded: true}) - } - children = append(children, &tree.TreeNode{ - Label: "Cores", - Value: fmt.Sprintf("%d", info.NumCPU), - Expanded: true, - }) - if info.MemoryTotal > 0 { - memVal := fmt.Sprintf("%s total, %s free", - branding.FormatBytes(int64(info.MemoryTotal)), - branding.FormatBytes(int64(info.MemoryFree))) - children = append(children, &tree.TreeNode{Label: "Memory", Value: memVal, Expanded: true}) - } - if len(children) == 0 { - return nil - } - return &tree.TreeNode{Label: "Hardware", Children: children, Expanded: true} -} - -func (v *InfoView) buildSystemSection(info *branding.SystemInfo) *tree.TreeNode { - var children []*tree.TreeNode - if info.Hostname != "" { - children = append(children, &tree.TreeNode{Label: "Hostname", Value: info.Hostname, Expanded: true}) - } - if info.Username != "" { - children = append(children, &tree.TreeNode{Label: "User", Value: info.Username, Expanded: true}) - } - if info.HomeDir != "" { - children = append(children, &tree.TreeNode{Label: "Home", Value: info.HomeDir, Expanded: true}) - } - if info.WorkingDir != "" { - children = append(children, &tree.TreeNode{Label: "Working Dir", Value: info.WorkingDir, Expanded: true}) - } - if len(children) == 0 { - return nil - } - return &tree.TreeNode{Label: "System", Children: children, Expanded: true} -} - -func (v *InfoView) buildConfigSection(info *branding.SystemInfo) *tree.TreeNode { - var children []*tree.TreeNode - if info.ConfigDir != "" { - children = append(children, &tree.TreeNode{Label: "Config Dir", Value: info.ConfigDir, Expanded: true}) - } - if info.StateDBPath != "" { - children = append(children, &tree.TreeNode{Label: "State DB", Value: info.StateDBPath, Expanded: true}) - if size, err := branding.GetStateDBSize(); err == nil { - children = append(children, &tree.TreeNode{Label: "DB Size", Value: branding.FormatBytes(size), Expanded: true}) - } - } - if len(children) == 0 { - return nil - } - return &tree.TreeNode{Label: "Configuration", Children: children, Expanded: true} -} - -func (v *InfoView) buildGitSection(info *branding.SystemInfo) *tree.TreeNode { - children := []*tree.TreeNode{ - {Label: "Branch", Value: info.GitBranch, Expanded: true}, - {Label: "Commit", Value: info.GitCommit, Expanded: true}, - {Label: "Status", Value: info.GitStatus, Expanded: true}, - } - if info.GitRemote != "" { - children = append(children, &tree.TreeNode{Label: "Remote", Value: info.GitRemote, Expanded: true}) - } - return &tree.TreeNode{Label: "Git Repository", Children: children, Expanded: true} -} - -// ToJSON returns the view's data for JSON output mode. -// Returns a map with all collected system information fields. -// If no system info was provided to the view, returns a map with a "error" key. -func (v *InfoView) ToJSON() any { - if v.systemInfo == nil { - return map[string]any{ - "error": "no system information available", - } - } - - info := v.systemInfo - result := map[string]any{ - "cli": map[string]any{ - "version": info.CLIVersion, - "build_date": info.CLIBuildDate, - "commit": info.CLICommit, - }, - "go_runtime": map[string]any{ - "version": info.GoVersion, - "os": info.GoOS, - "arch": info.GoArch, - "num_cpu": info.NumCPU, - }, - "hardware": map[string]any{ - "cpu_model": info.CPUModel, - "memory_total": info.MemoryTotal, - "memory_free": info.MemoryFree, - }, - "system": map[string]any{ - "hostname": info.Hostname, - "username": info.Username, - "home_dir": info.HomeDir, - "working_dir": info.WorkingDir, - }, - "configuration": map[string]any{ - "config_dir": info.ConfigDir, - "state_db_path": info.StateDBPath, - }, - } - - if info.IsGitRepo { - result["git"] = map[string]any{ - "branch": info.GitBranch, - "commit": info.GitCommit, - "remote": info.GitRemote, - "status": info.GitStatus, - "dirty": info.GitDirty, - } - } - - return result -} diff --git a/pkg/ui/views/infoview_test.go b/pkg/ui/views/infoview_test.go deleted file mode 100644 index fcc7eb4..0000000 --- a/pkg/ui/views/infoview_test.go +++ /dev/null @@ -1,730 +0,0 @@ -package views - -import ( - "encoding/json" - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" - - "github.com/arc-framework/arc-cli/internal/branding" - "github.com/arc-framework/arc-cli/pkg/ui/components/tree" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// createInfoTestProfile creates a test profile for InfoView tests. -func createInfoTestProfile() *profiles.Profile { - return &profiles.Profile{ - ID: "test-profile", - Name: "Test Profile", - Description: "Test profile for InfoView testing", - Logo: "TEST\nLOGO", - PrimaryColor: "#00ADD8", - TierNames: []string{"Starter", "Pro", "Ultra"}, - } -} - -// createInfoTestTheme creates a test theme for InfoView tests. -func createInfoTestTheme() *themes.Theme { - return &themes.Theme{ - Name: "test-theme", - Colors: themes.ColorSet{ - Primary: "#00ADD8", - Secondary: "#5DC9E2", - Background: "#0D1117", - Foreground: "#E6EDF3", - Muted: "#7D7D7D", - Border: "#30363D", - Success: "#00FF00", - Warning: "#FFFF00", - Error: "#FF0000", - Info: "#00FFFF", - }, - } -} - -// createMockSystemInfo creates a mock SystemInfo for testing. -func createMockSystemInfo() *branding.SystemInfo { - return &branding.SystemInfo{ - CLIVersion: "1.0.0", - CLIBuildDate: "2024-01-01", - CLICommit: "abc123", - GoVersion: "go1.24.0", - GoOS: "darwin", - GoArch: "arm64", - NumCPU: 10, - CPUModel: "Apple M1 Pro", - MemoryTotal: 34359738368, // 32 GB - MemoryFree: 17179869184, // 16 GB - Hostname: "testhost", - Username: "testuser", - HomeDir: "/Users/testuser", - WorkingDir: "/Users/testuser/project", - ConfigDir: "/Users/testuser/.arc/config", - StateDBPath: "/Users/testuser/.arc/state.db", - IsGitRepo: true, - GitBranch: "main", - GitCommit: "def456", - GitRemote: "git@github.com:test/repo.git", - GitStatus: "clean", - GitDirty: false, - } -} - -// createMinimalSystemInfo creates a minimal SystemInfo with only required fields. -func createMinimalSystemInfo() *branding.SystemInfo { - return &branding.SystemInfo{ - CLIVersion: "1.0.0", - CLIBuildDate: "2024-01-01", - CLICommit: "", - GoVersion: "go1.24.0", - GoOS: "linux", - GoArch: "amd64", - NumCPU: 4, - IsGitRepo: false, - } -} - -func TestInfoView_Initialization(t *testing.T) { - view := NewInfoView(nil) - - if view == nil { - t.Fatal("NewInfoView returned nil") - } - - if view.width != 80 { - t.Errorf("expected default width 80, got %d", view.width) - } - - if view.height != 40 { - t.Errorf("expected default height 40, got %d", view.height) - } - - if view.Name() != "info" { - t.Errorf("expected name 'info', got '%s'", view.Name()) - } -} - -func TestInfoView_Init(t *testing.T) { - view := NewInfoView(nil) - - cmd := view.Init() - if cmd != nil { - t.Error("Init() should return nil for static view") - } -} - -func TestInfoView_OnEnter_WithSystemInfo(t *testing.T) { - view := NewInfoView(nil) - - mockInfo := createMockSystemInfo() - profile := createInfoTestProfile() - theme := createInfoTestTheme() - - ctx := engine.NewViewContext(profile, theme, 100, 50, map[string]interface{}{ - "systemInfo": mockInfo, - }) - - cmd := view.OnEnter(ctx) - if cmd != nil { - t.Error("OnEnter should return nil") - } - - // Verify components were initialized - if view.hero == nil { - t.Error("hero component not initialized") - } - - if view.tree == nil { - t.Error("tree component not initialized") - } - - if view.statusBar == nil { - t.Error("statusBar component not initialized") - } - - if view.systemInfo == nil { - t.Error("systemInfo not extracted from context") - } - - if view.systemInfo != mockInfo { - t.Error("systemInfo does not match provided data") - } - - // Verify dimensions were updated - if view.width != 100 { - t.Errorf("expected width 100, got %d", view.width) - } - - if view.height != 50 { - t.Errorf("expected height 50, got %d", view.height) - } -} - -func TestInfoView_OnEnter_NoSystemInfo(t *testing.T) { - view := NewInfoView(nil) - profile := createInfoTestProfile() - theme := createInfoTestTheme() - - ctx := engine.NewViewContext(profile, theme, 100, 50, map[string]interface{}{}) - - cmd := view.OnEnter(ctx) - if cmd != nil { - t.Error("OnEnter should return nil") - } - - // Components should still be initialized - if view.hero == nil { - t.Error("hero component not initialized") - } - - if view.tree == nil { - t.Error("tree component not initialized") - } - - if view.statusBar == nil { - t.Error("statusBar component not initialized") - } - - // systemInfo should be nil - if view.systemInfo != nil { - t.Error("systemInfo should be nil when not provided") - } - - // Tree should still render with a default message - treeRoot := view.tree.GetRoot() - if treeRoot == nil { - t.Fatal("tree root should not be nil") - } - - if !strings.Contains(treeRoot.Value, "no data available") { - t.Errorf("expected 'no data available' message, got '%s'", treeRoot.Value) - } -} - -func TestInfoView_OnExit(t *testing.T) { - view := NewInfoView(nil) - - cmd := view.OnExit() - if cmd != nil { - t.Error("OnExit should return nil") - } -} - -func TestInfoView_Update_WindowResize(t *testing.T) { - view := NewInfoView(nil) - - profile := createInfoTestProfile() - theme := createInfoTestTheme() - ctx := engine.NewViewContext(profile, theme, 80, 40, map[string]interface{}{ - "systemInfo": createMockSystemInfo(), - }) - view.OnEnter(ctx) - - // Send window resize message - msg := tea.WindowSizeMsg{Width: 120, Height: 60} - updatedModel, cmd := view.Update(msg) - - if cmd != nil { - t.Error("Update should return nil cmd for window resize") - } - - updatedView := updatedModel.(*InfoView) - if updatedView.width != 120 { - t.Errorf("expected width 120, got %d", updatedView.width) - } - - if updatedView.height != 60 { - t.Errorf("expected height 60, got %d", updatedView.height) - } -} - -func TestInfoView_Update_KeyboardNavigation(t *testing.T) { - view := NewInfoView(nil) - - profile := createInfoTestProfile() - theme := createInfoTestTheme() - ctx := engine.NewViewContext(profile, theme, 80, 40, map[string]interface{}{ - "systemInfo": createMockSystemInfo(), - }) - view.OnEnter(ctx) - - tests := []struct { - name string - key string - expectsQuit bool - }{ - {"quit with q", "q", true}, - {"quit with ctrl+c", "ctrl+c", true}, - {"navigate down with j", "j", false}, - {"navigate down with arrow", "down", false}, - {"navigate up with k", "k", false}, - {"navigate up with arrow", "up", false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)} - if tt.key == "ctrl+c" { - msg = tea.KeyMsg{Type: tea.KeyCtrlC} - } else if tt.key == "down" { - msg = tea.KeyMsg{Type: tea.KeyDown} - } else if tt.key == "up" { - msg = tea.KeyMsg{Type: tea.KeyUp} - } - - _, cmd := view.Update(msg) - - if tt.expectsQuit { - if cmd == nil { - t.Error("expected quit command, got nil") - } - } else { - if cmd != nil { - t.Error("expected nil command for navigation keys") - } - } - }) - } -} - -func TestInfoView_View_Rendering(t *testing.T) { - view := NewInfoView(nil) - - // Test rendering before initialization - output := view.View() - if output != "" { - t.Error("View() should return empty string before initialization") - } - - profile := createInfoTestProfile() - theme := createInfoTestTheme() - ctx := engine.NewViewContext(profile, theme, 80, 40, map[string]interface{}{ - "systemInfo": createMockSystemInfo(), - }) - view.OnEnter(ctx) - - // Test rendering after initialization - output = view.View() - if output == "" { - t.Error("View() should return non-empty string after initialization") - } - - // Verify output contains expected components - // Note: We can't test exact output due to ANSI styling, but we can check structure - if !strings.Contains(output, "System Information") { - t.Error("output should contain 'System Information' tree root") - } -} - -func TestInfoView_View_EmptyDimensions(t *testing.T) { - view := NewInfoView(nil) - - profile := createInfoTestProfile() - theme := createInfoTestTheme() - ctx := engine.NewViewContext(profile, theme, 80, 40, map[string]interface{}{ - "systemInfo": createMockSystemInfo(), - }) - view.OnEnter(ctx) - - // Set dimensions to 0 - view.width = 0 - view.height = 0 - - output := view.View() - if output != "" { - t.Error("View() should return empty string when dimensions are 0") - } -} - -func TestInfoView_Keybindings(t *testing.T) { - view := NewInfoView(nil) - - keybindings := view.Keybindings() - - if len(keybindings) == 0 { - t.Fatal("expected keybindings, got empty slice") - } - - // Verify expected keybindings exist - expectedKeys := map[string]bool{ - "↑/↓": false, - "q": false, - } - - for _, kb := range keybindings { - if _, exists := expectedKeys[kb.Key]; exists { - expectedKeys[kb.Key] = true - } - } - - for key, found := range expectedKeys { - if !found { - t.Errorf("expected keybinding for '%s' not found", key) - } - } -} - -func TestInfoView_TreeStructure_Complete(t *testing.T) { - view := NewInfoView(nil) - - mockInfo := createMockSystemInfo() - profile := createInfoTestProfile() - theme := createInfoTestTheme() - - ctx := engine.NewViewContext(profile, theme, 100, 50, map[string]interface{}{ - "systemInfo": mockInfo, - }) - - view.OnEnter(ctx) - - // Get tree root - treeRoot := view.tree.GetRoot() - if treeRoot == nil { - t.Fatal("tree root is nil") - } - - if treeRoot.Label != "System Information" { - t.Errorf("expected root label 'System Information', got '%s'", treeRoot.Label) - } - - // Verify expected sections exist - expectedSections := []string{ - "CLI", - "Go Runtime", - "Hardware", - "System", - "Configuration", - "Git Repository", - } - - if len(treeRoot.Children) < len(expectedSections) { - t.Errorf("expected at least %d sections, got %d", len(expectedSections), len(treeRoot.Children)) - } - - foundSections := make(map[string]bool) - for _, child := range treeRoot.Children { - foundSections[child.Label] = true - } - - for _, section := range expectedSections { - if !foundSections[section] { - t.Errorf("expected section '%s' not found in tree", section) - } - } - - // Verify CLI section structure - cliSection := findTreeNodeByLabel(treeRoot.Children, "CLI") - if cliSection == nil { - t.Fatal("CLI section not found") - } - - expectedCLIFields := []string{"Version", "Build Date", "Commit"} - for _, field := range expectedCLIFields { - if findTreeNodeByLabel(cliSection.Children, field) == nil { - t.Errorf("CLI section missing field '%s'", field) - } - } - - // Verify Go Runtime section structure - goSection := findTreeNodeByLabel(treeRoot.Children, "Go Runtime") - if goSection == nil { - t.Fatal("Go Runtime section not found") - } - - expectedGoFields := []string{"Version", "OS/Arch"} - for _, field := range expectedGoFields { - if findTreeNodeByLabel(goSection.Children, field) == nil { - t.Errorf("Go Runtime section missing field '%s'", field) - } - } - - // Verify Hardware section structure - hardwareSection := findTreeNodeByLabel(treeRoot.Children, "Hardware") - if hardwareSection == nil { - t.Fatal("Hardware section not found") - } - - expectedHardwareFields := []string{"CPU", "Cores", "Memory"} - for _, field := range expectedHardwareFields { - if findTreeNodeByLabel(hardwareSection.Children, field) == nil { - t.Errorf("Hardware section missing field '%s'", field) - } - } - - // Verify Git Repository section exists (since IsGitRepo = true) - gitSection := findTreeNodeByLabel(treeRoot.Children, "Git Repository") - if gitSection == nil { - t.Fatal("Git Repository section not found when IsGitRepo=true") - } - - expectedGitFields := []string{"Branch", "Commit", "Status", "Remote"} - for _, field := range expectedGitFields { - if findTreeNodeByLabel(gitSection.Children, field) == nil { - t.Errorf("Git Repository section missing field '%s'", field) - } - } -} - -func TestInfoView_TreeStructure_Minimal(t *testing.T) { - view := NewInfoView(nil) - - minimalInfo := createMinimalSystemInfo() - profile := createInfoTestProfile() - theme := createInfoTestTheme() - - ctx := engine.NewViewContext(profile, theme, 100, 50, map[string]interface{}{ - "systemInfo": minimalInfo, - }) - - view.OnEnter(ctx) - - treeRoot := view.tree.GetRoot() - if treeRoot == nil { - t.Fatal("tree root is nil") - } - - // Verify Git Repository section does NOT exist (IsGitRepo = false) - gitSection := findTreeNodeByLabel(treeRoot.Children, "Git Repository") - if gitSection != nil { - t.Error("Git Repository section should not exist when IsGitRepo=false") - } - - // Verify CLI section still exists with minimal data - cliSection := findTreeNodeByLabel(treeRoot.Children, "CLI") - if cliSection == nil { - t.Fatal("CLI section not found") - } - - // Commit should not be present when empty - commitNode := findTreeNodeByLabel(cliSection.Children, "Commit") - if commitNode != nil { - t.Error("Commit field should not be present when CLICommit is empty") - } -} - -func TestInfoView_TreeStructure_NoData(t *testing.T) { - view := NewInfoView(nil) - - profile := createInfoTestProfile() - theme := createInfoTestTheme() - ctx := engine.NewViewContext(profile, theme, 100, 50, map[string]interface{}{}) - - view.OnEnter(ctx) - - treeRoot := view.tree.GetRoot() - if treeRoot == nil { - t.Fatal("tree root is nil") - } - - if treeRoot.Label != "System Information" { - t.Errorf("expected root label 'System Information', got '%s'", treeRoot.Label) - } - - if !strings.Contains(treeRoot.Value, "no data available") { - t.Errorf("expected 'no data available' message, got '%s'", treeRoot.Value) - } - - if len(treeRoot.Children) != 0 { - t.Errorf("expected no children when no data available, got %d", len(treeRoot.Children)) - } -} - -func TestInfoView_BuildSystemInfoTree_DBSize(t *testing.T) { - view := NewInfoView(nil) - - mockInfo := createMockSystemInfo() - profile := createInfoTestProfile() - theme := createInfoTestTheme() - - ctx := engine.NewViewContext(profile, theme, 100, 50, map[string]interface{}{ - "systemInfo": mockInfo, - }) - - view.OnEnter(ctx) - - treeRoot := view.tree.GetRoot() - configSection := findTreeNodeByLabel(treeRoot.Children, "Configuration") - - if configSection == nil { - t.Fatal("Configuration section not found") - } - - // DB Size field may or may not exist depending on whether the file exists - // We just verify that if StateDBPath is set, the section includes it - stateDBNode := findTreeNodeByLabel(configSection.Children, "State DB") - if stateDBNode == nil { - t.Error("State DB field should exist when StateDBPath is set") - } -} - -func TestInfoView_EngineView_Interface(t *testing.T) { - // Verify InfoView implements engine.View interface - view := NewInfoView(nil) - var _ engine.View = view -} - -func TestInfoView_BubbleTeaModel_Interface(t *testing.T) { - // Verify InfoView implements tea.Model interface - view := NewInfoView(nil) - var _ tea.Model = view -} - -// Helper function to find a tree node by label -func findTreeNodeByLabel(nodes []*tree.TreeNode, label string) *tree.TreeNode { - for _, node := range nodes { - if node.Label == label { - return node - } - } - return nil -} - -// T243: JSON marshaling tests for InfoView.ToJSON -func TestInfoView_ToJSON(t *testing.T) { - view := NewInfoView(nil) - - mockInfo := createMockSystemInfo() - profile := createInfoTestProfile() - theme := createInfoTestTheme() - - ctx := engine.NewViewContext(profile, theme, 100, 50, map[string]interface{}{ - "systemInfo": mockInfo, - }) - view.OnEnter(ctx) - - t.Run("implements JSONExporter interface", func(t *testing.T) { - var _ engine.JSONExporter = view - }) - - t.Run("returns JSON-marshallable data", func(t *testing.T) { - raw := view.ToJSON() - data, err := json.Marshal(raw) - if err != nil { - t.Fatalf("json.Marshal failed: %v", err) - } - if len(data) == 0 { - t.Error("expected non-empty JSON output") - } - }) - - t.Run("contains expected top-level sections", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - if err != nil { - t.Fatalf("json.Marshal failed: %v", err) - } - var result map[string]any - if err := json.Unmarshal(data, &result); err != nil { - t.Fatalf("json.Unmarshal failed: %v", err) - } - - expectedKeys := []string{"cli", "go_runtime", "hardware", "system", "configuration"} - for _, key := range expectedKeys { - if _, ok := result[key]; !ok { - t.Errorf("expected key %q not found in ToJSON output", key) - } - } - }) - - t.Run("cli section contains version info", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - if err != nil { - t.Fatalf("json.Marshal failed: %v", err) - } - var result map[string]any - if err := json.Unmarshal(data, &result); err != nil { - t.Fatalf("json.Unmarshal failed: %v", err) - } - - cli := result["cli"].(map[string]any) - if cli["version"] != "1.0.0" { - t.Errorf("expected cli.version '1.0.0', got %v", cli["version"]) - } - if cli["build_date"] != "2024-01-01" { - t.Errorf("expected cli.build_date '2024-01-01', got %v", cli["build_date"]) - } - if cli["commit"] != "abc123" { - t.Errorf("expected cli.commit 'abc123', got %v", cli["commit"]) - } - }) - - t.Run("go_runtime section contains runtime info", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - if err != nil { - t.Fatalf("json.Marshal failed: %v", err) - } - var result map[string]any - if err := json.Unmarshal(data, &result); err != nil { - t.Fatalf("json.Unmarshal failed: %v", err) - } - - goRuntime := result["go_runtime"].(map[string]any) - if goRuntime["os"] != "darwin" { - t.Errorf("expected go_runtime.os 'darwin', got %v", goRuntime["os"]) - } - if goRuntime["arch"] != "arm64" { - t.Errorf("expected go_runtime.arch 'arm64', got %v", goRuntime["arch"]) - } - }) - - t.Run("includes git section when IsGitRepo is true", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - if err != nil { - t.Fatalf("json.Marshal failed: %v", err) - } - var result map[string]any - if err := json.Unmarshal(data, &result); err != nil { - t.Fatalf("json.Unmarshal failed: %v", err) - } - - if _, ok := result["git"]; !ok { - t.Error("expected 'git' key when IsGitRepo is true") - } - git := result["git"].(map[string]any) - if git["branch"] != "main" { - t.Errorf("expected git.branch 'main', got %v", git["branch"]) - } - }) - - t.Run("omits git section when IsGitRepo is false", func(t *testing.T) { - noGitView := NewInfoView(nil) - minInfo := createMinimalSystemInfo() // IsGitRepo = false - noGitCtx := engine.NewViewContext(profile, theme, 80, 40, map[string]interface{}{ - "systemInfo": minInfo, - }) - noGitView.OnEnter(noGitCtx) - - data, err := json.Marshal(noGitView.ToJSON()) - if err != nil { - t.Fatalf("json.Marshal failed: %v", err) - } - var result map[string]any - if err := json.Unmarshal(data, &result); err != nil { - t.Fatalf("json.Unmarshal failed: %v", err) - } - - if _, ok := result["git"]; ok { - t.Error("expected no 'git' key when IsGitRepo is false") - } - }) - - t.Run("returns error map when systemInfo is nil", func(t *testing.T) { - nilView := NewInfoView(nil) - // Do not call OnEnter — systemInfo stays nil - - data, err := json.Marshal(nilView.ToJSON()) - if err != nil { - t.Fatalf("json.Marshal failed: %v", err) - } - var result map[string]any - if err := json.Unmarshal(data, &result); err != nil { - t.Fatalf("json.Unmarshal failed: %v", err) - } - - if _, ok := result["error"]; !ok { - t.Error("expected 'error' key when systemInfo is nil") - } - }) -} diff --git a/pkg/ui/views/initwizardmodel.go b/pkg/ui/views/initwizardmodel.go deleted file mode 100644 index dfbd223..0000000 --- a/pkg/ui/views/initwizardmodel.go +++ /dev/null @@ -1,160 +0,0 @@ -package views - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/huh" - - "github.com/arc-framework/arc-cli/pkg/ui/components/wizard" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -// wizardInitModel wraps wizard.Wizard to implement initWizardModel. -// It provides the multi-step init flow using huh forms and the wizard component. -type wizardInitModel struct { - w *wizard.Wizard - selectedProfile string - installPath string -} - -// newInitWizardModelFromContext constructs the wizard model from a ViewContext. -// It reads optional pre-set values from ctx.Args. -func newInitWizardModelFromContext(ctx *engine.ViewContext) initWizardModel { - theme := ctx.Theme - - // --- Step 1: Welcome + Profile Selection --- - // Load available profiles for the select options - var profileOptions []huh.Option[string] - repo, err := profiles.NewRepository() - if err == nil { - all, loadErr := repo.ListProfiles() - if loadErr == nil { - for _, p := range all { - profileOptions = append(profileOptions, huh.NewOption(p.Name+" ("+p.ID+")", p.ID)) - } - } - } - if len(profileOptions) == 0 { - profileOptions = []huh.Option[string]{ - huh.NewOption("Enterprise (default)", "enterprise"), - } - } - - var selectedProfile string - if v, ok := ctx.Args["profile"].(string); ok && v != "" { - selectedProfile = v - } - if selectedProfile == "" { - selectedProfile = "enterprise" - } - - step1Form := huh.NewForm( - huh.NewGroup( - huh.NewNote(). - Title("Welcome to A.R.C."). - Description("This wizard will guide you through setting up your A.R.C. environment."), - huh.NewSelect[string](). - Title("Choose Your Profile"). - Description("Select the UI profile that matches your team's style."). - Options(profileOptions...). - Value(&selectedProfile), - ), - ) - - // --- Step 2: Stack / Tier Selection --- - var selectedTier string - tierOptions := []huh.Option[string]{ - huh.NewOption("Super Saiyan (standard developer stack)", "super-saiyan"), - huh.NewOption("Super Saiyan Blue (advanced) — coming soon", "super-saiyan-blue"), - huh.NewOption("Ultra Instinct (god-mode) — coming soon", "ultra-instinct"), - } - - step2Form := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("Choose Your Stack Tier"). - Description("Each tier includes a different set of services."). - Options(tierOptions...). - Value(&selectedTier), - ), - ) - - // --- Step 3: Directory Configuration --- - installPath := "./" - if v, ok := ctx.Args["path"].(string); ok && v != "" { - installPath = v - } - - step3Form := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Installation Path"). - Description("Enter the directory where you want to initialize A.R.C."). - Placeholder("./"). - Value(&installPath), - ), - ) - - // --- Step 4: Confirmation --- - var confirmed bool - step4Form := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Ready to Install"). - Description("A.R.C. will be initialized in the selected directory."). - Value(&confirmed), - ), - ) - - steps := []wizard.WizardStep{ - { - Title: "Welcome", - Description: "Set up your A.R.C. environment", - Form: step1Form, - }, - { - Title: "Stack Selection", - Description: "Choose your platform complexity level", - Form: step2Form, - }, - { - Title: "Directory Configuration", - Description: "Specify where to initialize", - Form: step3Form, - }, - { - Title: "Confirmation", - Description: "Review and confirm your selections", - Form: step4Form, - }, - } - - w := wizard.NewWizard(steps, theme) - return &wizardInitModel{ - w: w, - selectedProfile: selectedProfile, - installPath: installPath, - } -} - -// Init implements tea.Model. -func (m *wizardInitModel) Init() tea.Cmd { - return m.w.Init() -} - -// Update implements tea.Model. -func (m *wizardInitModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - updated, cmd := m.w.Update(msg) - m.w = updated.(*wizard.Wizard) - return m, cmd -} - -// View implements tea.Model. -func (m *wizardInitModel) View() string { - return m.w.View() -} - -// IsComplete returns true when all wizard steps have been completed. -func (m *wizardInitModel) IsComplete() bool { - return m.w.IsComplete() -} diff --git a/pkg/ui/views/initwizardview.go b/pkg/ui/views/initwizardview.go deleted file mode 100644 index 36745bf..0000000 --- a/pkg/ui/views/initwizardview.go +++ /dev/null @@ -1,130 +0,0 @@ -package views - -import ( - tea "github.com/charmbracelet/bubbletea" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/engine" -) - -const viewInitWizard = "init-wizard" - -// InitWizardView wraps the multi-step init wizard as an engine.View. -// -// The view delegates all rendering and input handling to the embedded wizard -// model, which provides profile selection, stack selection, path input, -// installation progress, and completion screens. -// -// Steps (T354-T358): -// 1. Welcome / Profile Selection -// 2. Stack (Tier) Selection -// 3. Directory Configuration -// 4. Installation progress -// 5. Confirmation / Completion -// -// Design: 017-ui-engine Phase 9 (T354-T361) -type InitWizardView struct { - factory ui.ComponentFactory - model initWizardModel - width int - height int -} - -// initWizardModel defines the interface the wizard model must satisfy. -// This allows the view to delegate to the existing init wizard implementation -// without coupling directly to the concrete type (testability + flexibility). -type initWizardModel interface { - tea.Model - // IsComplete returns true when the wizard has finished all steps. - IsComplete() bool -} - -// NewInitWizardView creates a new InitWizardView with the given ComponentFactory. -func NewInitWizardView(factory ui.ComponentFactory) *InitWizardView { - return &InitWizardView{ - factory: factory, - width: 80, - height: 24, - } -} - -// Init initializes the view (Bubble Tea lifecycle). -func (v *InitWizardView) Init() tea.Cmd { - if v.model != nil { - return v.model.Init() - } - return nil -} - -// Update handles messages and delegates to the embedded wizard model. -func (v *InitWizardView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if msg, ok := msg.(tea.WindowSizeMsg); ok { - v.width = msg.Width - v.height = msg.Height - } - - if v.model != nil { - updatedModel, cmd := v.model.Update(msg) - v.model = updatedModel.(initWizardModel) - return v, cmd - } - return v, nil -} - -// View delegates rendering to the embedded wizard model. -func (v *InitWizardView) View() string { - if v.model == nil { - return "" - } - return v.model.View() -} - -// OnEnter is called when the view becomes active. -// Creates the wizard model with the given context dimensions. -func (v *InitWizardView) OnEnter(ctx *engine.ViewContext) tea.Cmd { - v.width = ctx.Width - v.height = ctx.Height - - // Create new init wizard model via the package-level constructor - // (defined in pkg/cli/init.go but accessible via the views package bridge) - v.model = newInitWizardModelFromContext(ctx) - return v.model.Init() -} - -// OnExit is called when the view is replaced. -func (v *InitWizardView) OnExit() tea.Cmd { - return nil -} - -// Name returns the unique identifier for this view. -func (v *InitWizardView) Name() string { - return viewInitWizard -} - -// Keybindings returns the keyboard shortcuts for this view. -func (v *InitWizardView) Keybindings() []engine.KeyBinding { - return []engine.KeyBinding{ - {Key: "↑/↓/←/→", Description: "navigate"}, - {Key: "enter", Description: "select/confirm"}, - {Key: "esc", Description: "back"}, - {Key: "q", Description: "quit"}, - } -} - -// ToJSON returns a representation of the wizard state for JSON output mode. -// When the wizard is not complete, returns an in-progress status. -func (v *InitWizardView) ToJSON() any { - if v.model == nil { - return map[string]any{ - "status": "not started", - } - } - if v.model.IsComplete() { - return map[string]any{ - "status": "complete", - } - } - return map[string]any{ - "status": "in progress", - } -} diff --git a/pkg/ui/views/initwizardview_test.go b/pkg/ui/views/initwizardview_test.go deleted file mode 100644 index b2c897e..0000000 --- a/pkg/ui/views/initwizardview_test.go +++ /dev/null @@ -1,205 +0,0 @@ -package views - -import ( - "encoding/json" - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -func createInitWizardFactory() ui.ComponentFactory { - profileCtx := profiles.GetDefaultProfileContext() - return ui.NewComponentFactory(profileCtx, components.BorderTierNone) -} - -func createInitWizardContext(args map[string]any) *engine.ViewContext { - profileCtx := profiles.GetDefaultProfileContext() - if args == nil { - args = make(map[string]any) - } - return engine.NewViewContext(profileCtx.Profile(), profileCtx.Theme(), 80, 40, args) -} - -// T354: TestNewInitWizardView validates construction. -func TestNewInitWizardView(t *testing.T) { - factory := createInitWizardFactory() - view := NewInitWizardView(factory) - - require.NotNil(t, view) - assert.Equal(t, 80, view.width) - assert.Equal(t, 24, view.height) - assert.NotNil(t, view.factory) - assert.Nil(t, view.model, "model is nil before OnEnter") -} - -// T354: TestInitWizardView_Name validates the name constant. -func TestInitWizardView_Name(t *testing.T) { - factory := createInitWizardFactory() - view := NewInitWizardView(factory) - - assert.Equal(t, viewInitWizard, view.Name()) -} - -// T354: TestInitWizardView_Init validates that Init returns nil before OnEnter. -func TestInitWizardView_Init(t *testing.T) { - factory := createInitWizardFactory() - view := NewInitWizardView(factory) - - cmd := view.Init() - assert.Nil(t, cmd, "Init without model should return nil") -} - -// T355-T358: TestInitWizardView_OnEnter validates that wizard steps are created. -func TestInitWizardView_OnEnter(t *testing.T) { - factory := createInitWizardFactory() - view := NewInitWizardView(factory) - - ctx := createInitWizardContext(nil) - cmd := view.OnEnter(ctx) - - assert.NotNil(t, view.model, "model must be set after OnEnter") - assert.Equal(t, 80, view.width) - assert.Equal(t, 40, view.height) - // Init command may be non-nil from form initialization - _ = cmd -} - -// T355: TestInitWizardView_View_BeforeOnEnter validates empty output before initialization. -func TestInitWizardView_View_BeforeOnEnter(t *testing.T) { - factory := createInitWizardFactory() - view := NewInitWizardView(factory) - - output := view.View() - assert.Empty(t, output) -} - -// T355: TestInitWizardView_View_AfterOnEnter validates non-empty output. -func TestInitWizardView_View_AfterOnEnter(t *testing.T) { - factory := createInitWizardFactory() - view := NewInitWizardView(factory) - - ctx := createInitWizardContext(nil) - view.OnEnter(ctx) - - output := view.View() - assert.NotEmpty(t, output) -} - -// T356: TestInitWizardView_ProfileSelectionArgs validates profile arg pass-through. -func TestInitWizardView_ProfileSelectionArgs(t *testing.T) { - factory := createInitWizardFactory() - view := NewInitWizardView(factory) - - ctx := createInitWizardContext(map[string]any{ - "profile": "saiyan", - "path": "/tmp/test-arc", - }) - view.OnEnter(ctx) - - assert.NotNil(t, view.model) - output := view.View() - assert.NotEmpty(t, output) -} - -// T357: TestInitWizardView_WindowResize validates dimension updates. -func TestInitWizardView_WindowResize(t *testing.T) { - factory := createInitWizardFactory() - view := NewInitWizardView(factory) - - ctx := createInitWizardContext(nil) - view.OnEnter(ctx) - - msg := tea.WindowSizeMsg{Width: 120, Height: 50} - updatedModel, _ := view.Update(msg) - - updated := updatedModel.(*InitWizardView) - assert.Equal(t, 120, updated.width) - assert.Equal(t, 50, updated.height) -} - -// T361: TestInitWizardView_QuitKey validates cancellation via ctrl+c. -func TestInitWizardView_QuitKey(t *testing.T) { - factory := createInitWizardFactory() - view := NewInitWizardView(factory) - - ctx := createInitWizardContext(nil) - view.OnEnter(ctx) - - // ctrl+c should propagate to wizard model and produce a quit command - _, cmd := view.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) - // The command may or may not be quit depending on wizard state; we just verify no panic - _ = cmd -} - -// T354: TestInitWizardView_OnExit validates cleanup. -func TestInitWizardView_OnExit(t *testing.T) { - factory := createInitWizardFactory() - view := NewInitWizardView(factory) - - cmd := view.OnExit() - assert.Nil(t, cmd) -} - -// T354: TestInitWizardView_Keybindings validates keybindings are present. -func TestInitWizardView_Keybindings(t *testing.T) { - factory := createInitWizardFactory() - view := NewInitWizardView(factory) - - kbs := view.Keybindings() - assert.NotEmpty(t, kbs) - - keys := map[string]bool{} - for _, kb := range kbs { - keys[kb.Key] = true - } - assert.True(t, keys["enter"]) - assert.True(t, keys["esc"]) - assert.True(t, keys["q"]) -} - -// T354: TestInitWizardView_ToJSON validates JSON output. -func TestInitWizardView_ToJSON(t *testing.T) { - factory := createInitWizardFactory() - view := NewInitWizardView(factory) - - t.Run("before OnEnter", func(t *testing.T) { - data := view.ToJSON() - require.NotNil(t, data) - raw, err := json.Marshal(data) - require.NoError(t, err) - var result map[string]any - require.NoError(t, json.Unmarshal(raw, &result)) - assert.Contains(t, result, "status") - assert.Equal(t, "not started", result["status"]) - }) - - t.Run("after OnEnter", func(t *testing.T) { - ctx := createInitWizardContext(nil) - view.OnEnter(ctx) - - data := view.ToJSON() - require.NotNil(t, data) - raw, err := json.Marshal(data) - require.NoError(t, err) - var result map[string]any - require.NoError(t, json.Unmarshal(raw, &result)) - assert.Contains(t, result, "status") - }) -} - -// T354: TestInitWizardView_ImplementsInterfaces validates interface compliance. -func TestInitWizardView_ImplementsInterfaces(t *testing.T) { - factory := createInitWizardFactory() - view := NewInitWizardView(factory) - - var _ engine.View = view - var _ tea.Model = view - var _ engine.JSONExporter = view -} diff --git a/pkg/ui/views/portstableview.go b/pkg/ui/views/portstableview.go deleted file mode 100644 index f2dd87f..0000000 --- a/pkg/ui/views/portstableview.go +++ /dev/null @@ -1,349 +0,0 @@ -package views - -// PortsTableView displays all port allocations using a searchable DataTable. -// -// Layout: -// -// ┌────────────────────────────────────────┐ -// │ Search: [filter ports_____________] │ -// ├────────────────────────────────────────┤ -// │ Port │ Service │ Protocol │ Conf │ -// │────────────┼──────────┼──────────┼──── │ -// │ 5432:5432 │ postgres │ tcp │ │ -// │ 6379:6379 │ redis │ tcp │ ⚠ │ -// ├────────────────────────────────────────┤ -// │ ↑/↓: navigate • /: search • q: quit │ -// └────────────────────────────────────────┘ -// -// Design: 017-ui-engine Phase 10 (T380) - -import ( - "fmt" - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/catalog" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components/search" - "github.com/arc-framework/arc-cli/pkg/ui/components/status" - "github.com/arc-framework/arc-cli/pkg/ui/components/table" - "github.com/arc-framework/arc-cli/pkg/ui/engine" -) - -const viewPortsTable = "ports-table" - -// PortsTableView displays port allocations in a searchable table. -type PortsTableView struct { - factory ui.ComponentFactory - searchBar *search.SearchBar - dataTable *table.DataTable - statusBar *status.StatusBar - allRows []table.Row - allocations []catalog.PortAllocation - conflicts []catalog.PortConflict - width int - height int -} - -// NewPortsTableView creates a new PortsTableView with the given ComponentFactory. -func NewPortsTableView(factory ui.ComponentFactory) *PortsTableView { - return &PortsTableView{ - factory: factory, - width: 80, - height: 40, - allRows: []table.Row{}, - } -} - -// Init initializes the PortsTableView (Bubble Tea lifecycle). -// Returns a command to start the search bar cursor blinking. -func (v *PortsTableView) Init() tea.Cmd { - if v.searchBar != nil { - return v.searchBar.Init() - } - return nil -} - -// Update handles messages and updates the PortsTableView state. -func (v *PortsTableView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - return v.handleWindowResize(msg) - case tea.KeyMsg: - return v.handleKeyPress(msg) - } - - return v.updateComponents(msg) -} - -// handleWindowResize processes window resize messages. -// -//nolint:dupl // boilerplate resize logic is structurally identical across list views by design -func (v *PortsTableView) handleWindowResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { - v.width = msg.Width - v.height = msg.Height - - if v.searchBar != nil { - v.searchBar.SetWidth(v.width - 4) - } - if v.dataTable != nil { - v.dataTable.SetWidth(v.width) - // Search bar with border = 3 lines; 2 spacers; 1 status = 6 overhead - tableHeight := v.height - 6 - if tableHeight < 3 { - tableHeight = 3 - } - v.dataTable.SetHeight(tableHeight) - } - return v, nil -} - -// handleKeyPress processes keyboard input for view-level actions. -func (v *PortsTableView) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", keyCtrlC: - return v, tea.Quit - - case "/": - if v.searchBar != nil { - return v, v.searchBar.Focus() - } - return v, nil - - case keyEsc: - if v.searchBar != nil && v.searchBar.Focused() { - var searchModel tea.Model - searchModel, cmd := v.searchBar.Update(msg) - v.searchBar = searchModel.(*search.SearchBar) - return v, cmd - } - return v, nil - } - - return v.updateComponents(msg) -} - -// updateComponents updates child components with the given message. -// -//nolint:dupl // boilerplate update-dispatch is structurally identical across list views by design -func (v *PortsTableView) updateComponents(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - if v.searchBar != nil { - var searchModel tea.Model - searchModel, cmd := v.searchBar.Update(msg) - v.searchBar = searchModel.(*search.SearchBar) - cmds = append(cmds, cmd) - } - - if v.dataTable != nil && (v.searchBar == nil || !v.searchBar.Focused()) { - var tableModel tea.Model - tableModel, cmd := v.dataTable.Update(msg) - v.dataTable = tableModel.(*table.DataTable) - cmds = append(cmds, cmd) - } - - return v, tea.Batch(cmds...) -} - -// View renders the PortsTableView as a string. -func (v *PortsTableView) View() string { - if v.width <= 0 || v.height <= 0 { - return "" - } - - if v.dataTable == nil || v.statusBar == nil { - return "" - } - - var searchContent string - if v.searchBar != nil { - searchContent = v.searchBar.View() - } - - tableContent := v.dataTable.View() - - message := "" - if v.dataTable != nil { - rowCount := v.dataTable.RowCount() - total := len(v.allRows) - if rowCount < total { - message = lipgloss.NewStyle().Render( - strings.Join([]string{ - "Showing", - intToString(rowCount), - "of", - intToString(total), - "ports", - }, " "), - ) - } - } - statusContent := v.statusBar.Render(v.width, v.Keybindings(), message) - - return lipgloss.JoinVertical( - lipgloss.Left, - searchContent, - "", - tableContent, - "", - statusContent, - ) -} - -// OnEnter is called when the PortsTableView becomes active. -// Reads allocations from ctx.Args["allocations"] and conflicts from ctx.Args["conflicts"]. -func (v *PortsTableView) OnEnter(ctx *engine.ViewContext) tea.Cmd { - theme := ctx.Theme - - // Extract allocations from context args - v.allocations = nil - if allocs, ok := ctx.Args["allocations"].([]catalog.PortAllocation); ok { - v.allocations = allocs - } - - // Extract conflicts from context args - v.conflicts = nil - if confs, ok := ctx.Args["conflicts"].([]catalog.PortConflict); ok { - v.conflicts = confs - } - - // Build table rows from allocation data - v.allRows = v.buildRows() - - // Initialize search bar - v.searchBar = search.NewSearchBar(theme, "Filter ports...") - v.searchBar.SetWidth(v.width - 4) - v.searchBar.SetOnChange(func(query string) { - v.filterRows(query) - }) - - // Define columns - columns := []table.Column{ - {Title: "Port", Width: 16}, - {Title: "Service", Width: 16}, - {Title: "Protocol", Width: 10}, - {Title: "Conflict", Width: 10}, - } - - v.dataTable = table.NewDataTable(columns, v.allRows, theme) - - tableHeight := v.height - 5 - if tableHeight < 3 { - tableHeight = 3 - } - v.dataTable.SetWidth(v.width) - v.dataTable.SetHeight(tableHeight) - - // Initialize status bar - v.statusBar = status.NewStatusBar(theme) - - // Update dimensions from context - v.width = ctx.Width - v.height = ctx.Height - - return v.searchBar.Init() -} - -// OnExit is called when the PortsTableView is replaced by another view. -// No cleanup needed for this view. -func (v *PortsTableView) OnExit() tea.Cmd { - return nil -} - -// Name returns the unique identifier for this view. -func (v *PortsTableView) Name() string { - return viewPortsTable -} - -// Keybindings returns the keyboard shortcuts for the PortsTableView. -func (v *PortsTableView) Keybindings() []engine.KeyBinding { - return []engine.KeyBinding{ - {Key: "↑/↓", Description: "navigate"}, - {Key: "/", Description: "search"}, - {Key: "q", Description: "quit"}, - } -} - -// buildRows converts port allocations into table rows. -func (v *PortsTableView) buildRows() []table.Row { - if len(v.allocations) == 0 { - return []table.Row{} - } - - // Build conflict port set for O(1) lookup - conflictPorts := make(map[int]bool, len(v.conflicts)) - for _, c := range v.conflicts { - conflictPorts[c.Port] = true - } - - rows := make([]table.Row, 0, len(v.allocations)) - for _, a := range v.allocations { - portStr := fmt.Sprintf("%d:%d", a.HostPort, a.ContPort) - conflict := "" - if conflictPorts[a.HostPort] { - conflict = "yes" - } - rows = append(rows, table.Row{portStr, a.Service, a.Protocol, conflict}) - } - return rows -} - -// filterRows filters the data table rows based on the search query. -func (v *PortsTableView) filterRows(query string) { - if v.dataTable == nil { - return - } - - if query == "" { - v.dataTable.SetRows(v.allRows) - return - } - - queryLower := strings.ToLower(query) - filtered := []table.Row{} - - for _, row := range v.allRows { - for _, cell := range row { - if strings.Contains(strings.ToLower(cell), queryLower) { - filtered = append(filtered, row) - break - } - } - } - - v.dataTable.SetRows(filtered) -} - -// ToJSON returns the port allocation data for JSON output mode. -func (v *PortsTableView) ToJSON() any { - rows := make([]map[string]any, 0, len(v.allRows)) - for _, row := range v.allRows { - entry := map[string]any{ - "port": "", - "service": "", - "protocol": "", - "conflict": "", - } - if len(row) > 0 { - entry["port"] = row[0] - } - if len(row) > 1 { - entry["service"] = row[1] - } - if len(row) > 2 { - entry["protocol"] = row[2] - } - if len(row) > 3 { - entry["conflict"] = row[3] - } - rows = append(rows, entry) - } - return map[string]any{ - "allocations": rows, - "count": len(v.allRows), - "conflict_count": len(v.conflicts), - } -} diff --git a/pkg/ui/views/profilelistview.go b/pkg/ui/views/profilelistview.go deleted file mode 100644 index 6d6624e..0000000 --- a/pkg/ui/views/profilelistview.go +++ /dev/null @@ -1,300 +0,0 @@ -package views - -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components/search" - "github.com/arc-framework/arc-cli/pkg/ui/components/status" - "github.com/arc-framework/arc-cli/pkg/ui/components/table" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -// ProfileListView displays all available profiles in a searchable DataTable. -// -// Layout: -// -// ┌────────────────────────────────────────────────────┐ -// │ Search: [enterprise___________________________] │ -// ├──────────────┬──────────────┬────────────┬────────┤ -// │ Profile ID │ Name │ Desc │ Theme │ -// │──────────────┼──────────────┼────────────┼────────│ -// │ enterprise │ Enterprise │ Corporate │ cyan │ -// │ jedi │ Jedi │ Star Wars │ nord │ -// │ saiyan │ Saiyan │ DBZ │ fire │ -// ├────────────────────────────────────────────────────┤ -// │ ↑/↓: navigate • /: search • q: quit │ -// └────────────────────────────────────────────────────┘ -// -// Design: 017-ui-engine Phase 6 (T341) -type ProfileListView struct { - factory ui.ComponentFactory - searchBar *search.SearchBar - dataTable *table.DataTable - statusBar *status.StatusBar - allRows []table.Row - allData []profiles.Profile - width int - height int -} - -// NewProfileListView creates a new ProfileListView with the given ComponentFactory. -func NewProfileListView(factory ui.ComponentFactory) *ProfileListView { - return &ProfileListView{ - factory: factory, - width: 80, - height: 40, - allRows: []table.Row{}, - } -} - -// Init initializes the ProfileListView (Bubble Tea lifecycle). -func (v *ProfileListView) Init() tea.Cmd { - if v.searchBar != nil { - return v.searchBar.Init() - } - return nil -} - -// Update handles messages and updates the ProfileListView state. -func (v *ProfileListView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - return v.handleWindowResize(msg) - case tea.KeyMsg: - return v.handleKeyPress(msg) - } - return v.updateComponents(msg) -} - -// handleWindowResize updates component dimensions on terminal resize. -// -//nolint:dupl // boilerplate resize logic is structurally identical across list views by design -func (v *ProfileListView) handleWindowResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { - v.width = msg.Width - v.height = msg.Height - - if v.searchBar != nil { - v.searchBar.SetWidth(v.width - 4) - } - if v.dataTable != nil { - v.dataTable.SetWidth(v.width) - // Search bar renders with RoundedBorder = 3 lines; 2 empty spacers; 1 status bar = 6 total - tableHeight := v.height - 6 - if tableHeight < 3 { - tableHeight = 3 - } - v.dataTable.SetHeight(tableHeight) - } - return v, nil -} - -// handleKeyPress processes keyboard input. -func (v *ProfileListView) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", keyCtrlC: - return v, tea.Quit - - case "/": - if v.searchBar != nil { - return v, v.searchBar.Focus() - } - return v, nil - - case keyEsc: - if v.searchBar != nil && v.searchBar.Focused() { - searchModel, cmd := v.searchBar.Update(msg) - v.searchBar = searchModel.(*search.SearchBar) - return v, cmd - } - return v, nil - } - - return v.updateComponents(msg) -} - -// updateComponents delegates messages to child components. -func (v *ProfileListView) updateComponents(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - if v.searchBar != nil { - searchModel, cmd := v.searchBar.Update(msg) - v.searchBar = searchModel.(*search.SearchBar) - cmds = append(cmds, cmd) - } - - if v.dataTable != nil && (v.searchBar == nil || !v.searchBar.Focused()) { - tableModel, cmd := v.dataTable.Update(msg) - v.dataTable = tableModel.(*table.DataTable) - cmds = append(cmds, cmd) - } - - return v, tea.Batch(cmds...) -} - -// View renders the ProfileListView as a string. -// -//nolint:dupl // list view rendering is structurally identical across views by design -func (v *ProfileListView) View() string { - if v.width <= 0 || v.height <= 0 { - return "" - } - - var searchContent string - if v.searchBar != nil { - searchContent = v.searchBar.View() - } - - var tableContent string - if v.dataTable != nil { - tableContent = v.dataTable.View() - } - - var statusContent string - if v.statusBar != nil { - message := "" - if v.dataTable != nil { - rowCount := v.dataTable.RowCount() - total := len(v.allRows) - if rowCount < total { - message = lipgloss.NewStyle().Render( - strings.Join([]string{ - "Showing", lipgloss.NewStyle().Bold(true).Render(string(rune(rowCount + '0'))), - "of", lipgloss.NewStyle().Bold(true).Render(string(rune(total + '0'))), - "profiles", - }, " "), - ) - } - } - statusContent = v.statusBar.Render(v.width, v.Keybindings(), message) - } - - return lipgloss.JoinVertical( - lipgloss.Left, - searchContent, - "", - tableContent, - "", - statusContent, - ) -} - -// OnEnter is called when the ProfileListView becomes active. -// Loads all profiles from the repository and initializes components. -func (v *ProfileListView) OnEnter(ctx *engine.ViewContext) tea.Cmd { - theme := ctx.Theme - - // Initialize search bar - v.searchBar = search.NewSearchBar(theme, "Search profiles...") - v.searchBar.SetWidth(v.width - 4) - v.searchBar.SetOnChange(func(query string) { - v.filterRows(query) - }) - - // Define columns - columns := []table.Column{ - {Title: "Profile ID", Width: 15}, - {Title: "Name", Width: 20}, - {Title: "Description", Width: 40}, - {Title: "Theme", Width: 15}, - } - - // Load profiles from repository - repo, err := profiles.NewRepository() - if err == nil { - allProfiles, loadErr := repo.LoadAll() - if loadErr == nil { - v.allData = allProfiles - v.allRows = make([]table.Row, len(allProfiles)) - for i := range allProfiles { - p := &allProfiles[i] - desc := p.Description - if len(desc) > 38 { - desc = desc[:35] + "..." - } - v.allRows[i] = table.Row{p.ID, p.Name, desc, p.ThemeID} - } - } - } - - v.dataTable = table.NewDataTable(columns, v.allRows, theme) - - // Search bar with border = 3 lines; 2 spacers; 1 status = 6 overhead - tableHeight := v.height - 6 - if tableHeight < 3 { - tableHeight = 3 - } - v.dataTable.SetWidth(v.width) - v.dataTable.SetHeight(tableHeight) - - v.statusBar = status.NewStatusBar(theme) - - v.width = ctx.Width - v.height = ctx.Height - - return v.searchBar.Init() -} - -// OnExit is called when the view is replaced. -func (v *ProfileListView) OnExit() tea.Cmd { - return nil -} - -// Name returns the unique identifier for this view. -func (v *ProfileListView) Name() string { - return "profile-list" -} - -// Keybindings returns the keyboard shortcuts for this view. -func (v *ProfileListView) Keybindings() []engine.KeyBinding { - return []engine.KeyBinding{ - {Key: "↑/↓", Description: "navigate"}, - {Key: "/", Description: "search"}, - {Key: "q", Description: "quit"}, - } -} - -// filterRows filters displayed rows by the search query. -func (v *ProfileListView) filterRows(query string) { - if v.dataTable == nil { - return - } - if query == "" { - v.dataTable.SetRows(v.allRows) - return - } - queryLower := strings.ToLower(query) - var filtered []table.Row - for _, row := range v.allRows { - for _, cell := range row { - if strings.Contains(strings.ToLower(cell), queryLower) { - filtered = append(filtered, row) - break - } - } - } - v.dataTable.SetRows(filtered) -} - -// ToJSON returns the view's data for JSON output mode. -func (v *ProfileListView) ToJSON() any { - items := make([]map[string]any, 0, len(v.allData)) - for i := range v.allData { - p := &v.allData[i] - items = append(items, map[string]any{ - "id": p.ID, - "name": p.Name, - "description": p.Description, - "theme": p.ThemeID, - "tier_names": p.TierNames, - }) - } - return map[string]any{ - "profiles": items, - "count": len(v.allData), - } -} diff --git a/pkg/ui/views/profilelistview_test.go b/pkg/ui/views/profilelistview_test.go deleted file mode 100644 index fd9f2d7..0000000 --- a/pkg/ui/views/profilelistview_test.go +++ /dev/null @@ -1,216 +0,0 @@ -package views - -import ( - "encoding/json" - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -// createProfileListFactory creates a ComponentFactory for ProfileListView testing. -func createProfileListFactory() ui.ComponentFactory { - profileCtx := profiles.GetDefaultProfileContext() - return ui.NewComponentFactory(profileCtx, components.BorderTierBlock) -} - -// createProfileListViewContext creates a ViewContext for ProfileListView testing. -func createProfileListViewContext() *engine.ViewContext { - profileCtx := profiles.GetDefaultProfileContext() - return engine.NewViewContext( - profileCtx.Profile(), - profileCtx.Theme(), - 80, - 40, - make(map[string]any), - ) -} - -func TestNewProfileListView(t *testing.T) { - factory := createProfileListFactory() - view := NewProfileListView(factory) - - require.NotNil(t, view) - assert.NotNil(t, view.factory) - assert.Equal(t, 80, view.width) - assert.Equal(t, 40, view.height) - assert.Empty(t, view.allRows) -} - -func TestProfileListView_Name(t *testing.T) { - factory := createProfileListFactory() - view := NewProfileListView(factory) - - assert.Equal(t, "profile-list", view.Name()) -} - -func TestProfileListView_Keybindings(t *testing.T) { - factory := createProfileListFactory() - view := NewProfileListView(factory) - - keybindings := view.Keybindings() - require.Len(t, keybindings, 3) - - expectedKeys := map[string]string{ - "↑/↓": "navigate", - "/": "search", - "q": "quit", - } - for _, kb := range keybindings { - desc, exists := expectedKeys[kb.Key] - assert.True(t, exists, "unexpected keybinding: %s", kb.Key) - assert.Equal(t, desc, kb.Description) - } -} - -func TestProfileListView_OnEnter(t *testing.T) { - factory := createProfileListFactory() - view := NewProfileListView(factory) - ctx := createProfileListViewContext() - - cmd := view.OnEnter(ctx) - - // Components should be initialized - assert.NotNil(t, view.searchBar) - assert.NotNil(t, view.dataTable) - assert.NotNil(t, view.statusBar) - - // Profiles should be loaded (embedded profiles exist) - assert.NotEmpty(t, view.allRows) - assert.NotEmpty(t, view.allData) - - // Each row has 4 columns: ID, Name, Description, Theme - for _, row := range view.allRows { - assert.Len(t, row, 4) - assert.NotEmpty(t, row[0], "profile ID should not be empty") - assert.NotEmpty(t, row[1], "profile name should not be empty") - } - - // Init cmd returned (cursor blink) - assert.NotNil(t, cmd) -} - -func TestProfileListView_OnExit(t *testing.T) { - factory := createProfileListFactory() - view := NewProfileListView(factory) - - cmd := view.OnExit() - assert.Nil(t, cmd) -} - -func TestProfileListView_Init(t *testing.T) { - factory := createProfileListFactory() - view := NewProfileListView(factory) - - // Before OnEnter, Init should return nil (no search bar) - cmd := view.Init() - assert.Nil(t, cmd) -} - -func TestProfileListView_Init_AfterOnEnter(t *testing.T) { - factory := createProfileListFactory() - view := NewProfileListView(factory) - ctx := createProfileListViewContext() - _ = view.OnEnter(ctx) - - // After OnEnter, Init should return the search bar init cmd - cmd := view.Init() - assert.NotNil(t, cmd) -} - -func TestProfileListView_Update_QuitKey(t *testing.T) { - factory := createProfileListFactory() - view := NewProfileListView(factory) - ctx := createProfileListViewContext() - _ = view.OnEnter(ctx) - - model, cmd := view.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) - assert.NotNil(t, model) - assert.NotNil(t, cmd) -} - -func TestProfileListView_Update_WindowResize(t *testing.T) { - factory := createProfileListFactory() - view := NewProfileListView(factory) - ctx := createProfileListViewContext() - _ = view.OnEnter(ctx) - - model, cmd := view.Update(tea.WindowSizeMsg{Width: 120, Height: 50}) - updatedView, ok := model.(*ProfileListView) - require.True(t, ok) - assert.Equal(t, 120, updatedView.width) - assert.Equal(t, 50, updatedView.height) - assert.Nil(t, cmd) -} - -func TestProfileListView_View(t *testing.T) { - factory := createProfileListFactory() - view := NewProfileListView(factory) - ctx := createProfileListViewContext() - _ = view.OnEnter(ctx) - - rendered := view.View() - assert.NotEmpty(t, rendered) -} - -func TestProfileListView_View_EmptyDimensions(t *testing.T) { - factory := createProfileListFactory() - view := NewProfileListView(factory) - view.width = 0 - view.height = 0 - - rendered := view.View() - assert.Empty(t, rendered) -} - -func TestProfileListView_ToJSON(t *testing.T) { - factory := createProfileListFactory() - view := NewProfileListView(factory) - ctx := createProfileListViewContext() - _ = view.OnEnter(ctx) - - data := view.ToJSON() - require.NotNil(t, data) - - // Must be JSON-marshallable - bytes, err := json.Marshal(data) - require.NoError(t, err) - assert.NotEmpty(t, bytes) - - // Check structure - var result map[string]any - err = json.Unmarshal(bytes, &result) - require.NoError(t, err) - assert.Contains(t, result, "profiles") - assert.Contains(t, result, "count") - - count := result["count"].(float64) - assert.Greater(t, count, float64(0)) -} - -func TestProfileListView_filterRows(t *testing.T) { - factory := createProfileListFactory() - view := NewProfileListView(factory) - ctx := createProfileListViewContext() - _ = view.OnEnter(ctx) - - initialCount := view.dataTable.RowCount() - require.Greater(t, initialCount, 0) - - // Filter by "enterprise" - view.filterRows("enterprise") - filteredCount := view.dataTable.RowCount() - assert.LessOrEqual(t, filteredCount, initialCount) - assert.GreaterOrEqual(t, filteredCount, 1) - - // Reset filter - view.filterRows("") - resetCount := view.dataTable.RowCount() - assert.Equal(t, initialCount, resetCount) -} diff --git a/pkg/ui/views/profileselectview.go b/pkg/ui/views/profileselectview.go deleted file mode 100644 index b5445af..0000000 --- a/pkg/ui/views/profileselectview.go +++ /dev/null @@ -1,226 +0,0 @@ -package views - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components/status" - "github.com/arc-framework/arc-cli/pkg/ui/components/table" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -// ProfileSelectView provides cursor-based profile selection in a DataTable. -// On Enter, the selected profile ID is stored in view state (UI only, not persisted). -// -// Layout: -// -// ┌────────────────────────────────────────────────┐ -// │ Select Profile │ -// ├─────────────────────┬──────────────────────────┤ -// │ Name │ Description │ -// │─────────────────────┼──────────────────────────│ -// │ ▶ Enterprise │ Corporate professional │ -// │ Jedi │ Star Wars themed │ -// │ Saiyan │ Dragon Ball Z themed │ -// ├────────────────────────────────────────────────┤ -// │ ↑/↓: navigate • enter: select • q: quit │ -// └────────────────────────────────────────────────┘ -// -// Design: 017-ui-engine Phase 6 (T342) -type ProfileSelectView struct { - factory ui.ComponentFactory - dataTable *table.DataTable - statusBar *status.StatusBar - allRows []table.Row - allData []profiles.Profile - selectedID string // Profile ID chosen by the user (UI state only) - selectionMade bool - width int - height int -} - -// NewProfileSelectView creates a new ProfileSelectView with the given ComponentFactory. -func NewProfileSelectView(factory ui.ComponentFactory) *ProfileSelectView { - return &ProfileSelectView{ - factory: factory, - width: 80, - height: 40, - allRows: []table.Row{}, - } -} - -// Init initializes the ProfileSelectView (Bubble Tea lifecycle). -func (v *ProfileSelectView) Init() tea.Cmd { - return nil -} - -// Update handles messages and updates state. -func (v *ProfileSelectView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - if v.dataTable != nil { - v.dataTable.SetWidth(v.width) - tableHeight := v.height - 4 - if tableHeight < 3 { - tableHeight = 3 - } - v.dataTable.SetHeight(tableHeight) - } - return v, nil - - case tea.KeyMsg: - switch msg.String() { - case "q", keyCtrlC: - return v, tea.Quit - - case keyEnter: - // Record selection from cursor position - if v.dataTable != nil && len(v.allData) > 0 { - cursor := v.dataTable.Cursor() - if cursor >= 0 && cursor < len(v.allData) { - v.selectedID = v.allData[cursor].ID - v.selectionMade = true - } - } - return v, tea.Quit - } - } - - // Delegate table navigation - if v.dataTable != nil { - tableModel, cmd := v.dataTable.Update(msg) - v.dataTable = tableModel.(*table.DataTable) - return v, cmd - } - - return v, nil -} - -// View renders the ProfileSelectView. -func (v *ProfileSelectView) View() string { - if v.width <= 0 || v.height <= 0 { - return "" - } - - titleStyle := lipgloss.NewStyle().Bold(true).Padding(0, 1) - title := titleStyle.Render("Select Profile") - - var tableContent string - if v.dataTable != nil { - tableContent = v.dataTable.View() - } - - var statusContent string - if v.statusBar != nil { - message := "" - if v.selectionMade { - message = lipgloss.NewStyle().Bold(true).Render("Selected: " + v.selectedID) - } - statusContent = v.statusBar.Render(v.width, v.Keybindings(), message) - } - - return lipgloss.JoinVertical( - lipgloss.Left, - title, - "", - tableContent, - "", - statusContent, - ) -} - -// OnEnter is called when the view becomes active. -// Loads all profiles and initializes the table. -func (v *ProfileSelectView) OnEnter(ctx *engine.ViewContext) tea.Cmd { - theme := ctx.Theme - - columns := []table.Column{ - {Title: "Profile ID", Width: 15}, - {Title: "Name", Width: 20}, - {Title: "Description", Width: 45}, - } - - repo, err := profiles.NewRepository() - if err == nil { - allProfiles, loadErr := repo.LoadAll() - if loadErr == nil { - v.allData = allProfiles - v.allRows = make([]table.Row, len(allProfiles)) - for i := range allProfiles { - p := &allProfiles[i] - desc := p.Description - if len(desc) > 43 { - desc = desc[:40] + "..." - } - v.allRows[i] = table.Row{p.ID, p.Name, desc} - } - } - } - - v.dataTable = table.NewDataTable(columns, v.allRows, theme) - - tableHeight := v.height - 4 - if tableHeight < 3 { - tableHeight = 3 - } - v.dataTable.SetWidth(v.width) - v.dataTable.SetHeight(tableHeight) - - v.statusBar = status.NewStatusBar(theme) - - v.width = ctx.Width - v.height = ctx.Height - - return nil -} - -// OnExit is called when the view is replaced. -func (v *ProfileSelectView) OnExit() tea.Cmd { - return nil -} - -// Name returns the unique identifier for this view. -func (v *ProfileSelectView) Name() string { - return "profile-select" -} - -// Keybindings returns the keyboard shortcuts for this view. -func (v *ProfileSelectView) Keybindings() []engine.KeyBinding { - return []engine.KeyBinding{ - {Key: "↑/↓", Description: "navigate"}, - {Key: "enter", Description: "select"}, - {Key: "q", Description: "quit"}, - } -} - -// SelectedProfileID returns the profile ID chosen by the user, and whether a -// selection was made. This is UI-only state; the caller is responsible for -// persisting the selection. -func (v *ProfileSelectView) SelectedProfileID() (string, bool) { - return v.selectedID, v.selectionMade -} - -// ToJSON returns the view's data for JSON output mode. -func (v *ProfileSelectView) ToJSON() any { - items := make([]map[string]any, 0, len(v.allData)) - for i := range v.allData { - p := &v.allData[i] - items = append(items, map[string]any{ - "id": p.ID, - "name": p.Name, - "description": p.Description, - }) - } - result := map[string]any{ - "profiles": items, - "count": len(v.allData), - } - if v.selectionMade { - result["selected"] = v.selectedID - } - return result -} diff --git a/pkg/ui/views/servicedepsview.go b/pkg/ui/views/servicedepsview.go deleted file mode 100644 index c2cfae4..0000000 --- a/pkg/ui/views/servicedepsview.go +++ /dev/null @@ -1,239 +0,0 @@ -package views - -// ServiceDepsView displays a dependency tree for a service using the Tree component. -// -// Layout: -// -// ┌────────────────────────────────────────┐ -// │ postgres │ -// │ ├── redis (cache) │ -// │ └── mongodb (database) │ -// │ └── none │ -// ├────────────────────────────────────────┤ -// │ ↑/↓: navigate • q: quit │ -// └────────────────────────────────────────┘ -// -// Design: 017-ui-engine Phase 10 (T379) - -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/catalog" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components/status" - "github.com/arc-framework/arc-cli/pkg/ui/components/tree" - "github.com/arc-framework/arc-cli/pkg/ui/engine" -) - -const viewServiceDeps = "service-deps" - -// ServiceDepsView displays a dependency tree for a service. -type ServiceDepsView struct { - factory ui.ComponentFactory - tree *tree.Tree - statusBar *status.StatusBar - serviceName string - depsTree *catalog.DependencyNode - width int - height int -} - -// NewServiceDepsView creates a new ServiceDepsView with the given ComponentFactory. -// The factory is used to access profile and theme information for rendering. -func NewServiceDepsView(factory ui.ComponentFactory) *ServiceDepsView { - return &ServiceDepsView{ - factory: factory, - width: 80, - height: 40, - } -} - -// Init initializes the ServiceDepsView (Bubble Tea lifecycle). -// No commands are needed for this static view. -func (v *ServiceDepsView) Init() tea.Cmd { - return nil -} - -// Update handles messages and updates the ServiceDepsView state. -// Handles window resize, tree navigation (j/k, arrows), and keyboard input. -func (v *ServiceDepsView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - return v, nil - - case tea.KeyMsg: - switch msg.String() { - case "q", keyCtrlC: - return v, tea.Quit - case "j", keyDown: - return v, nil - case "k", keyUp: - return v, nil - } - } - - return v, nil -} - -// View renders the ServiceDepsView as a string. -// Displays the dependency tree and status bar. -func (v *ServiceDepsView) View() string { - if v.width <= 0 || v.height <= 0 { - return "" - } - - if v.tree == nil || v.statusBar == nil { - return "" - } - - treeContent := v.tree.Render(v.width) - treeHeight := lipgloss.Height(treeContent) - statusContent := v.statusBar.Render(v.width, v.Keybindings(), "") - statusHeight := lipgloss.Height(statusContent) - - // Pad tree to fill available space above the status bar - availableHeight := v.height - statusHeight - 1 // -1 for separator - if treeHeight < availableHeight { - padding := availableHeight - treeHeight - if padding > 0 { - treeContent += strings.Repeat("\n", padding) - } - } - - // Separator line - var separator string - if v.factory != nil && v.factory.Theme() != nil { - separatorStyle := lipgloss.NewStyle(). - Foreground(v.factory.Theme().Colors.BorderColor()) - separator = separatorStyle.Render(strings.Repeat("─", v.width)) - } else { - separator = strings.Repeat("─", v.width) - } - - return lipgloss.JoinVertical( - lipgloss.Left, - treeContent, - separator, - statusContent, - ) -} - -// OnEnter is called when the ServiceDepsView becomes active. -// Reads ctx.Args["service_name"] (string) and ctx.Args["deps_tree"] (*catalog.DependencyNode). -func (v *ServiceDepsView) OnEnter(ctx *engine.ViewContext) tea.Cmd { - // Extract service name from args - if name, ok := ctx.Args["service_name"].(string); ok { - v.serviceName = name - } else { - v.serviceName = "unknown" - } - - // Extract dependency tree from args - if dt, ok := ctx.Args["deps_tree"].(*catalog.DependencyNode); ok { - v.depsTree = dt - } - - // Build tree component from dependency data - treeRoot := v.buildTreeFromDeps() - v.tree = tree.NewTree(treeRoot, ctx.Theme) - - // Initialize status bar with theme from context - v.statusBar = status.NewStatusBar(ctx.Theme) - - // Update dimensions from context - v.width = ctx.Width - v.height = ctx.Height - - return nil -} - -// OnExit is called when the ServiceDepsView is replaced by another view. -// No cleanup needed for this view. -func (v *ServiceDepsView) OnExit() tea.Cmd { - return nil -} - -// Name returns the unique identifier for this view. -func (v *ServiceDepsView) Name() string { - return viewServiceDeps -} - -// Keybindings returns the keyboard shortcuts for the ServiceDepsView. -func (v *ServiceDepsView) Keybindings() []engine.KeyBinding { - return []engine.KeyBinding{ - {Key: "↑/↓", Description: "navigate"}, - {Key: "q", Description: "quit"}, - } -} - -// buildTreeFromDeps converts a catalog.DependencyNode into a tree.TreeNode hierarchy. -func (v *ServiceDepsView) buildTreeFromDeps() *tree.TreeNode { - if v.depsTree == nil { - return &tree.TreeNode{ - Label: v.serviceName, - Value: "(no dependencies)", - Expanded: true, - } - } - return convertDepNode(v.depsTree) -} - -// convertDepNode recursively converts a catalog.DependencyNode to a tree.TreeNode. -func convertDepNode(dn *catalog.DependencyNode) *tree.TreeNode { - if dn == nil || dn.Service == nil { - return nil - } - - label := dn.Service.Codename - value := dn.Service.Technology - - node := &tree.TreeNode{ - Label: label, - Value: value, - Expanded: true, - } - - for _, child := range dn.Children { - childNode := convertDepNode(child) - if childNode != nil { - node.Children = append(node.Children, childNode) - } - } - - return node -} - -// ToJSON returns the dependency tree data for JSON output mode. -func (v *ServiceDepsView) ToJSON() any { - return map[string]any{ - "service": v.serviceName, - "tree": convertDepNodeToMap(v.depsTree), - } -} - -// convertDepNodeToMap converts a catalog.DependencyNode to a map for JSON output. -func convertDepNodeToMap(dn *catalog.DependencyNode) any { - if dn == nil || dn.Service == nil { - return nil - } - - children := make([]any, 0, len(dn.Children)) - for _, child := range dn.Children { - children = append(children, convertDepNodeToMap(child)) - } - - result := map[string]any{ - "codename": dn.Service.Codename, - "technology": dn.Service.Technology, - } - if len(children) > 0 { - result["children"] = children - } - - return result -} diff --git a/pkg/ui/views/servicedetailview.go b/pkg/ui/views/servicedetailview.go deleted file mode 100644 index eda15b1..0000000 --- a/pkg/ui/views/servicedetailview.go +++ /dev/null @@ -1,284 +0,0 @@ -package views - -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components/breadcrumb" - "github.com/arc-framework/arc-cli/pkg/ui/components/status" - "github.com/arc-framework/arc-cli/pkg/ui/components/tree" - "github.com/arc-framework/arc-cli/pkg/ui/engine" -) - -// BackMsg is a message that signals the view should navigate back. -// This is sent when the user presses 'b' to go back to the previous view. -type BackMsg struct{} - -// ServiceDetailView displays detailed information about a service in a tree structure. -// -// Layout: -// -// ┌────────────────────────────────────────┐ -// │ Home › Services › Postgres │ -// ├────────────────────────────────────────┤ -// │ ├─ Configuration │ -// │ │ ├─ Port: 5432 │ -// │ │ ├─ Host: localhost │ -// │ │ └─ Database: mydb │ -// │ ├─ Status │ -// │ │ ├─ State: running │ -// │ │ └─ Uptime: 2h 30m │ -// │ └─ Dependencies │ -// │ └─ None │ -// ├────────────────────────────────────────┤ -// │ b: back • ↑/↓: navigate • q: quit │ -// └────────────────────────────────────────┘ -// -// Design: 017-ui-engine Phase 3 (User Story 1 - Service Detail View) -type ServiceDetailView struct { - factory ui.ComponentFactory - breadcrumb *breadcrumb.Breadcrumb - tree *tree.Tree - statusBar *status.StatusBar - serviceName string - width int - height int -} - -// NewServiceDetailView creates a new ServiceDetailView with the given ComponentFactory. -// The factory is used to access profile and theme information for rendering. -func NewServiceDetailView(factory ui.ComponentFactory) *ServiceDetailView { - return &ServiceDetailView{ - factory: factory, - width: 80, // Default width - height: 40, // Default height - } -} - -// Init initializes the ServiceDetailView (Bubble Tea lifecycle). -// No commands are needed for this static view. -func (v *ServiceDetailView) Init() tea.Cmd { - return nil -} - -// Update handles messages and updates the ServiceDetailView state. -// Handles window resize, tree navigation (j/k, arrows), and keyboard input. -func (v *ServiceDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - return v, nil - - case tea.KeyMsg: - switch msg.String() { - case "q", keyCtrlC: - return v, tea.Quit - case "b": - // Signal navigation back - return v, func() tea.Msg { return BackMsg{} } - case "j", keyDown: - // Navigate down in tree (future: implement selection) - return v, nil - case "k", keyUp: - // Navigate up in tree (future: implement selection) - return v, nil - } - } - - return v, nil -} - -// View renders the ServiceDetailView as a string. -// Displays breadcrumb, tree, and status bar. -func (v *ServiceDetailView) View() string { - if v.width <= 0 || v.height <= 0 { - return "" - } - - // Render breadcrumb at top - var breadcrumbContent string - if v.breadcrumb != nil { - breadcrumbContent = v.breadcrumb.Render(v.width) - } - - // Render tree in middle - var treeContent string - if v.tree != nil { - treeContent = v.tree.Render(v.width) - } - - // Render status bar at bottom - var statusContent string - if v.statusBar != nil { - statusContent = v.statusBar.Render(v.width, v.Keybindings(), "") - } - - // Calculate heights - breadcrumbHeight := lipgloss.Height(breadcrumbContent) - treeHeight := lipgloss.Height(treeContent) - statusHeight := lipgloss.Height(statusContent) - - // Calculate available space for tree content - availableHeight := v.height - breadcrumbHeight - statusHeight - - // Add spacing between sections if needed - var sections []string - if breadcrumbContent != "" { - sections = append(sections, breadcrumbContent) - } - - // Add separator line after breadcrumb - if v.factory != nil && v.factory.Theme() != nil { - separatorStyle := lipgloss.NewStyle(). - Foreground(v.factory.Theme().Colors.BorderColor()) - separator := separatorStyle.Render(strings.Repeat("─", v.width)) - sections = append(sections, separator) - } - - if treeContent != "" { - // Pad tree content to fill available space - if treeHeight < availableHeight { - padding := availableHeight - treeHeight - 2 // -2 for separator - if padding > 0 { - treeContent += "\n" + strings.Repeat("\n", padding-1) - } - } - sections = append(sections, treeContent) - } - - // Add separator line before status bar - if v.factory != nil && v.factory.Theme() != nil { - separatorStyle := lipgloss.NewStyle(). - Foreground(v.factory.Theme().Colors.BorderColor()) - separator := separatorStyle.Render(strings.Repeat("─", v.width)) - sections = append(sections, separator) - } - - if statusContent != "" { - sections = append(sections, statusContent) - } - - // Join all sections vertically - return lipgloss.JoinVertical(lipgloss.Left, sections...) -} - -// OnEnter is called when the ServiceDetailView becomes active. -// Extracts the serviceName from context args and initializes components. -func (v *ServiceDetailView) OnEnter(ctx *engine.ViewContext) tea.Cmd { - // Extract serviceName from args - if serviceName, ok := ctx.Args["serviceName"].(string); ok { - v.serviceName = serviceName - } else { - // Default to "Unknown Service" if not provided - v.serviceName = "Unknown Service" - } - - // Build breadcrumb path: Home › Services › {serviceName} - breadcrumbItems := []string{"Home", "Services", v.serviceName} - v.breadcrumb = breadcrumb.NewBreadcrumb(breadcrumbItems, ctx.Theme) - - // Build tree structure for the service - serviceTree := v.buildServiceTree(v.serviceName) - v.tree = tree.NewTree(serviceTree, ctx.Theme) - - // Initialize status bar - v.statusBar = status.NewStatusBar(ctx.Theme) - - // Update dimensions from context - v.width = ctx.Width - v.height = ctx.Height - - return nil -} - -// OnExit is called when the ServiceDetailView is replaced by another view. -// No cleanup needed for this view. -func (v *ServiceDetailView) OnExit() tea.Cmd { - return nil -} - -// Name returns the unique identifier for this view. -func (v *ServiceDetailView) Name() string { - return "service-detail" -} - -// Keybindings returns the keyboard shortcuts for the ServiceDetailView. -func (v *ServiceDetailView) Keybindings() []engine.KeyBinding { - return []engine.KeyBinding{ - {Key: "b", Description: "back"}, - {Key: "↑/↓", Description: "navigate"}, - {Key: "q", Description: "quit"}, - } -} - -// buildServiceTree creates a tree structure for displaying service details. -// This is a mock implementation that generates sample data based on the service name. -// In a real implementation, this would fetch actual service data from a data source. -func (v *ServiceDetailView) buildServiceTree(serviceName string) *tree.TreeNode { - root := &tree.TreeNode{ - Label: serviceName, - Expanded: true, - } - - // Configuration section - configNode := &tree.TreeNode{ - Label: "Configuration", - Expanded: true, - Children: []*tree.TreeNode{ - {Label: "Port", Value: "5432"}, - {Label: "Host", Value: "localhost"}, - {Label: "Database", Value: "mydb"}, - }, - } - - // Status section - statusNode := &tree.TreeNode{ - Label: "Status", - Expanded: true, - Children: []*tree.TreeNode{ - {Label: "State", Value: "running"}, - {Label: "Uptime", Value: "2h 30m"}, - }, - } - - // Dependencies section - dependenciesNode := &tree.TreeNode{ - Label: "Dependencies", - Expanded: true, - Children: []*tree.TreeNode{ - {Label: "None", Value: ""}, - }, - } - - // Add all sections to root - root.Children = []*tree.TreeNode{ - configNode, - statusNode, - dependenciesNode, - } - - return root -} - -// ToJSON returns the view's data for JSON output mode. -// Returns a map describing the service and its tree-structured detail sections. -func (v *ServiceDetailView) ToJSON() any { - service := map[string]any{ - "name": v.serviceName, - "configuration": map[string]any{ - "port": "5432", - "host": "localhost", - "database": "mydb", - }, - "status": map[string]any{ - "state": "running", - "uptime": "2h 30m", - }, - "dependencies": []string{}, - } - return service -} diff --git a/pkg/ui/views/servicedetailview_test.go b/pkg/ui/views/servicedetailview_test.go deleted file mode 100644 index b48c197..0000000 --- a/pkg/ui/views/servicedetailview_test.go +++ /dev/null @@ -1,563 +0,0 @@ -package views - -import ( - "encoding/json" - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -func TestNewServiceDetailView(t *testing.T) { - factory := createTestFactoryForServiceDetail(t) - view := NewServiceDetailView(factory) - - assert.NotNil(t, view) - assert.Equal(t, factory, view.factory) - assert.Equal(t, 80, view.width, "Default width") - assert.Equal(t, 40, view.height, "Default height") - assert.Nil(t, view.breadcrumb, "Breadcrumb not initialized until OnEnter") - assert.Nil(t, view.tree, "Tree not initialized until OnEnter") - assert.Nil(t, view.statusBar, "StatusBar not initialized until OnEnter") -} - -func TestServiceDetailViewInit(t *testing.T) { - factory := createTestFactoryForServiceDetail(t) - view := NewServiceDetailView(factory) - - cmd := view.Init() - assert.Nil(t, cmd, "Init should return no command") -} - -func TestServiceDetailViewName(t *testing.T) { - factory := createTestFactoryForServiceDetail(t) - view := NewServiceDetailView(factory) - - assert.Equal(t, "service-detail", view.Name()) -} - -func TestServiceDetailViewKeybindings(t *testing.T) { - factory := createTestFactoryForServiceDetail(t) - view := NewServiceDetailView(factory) - - bindings := view.Keybindings() - - require.Len(t, bindings, 3) - assert.Equal(t, "b", bindings[0].Key) - assert.Equal(t, "back", bindings[0].Description) - assert.Equal(t, "↑/↓", bindings[1].Key) - assert.Equal(t, "navigate", bindings[1].Description) - assert.Equal(t, "q", bindings[2].Key) - assert.Equal(t, "quit", bindings[2].Description) -} - -func TestServiceDetailViewOnEnter(t *testing.T) { - factory := createTestFactoryForServiceDetail(t) - view := NewServiceDetailView(factory) - - ctx := createTestViewContextForServiceDetail(120, 60, map[string]any{ - "serviceName": "Postgres", - }) - - cmd := view.OnEnter(ctx) - - assert.Nil(t, cmd, "OnEnter should return no command") - assert.Equal(t, "Postgres", view.serviceName) - assert.NotNil(t, view.breadcrumb, "Breadcrumb should be initialized") - assert.NotNil(t, view.tree, "Tree should be initialized") - assert.NotNil(t, view.statusBar, "StatusBar should be initialized") - assert.Equal(t, 120, view.width, "Width should be updated from context") - assert.Equal(t, 60, view.height, "Height should be updated from context") -} - -func TestServiceDetailViewOnEnterWithoutServiceName(t *testing.T) { - factory := createTestFactoryForServiceDetail(t) - view := NewServiceDetailView(factory) - - ctx := createTestViewContextForServiceDetail(120, 60, map[string]any{}) - - view.OnEnter(ctx) - - assert.Equal(t, "Unknown Service", view.serviceName, "Should default to 'Unknown Service'") -} - -func TestServiceDetailViewOnEnterWithInvalidServiceName(t *testing.T) { - factory := createTestFactoryForServiceDetail(t) - view := NewServiceDetailView(factory) - - ctx := createTestViewContextForServiceDetail(120, 60, map[string]any{ - "serviceName": 123, // Not a string - }) - - view.OnEnter(ctx) - - assert.Equal(t, "Unknown Service", view.serviceName, "Should default to 'Unknown Service' for invalid type") -} - -func TestServiceDetailViewOnExit(t *testing.T) { - factory := createTestFactoryForServiceDetail(t) - view := NewServiceDetailView(factory) - - cmd := view.OnExit() - assert.Nil(t, cmd, "OnExit should return no command") -} - -func TestServiceDetailViewUpdateWindowSize(t *testing.T) { - factory := createTestFactoryForServiceDetail(t) - view := NewServiceDetailView(factory) - - // Initialize view with OnEnter - - ctx := createTestViewContextForServiceDetail(80, 40, map[string]any{ - "serviceName": "Postgres", - }) - view.OnEnter(ctx) - - // Send window resize message - msg := tea.WindowSizeMsg{Width: 160, Height: 60} - updatedModel, cmd := view.Update(msg) - - assert.Nil(t, cmd) - updatedView := updatedModel.(*ServiceDetailView) - assert.Equal(t, 160, updatedView.width) - assert.Equal(t, 60, updatedView.height) -} - -func TestServiceDetailViewUpdateKeyPress(t *testing.T) { - factory := createTestFactoryForServiceDetail(t) - view := NewServiceDetailView(factory) - - // Initialize view with OnEnter - - ctx := createTestViewContextForServiceDetail(80, 40, map[string]any{ - "serviceName": "Postgres", - }) - view.OnEnter(ctx) - - tests := []struct { - name string - key string - shouldQuit bool - shouldGoBack bool - }{ - { - name: "q key quits", - key: "q", - shouldQuit: true, - }, - { - name: "ctrl+c quits", - key: "ctrl+c", - shouldQuit: true, - }, - { - name: "b key goes back", - key: "b", - shouldGoBack: true, - }, - { - name: "j key navigates (no-op currently)", - key: "j", - }, - { - name: "k key navigates (no-op currently)", - key: "k", - }, - { - name: "down key navigates (no-op currently)", - key: "down", - }, - { - name: "up key navigates (no-op currently)", - key: "up", - }, - { - name: "other keys do nothing", - key: "a", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - msg := tea.KeyMsg{Type: tea.KeyRunes} - if tt.key == "ctrl+c" { - msg.Type = tea.KeyCtrlC - } - msg.Runes = []rune(tt.key) - - updatedModel, cmd := view.Update(msg) - - assert.NotNil(t, updatedModel) - if tt.shouldQuit || tt.shouldGoBack { - assert.NotNil(t, cmd, "Should return a command") - } - }) - } -} - -func TestServiceDetailViewView(t *testing.T) { - factory := createTestFactoryForServiceDetail(t) - view := NewServiceDetailView(factory) - - t.Run("before OnEnter returns empty", func(t *testing.T) { - // Set valid dimensions but don't call OnEnter - view.width = 80 - view.height = 40 - - output := view.View() - // Output may not be completely empty since we check dimensions, - // but components won't be initialized - _ = output - }) - - t.Run("with zero dimensions returns empty", func(t *testing.T) { - ctx := createTestViewContextForServiceDetail(0, 0, map[string]any{ - "serviceName": "Postgres", - }) - view.OnEnter(ctx) - - output := view.View() - assert.Equal(t, "", output, "Should return empty string with zero dimensions") - }) - - t.Run("after OnEnter renders content", func(t *testing.T) { - view := NewServiceDetailView(factory) - - ctx := createTestViewContextForServiceDetail(80, 40, map[string]any{ - "serviceName": "Postgres", - }) - view.OnEnter(ctx) - - output := view.View() - - assert.NotEmpty(t, output, "Should render content after OnEnter") - - // Verify breadcrumb content - assert.Contains(t, output, "Home", "Should contain breadcrumb 'Home'") - assert.Contains(t, output, "Services", "Should contain breadcrumb 'Services'") - assert.Contains(t, output, "Postgres", "Should contain breadcrumb 'Postgres'") - - // Verify tree content - assert.Contains(t, output, "Configuration", "Should contain 'Configuration' node") - assert.Contains(t, output, "Status", "Should contain 'Status' node") - assert.Contains(t, output, "Dependencies", "Should contain 'Dependencies' node") - - // Verify status bar - assert.Contains(t, output, "back", "Should contain 'back' keybinding") - assert.Contains(t, output, "quit", "Should contain 'quit' keybinding") - }) - - t.Run("renders with different service names", func(t *testing.T) { - services := []string{"Postgres", "Redis", "MongoDB", "Unknown Service"} - - for _, serviceName := range services { - view := NewServiceDetailView(factory) - - ctx := createTestViewContextForServiceDetail(80, 40, map[string]any{ - "serviceName": serviceName, - }) - view.OnEnter(ctx) - - output := view.View() - assert.NotEmpty(t, output, "Should render for service: %s", serviceName) - assert.Contains(t, output, serviceName, "Should contain service name: %s", serviceName) - } - }) -} - -func TestServiceDetailViewBuildServiceTree(t *testing.T) { - factory := createTestFactoryForServiceDetail(t) - view := NewServiceDetailView(factory) - - tests := []struct { - name string - serviceName string - }{ - {"postgres service", "Postgres"}, - {"redis service", "Redis"}, - {"mongodb service", "MongoDB"}, - {"unknown service", "Unknown"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tree := view.buildServiceTree(tt.serviceName) - - require.NotNil(t, tree) - assert.Equal(t, tt.serviceName, tree.Label) - assert.True(t, tree.Expanded, "Root should be expanded") - - // Verify tree structure - require.Len(t, tree.Children, 3, "Should have 3 main sections") - - // Configuration section - configNode := tree.Children[0] - assert.Equal(t, "Configuration", configNode.Label) - assert.True(t, configNode.Expanded) - require.Len(t, configNode.Children, 3) - assert.Equal(t, "Port", configNode.Children[0].Label) - assert.Equal(t, "5432", configNode.Children[0].Value) - assert.Equal(t, "Host", configNode.Children[1].Label) - assert.Equal(t, "localhost", configNode.Children[1].Value) - assert.Equal(t, "Database", configNode.Children[2].Label) - assert.Equal(t, "mydb", configNode.Children[2].Value) - - // Status section - statusNode := tree.Children[1] - assert.Equal(t, "Status", statusNode.Label) - assert.True(t, statusNode.Expanded) - require.Len(t, statusNode.Children, 2) - assert.Equal(t, "State", statusNode.Children[0].Label) - assert.Equal(t, "running", statusNode.Children[0].Value) - assert.Equal(t, "Uptime", statusNode.Children[1].Label) - assert.Equal(t, "2h 30m", statusNode.Children[1].Value) - - // Dependencies section - depsNode := tree.Children[2] - assert.Equal(t, "Dependencies", depsNode.Label) - assert.True(t, depsNode.Expanded) - require.Len(t, depsNode.Children, 1) - assert.Equal(t, "None", depsNode.Children[0].Label) - }) - } -} - -func TestServiceDetailViewIntegration(t *testing.T) { - // Test the full lifecycle: Init -> OnEnter -> Update -> View -> OnExit - factory := createTestFactoryForServiceDetail(t) - view := NewServiceDetailView(factory) - - // 1. Init - cmd := view.Init() - assert.Nil(t, cmd) - - // 2. OnEnter - - ctx := createTestViewContextForServiceDetail(120, 40, map[string]any{ - "serviceName": "Postgres", - }) - cmd = view.OnEnter(ctx) - assert.Nil(t, cmd) - assert.Equal(t, "Postgres", view.serviceName) - - // 3. Update with window resize - msg := tea.WindowSizeMsg{Width: 100, Height: 50} - updatedModel, cmd := view.Update(msg) - assert.Nil(t, cmd) - updatedView := updatedModel.(*ServiceDetailView) - assert.Equal(t, 100, updatedView.width) - assert.Equal(t, 50, updatedView.height) - - // 4. View renders - output := updatedView.View() - assert.NotEmpty(t, output) - assert.Contains(t, output, "Postgres") - - // 5. OnExit - cmd = updatedView.OnExit() - assert.Nil(t, cmd) -} - -func TestServiceDetailViewImplementsViewInterface(t *testing.T) { - // Compile-time check that ServiceDetailView implements engine.View - var _ engine.View = (*ServiceDetailView)(nil) -} - -func TestServiceDetailViewImplementsBubbleTeaModel(t *testing.T) { - // Compile-time check that ServiceDetailView implements tea.Model - var _ tea.Model = (*ServiceDetailView)(nil) -} - -func TestServiceDetailViewWithNilFactory(t *testing.T) { - // Test that view can be created with nil factory (should not panic) - view := NewServiceDetailView(nil) - assert.NotNil(t, view) - assert.Nil(t, view.factory) - - // OnEnter should still work - - ctx := createTestViewContextForServiceDetail(80, 40, map[string]any{ - "serviceName": "Postgres", - }) - - assert.NotPanics(t, func() { - view.OnEnter(ctx) - }) -} - -func TestServiceDetailViewMultipleOnEnterCalls(t *testing.T) { - // Test that calling OnEnter multiple times reinitializes components - factory := createTestFactoryForServiceDetail(t) - view := NewServiceDetailView(factory) - - ctx1 := createTestViewContextForServiceDetail(80, 40, map[string]any{ - "serviceName": "Postgres", - }) - view.OnEnter(ctx1) - - assert.Equal(t, "Postgres", view.serviceName) - assert.NotNil(t, view.breadcrumb) - assert.NotNil(t, view.tree) - assert.NotNil(t, view.statusBar) - assert.Equal(t, 80, view.width) - assert.Equal(t, 40, view.height) - - // Call OnEnter again with different context - ctx2 := createTestViewContextForServiceDetail(120, 60, map[string]any{ - "serviceName": "Redis", - }) - view.OnEnter(ctx2) - - // Components should be reinitialized and dimensions updated - assert.Equal(t, "Redis", view.serviceName) - assert.NotNil(t, view.breadcrumb) - assert.NotNil(t, view.tree) - assert.NotNil(t, view.statusBar) - assert.Equal(t, 120, view.width) - assert.Equal(t, 60, view.height) -} - -func TestBackMsg(t *testing.T) { - // Verify BackMsg is a valid type and can be instantiated - msg := BackMsg{} - _ = msg -} - -// Helper functions specific to ServiceDetailView tests - -// createTestFactoryForServiceDetail creates a test ComponentFactory with default profile. -func createTestFactoryForServiceDetail(t *testing.T) ui.ComponentFactory { - t.Helper() - profileCtx := profiles.GetDefaultProfileContext() - return ui.NewComponentFactory(profileCtx, components.BorderTierBlock) -} - -// createTestViewContextForServiceDetail creates a test ViewContext with given dimensions and args. -func createTestViewContextForServiceDetail(width, height int, args map[string]any) *engine.ViewContext { - profileCtx := profiles.GetDefaultProfileContext() - return engine.NewViewContext( - profileCtx.Profile(), - profileCtx.Theme(), - width, - height, - args, - ) -} - -func TestServiceDetailViewBreadcrumbPath(t *testing.T) { - factory := createTestFactoryForServiceDetail(t) - view := NewServiceDetailView(factory) - - ctx := createTestViewContextForServiceDetail(120, 40, map[string]any{ - "serviceName": "Postgres", - }) - view.OnEnter(ctx) - - // Breadcrumb should be initialized with correct path - assert.NotNil(t, view.breadcrumb) - - // Render to verify content - output := view.View() - - // Should contain all breadcrumb items in order - homeIdx := strings.Index(output, "Home") - servicesIdx := strings.Index(output, "Services") - postgresIdx := strings.Index(output, "Postgres") - - assert.True(t, homeIdx >= 0, "Should contain 'Home'") - assert.True(t, servicesIdx >= 0, "Should contain 'Services'") - assert.True(t, postgresIdx >= 0, "Should contain 'Postgres'") - - // Verify order (Home before Services before Postgres) - assert.True(t, homeIdx < servicesIdx, "'Home' should appear before 'Services'") - assert.True(t, servicesIdx < postgresIdx, "'Services' should appear before 'Postgres'") -} - -// T243: JSON marshaling tests for ServiceDetailView.ToJSON -func TestServiceDetailView_ToJSON(t *testing.T) { - factory := createTestFactoryForServiceDetail(t) - view := NewServiceDetailView(factory) - - profile := profiles.GetDefaultProfileContext().Profile() - theme := profiles.GetDefaultProfileContext().Theme() - ctx := engine.NewViewContext(profile, theme, 80, 40, map[string]any{ - "serviceName": "postgres", - }) - view.OnEnter(ctx) - - t.Run("implements JSONExporter interface", func(t *testing.T) { - var _ engine.JSONExporter = view - }) - - t.Run("returns JSON-marshallable data", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - require.NoError(t, err) - assert.NotEmpty(t, data) - }) - - t.Run("contains service name", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - - assert.Equal(t, "postgres", result["name"]) - }) - - t.Run("contains configuration section", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - - assert.Contains(t, result, "configuration") - config := result["configuration"].(map[string]any) - assert.Contains(t, config, "port") - assert.Contains(t, config, "host") - assert.Contains(t, config, "database") - }) - - t.Run("contains status section", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - - assert.Contains(t, result, "status") - status := result["status"].(map[string]any) - assert.Contains(t, status, "state") - assert.Contains(t, status, "uptime") - }) - - t.Run("contains dependencies section", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - - assert.Contains(t, result, "dependencies") - }) - - t.Run("default service name when not set", func(t *testing.T) { - emptyView := NewServiceDetailView(factory) - // OnEnter not called — serviceName is empty string - data, err := json.Marshal(emptyView.ToJSON()) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - - assert.Contains(t, result, "name") - }) -} diff --git a/pkg/ui/views/serviceslistview.go b/pkg/ui/views/serviceslistview.go deleted file mode 100644 index 53de075..0000000 --- a/pkg/ui/views/serviceslistview.go +++ /dev/null @@ -1,352 +0,0 @@ -package views - -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components/search" - "github.com/arc-framework/arc-cli/pkg/ui/components/status" - "github.com/arc-framework/arc-cli/pkg/ui/components/table" - "github.com/arc-framework/arc-cli/pkg/ui/engine" -) - -// ServicesListView displays a searchable, sortable table of services. -// This view integrates SearchBar, DataTable, and StatusBar components -// to provide a full-featured service browsing interface. -// -// Layout: -// -// ┌────────────────────────────────────────┐ -// │ Search: [postgres_____________] │ -// ├────────────────────────────────────────┤ -// │ Name │ Status │ Port │ -// │──────────────┼───────────┼────────────│ -// │ postgres │ running │ 5432 │ -// │ redis │ stopped │ 6379 │ -// │ mongodb │ running │ 27017 │ -// ├────────────────────────────────────────┤ -// │ ↑/↓: navigate • /: search • enter: view│ -// └────────────────────────────────────────┘ -// -// Features: -// - Real-time search filtering (case-insensitive, matches any column) -// - Keyboard navigation (j/k or arrows) -// - Column sorting (press 's' to toggle) -// - Row selection (enter to view detail) -// - Responsive layout with automatic width adjustment -// -// Design: 017-ui-engine Phase 3 (User Story 1) -type ServicesListView struct { - factory ui.ComponentFactory - searchBar *search.SearchBar - dataTable *table.DataTable - statusBar *status.StatusBar - allRows []table.Row // Original unfiltered data - width int - height int -} - -// NewServicesListView creates a new ServicesListView with the given ComponentFactory. -// The factory is used to access theme information for component styling. -func NewServicesListView(factory ui.ComponentFactory) *ServicesListView { - return &ServicesListView{ - factory: factory, - width: 80, // Default width - height: 40, // Default height - allRows: []table.Row{}, // Empty by default, populated in OnEnter - } -} - -// Init initializes the ServicesListView (Bubble Tea lifecycle). -// Returns a command to start the search bar cursor blinking. -func (v *ServicesListView) Init() tea.Cmd { - if v.searchBar != nil { - return v.searchBar.Init() - } - return nil -} - -// Update handles messages and updates the ServicesListView state. -// Handles window resize, keyboard input, and delegates to child components. -func (v *ServicesListView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - return v.handleWindowResize(msg) - case tea.KeyMsg: - return v.handleKeyPress(msg) - } - - return v.updateComponents(msg) -} - -// handleWindowResize processes window resize messages and updates component dimensions. -// -//nolint:dupl // boilerplate resize logic is structurally identical across list views by design -func (v *ServicesListView) handleWindowResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { - v.width = msg.Width - v.height = msg.Height - - // Update component dimensions - if v.searchBar != nil { - v.searchBar.SetWidth(v.width - 4) // Account for border padding - } - if v.dataTable != nil { - v.dataTable.SetWidth(v.width) - // Calculate available height for table (total - search - status - borders) - // Search bar renders with RoundedBorder = 3 lines; 2 empty spacers; 1 status bar = 6 total - tableHeight := v.height - 6 - if tableHeight < 3 { - tableHeight = 3 - } - v.dataTable.SetHeight(tableHeight) - } - return v, nil -} - -// handleKeyPress processes keyboard input for view-level actions. -func (v *ServicesListView) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", keyCtrlC: - return v, tea.Quit - - case "/": - // Focus search bar - if v.searchBar != nil { - return v, v.searchBar.Focus() - } - return v, nil - - case "enter": - // View service detail (to be implemented in Phase 4) - // For now, just return nil - return v, nil - - case keyEsc: - // Clear search if search bar is focused - if v.searchBar != nil && v.searchBar.Focused() { - var searchModel tea.Model - searchModel, cmd := v.searchBar.Update(msg) - v.searchBar = searchModel.(*search.SearchBar) - return v, cmd - } - return v, nil - } - - // Delegate to component updates for other keys - return v.updateComponents(msg) -} - -// updateComponents updates child components with the given message. -// -//nolint:dupl // boilerplate update-dispatch is structurally identical across list views by design -func (v *ServicesListView) updateComponents(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - // Update search bar - if v.searchBar != nil { - var searchModel tea.Model - searchModel, cmd := v.searchBar.Update(msg) - v.searchBar = searchModel.(*search.SearchBar) - cmds = append(cmds, cmd) - } - - // Update data table (only if search bar is not focused to avoid conflicts) - if v.dataTable != nil && (v.searchBar == nil || !v.searchBar.Focused()) { - var tableModel tea.Model - tableModel, cmd := v.dataTable.Update(msg) - v.dataTable = tableModel.(*table.DataTable) - cmds = append(cmds, cmd) - } - - return v, tea.Batch(cmds...) -} - -// View renders the ServicesListView as a string. -// Displays search bar at top, data table in middle, status bar at bottom. -// -//nolint:dupl // list view rendering is structurally identical across views by design -func (v *ServicesListView) View() string { - if v.width <= 0 || v.height <= 0 { - return "" - } - - // Render search bar at the top - var searchContent string - if v.searchBar != nil { - searchContent = v.searchBar.View() - } - - // Render data table in the middle - var tableContent string - if v.dataTable != nil { - tableContent = v.dataTable.View() - } - - // Render status bar at the bottom - var statusContent string - if v.statusBar != nil { - message := "" - if v.dataTable != nil { - rowCount := v.dataTable.RowCount() - totalRows := len(v.allRows) - if rowCount < totalRows { - message = lipgloss.NewStyle().Render( - strings.Join([]string{ - "Showing", - lipgloss.NewStyle().Bold(true).Render(string(rune(rowCount + '0'))), - "of", - lipgloss.NewStyle().Bold(true).Render(string(rune(totalRows + '0'))), - "services", - }, " "), - ) - } - } - statusContent = v.statusBar.Render(v.width, v.Keybindings(), message) - } - - // Join all sections vertically with spacing - return lipgloss.JoinVertical( - lipgloss.Left, - searchContent, - "", - tableContent, - "", - statusContent, - ) -} - -// OnEnter is called when the ServicesListView becomes active. -// Initializes all child components with profile/theme from context. -func (v *ServicesListView) OnEnter(ctx *engine.ViewContext) tea.Cmd { - theme := ctx.Theme - - // Initialize search bar with theme - v.searchBar = search.NewSearchBar(theme, "Search services...") - v.searchBar.SetWidth(v.width - 4) // Account for border padding - - // Wire up search bar onChange to filter table rows - v.searchBar.SetOnChange(func(query string) { - v.filterRows(query) - }) - - // Define columns for service data - columns := []table.Column{ - {Title: "Service (Technology)", Width: 30}, - {Title: "Role", Width: 15}, - {Title: "Description", Width: 45}, - } - - // Get real service data from context args if available - if rows, ok := ctx.Args["rows"].([]table.Row); ok && len(rows) > 0 { - v.allRows = rows - } else { - // Fallback to mock data for testing - v.allRows = []table.Row{ - {"postgres (PostgreSQL)", "Data", "Relational database"}, - {"redis (Redis)", "Data", "In-memory cache"}, - {"mongodb (MongoDB)", "Data", "Document database"}, - {"mysql (MySQL)", "Data", "Relational database"}, - } - } - - v.dataTable = table.NewDataTable(columns, v.allRows, theme) - - // Search bar with border = 3 lines; 2 spacers; 1 status = 6 overhead - tableHeight := v.height - 6 - if tableHeight < 3 { - tableHeight = 3 - } - v.dataTable.SetWidth(v.width) - v.dataTable.SetHeight(tableHeight) - - // Initialize status bar with theme - v.statusBar = status.NewStatusBar(theme) - - // Update dimensions from context - v.width = ctx.Width - v.height = ctx.Height - - return v.searchBar.Init() -} - -// OnExit is called when the ServicesListView is replaced by another view. -// No cleanup needed for this view. -func (v *ServicesListView) OnExit() tea.Cmd { - return nil -} - -// Name returns the unique identifier for this view. -func (v *ServicesListView) Name() string { - return "services-list" -} - -// Keybindings returns the keyboard shortcuts for the ServicesListView. -func (v *ServicesListView) Keybindings() []engine.KeyBinding { - return []engine.KeyBinding{ - {Key: "↑/↓", Description: "navigate"}, - {Key: "/", Description: "search"}, - {Key: "s", Description: "sort"}, - {Key: "enter", Description: "view"}, - {Key: "q", Description: "quit"}, - } -} - -// filterRows filters the data table rows based on the search query. -// Uses case-insensitive substring matching across all columns. -func (v *ServicesListView) filterRows(query string) { - if v.dataTable == nil { - return - } - - // If query is empty, show all rows - if query == "" { - v.dataTable.SetRows(v.allRows) - return - } - - // Filter rows by checking if any column contains the query (case-insensitive) - queryLower := strings.ToLower(query) - filtered := []table.Row{} - - for _, row := range v.allRows { - for _, cell := range row { - if strings.Contains(strings.ToLower(cell), queryLower) { - filtered = append(filtered, row) - break // Match found in this row, move to next row - } - } - } - - // Update table with filtered rows - v.dataTable.SetRows(filtered) -} - -// ToJSON returns the view's data for JSON output mode. -// Returns a map containing the current set of services (all rows) and count. -func (v *ServicesListView) ToJSON() any { - rows := make([]map[string]any, 0, len(v.allRows)) - for _, row := range v.allRows { - entry := map[string]any{ - "service": "", - "role": "", - "description": "", - } - if len(row) > 0 { - entry["service"] = row[0] - } - if len(row) > 1 { - entry["role"] = row[1] - } - if len(row) > 2 { - entry["description"] = row[2] - } - rows = append(rows, entry) - } - return map[string]any{ - "services": rows, - "count": len(v.allRows), - } -} diff --git a/pkg/ui/views/serviceslistview_test.go b/pkg/ui/views/serviceslistview_test.go deleted file mode 100644 index 96d0087..0000000 --- a/pkg/ui/views/serviceslistview_test.go +++ /dev/null @@ -1,1047 +0,0 @@ -package views - -import ( - "encoding/json" - "fmt" - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/components/table" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -// createServicesListFactory creates a ComponentFactory for ServicesListView testing. -func createServicesListFactory() ui.ComponentFactory { - profileCtx := profiles.GetDefaultProfileContext() - return ui.NewComponentFactory(profileCtx, components.BorderTierBlock) -} - -// createServicesListViewContext creates a ViewContext for ServicesListView testing. -func createServicesListViewContext() *engine.ViewContext { - profileCtx := profiles.GetDefaultProfileContext() - return &engine.ViewContext{ - Profile: profileCtx.Profile(), - Theme: profileCtx.Theme(), - Width: 80, - Height: 40, - Args: make(map[string]any), - } -} - -func TestNewServicesListView(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - - assert.NotNil(t, view) - assert.NotNil(t, view.factory) - assert.Equal(t, 80, view.width) - assert.Equal(t, 40, view.height) - assert.Empty(t, view.allRows) -} - -func TestServicesListView_Name(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - - assert.Equal(t, "services-list", view.Name()) -} - -func TestServicesListView_Keybindings(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - - keybindings := view.Keybindings() - assert.Len(t, keybindings, 5) - - // Verify all expected keybindings are present - expectedKeys := map[string]string{ - "↑/↓": "navigate", - "/": "search", - "s": "sort", - "enter": "view", - "q": "quit", - } - - for _, kb := range keybindings { - desc, exists := expectedKeys[kb.Key] - assert.True(t, exists, "Unexpected keybinding: %s", kb.Key) - assert.Equal(t, desc, kb.Description) - } -} - -func TestServicesListView_OnEnter(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - - cmd := view.OnEnter(ctx) - - // Verify components are initialized - assert.NotNil(t, view.searchBar) - assert.NotNil(t, view.dataTable) - assert.NotNil(t, view.statusBar) - - // Verify mock data is loaded - assert.Len(t, view.allRows, 4) - assert.Equal(t, "postgres (PostgreSQL)", view.allRows[0][0]) - assert.Equal(t, "redis (Redis)", view.allRows[1][0]) - assert.Equal(t, "mongodb (MongoDB)", view.allRows[2][0]) - assert.Equal(t, "mysql (MySQL)", view.allRows[3][0]) - - // Verify dimensions are updated - assert.Equal(t, ctx.Width, view.width) - assert.Equal(t, ctx.Height, view.height) - - // Verify command is returned (for search bar cursor blink) - assert.NotNil(t, cmd) -} - -func TestServicesListView_OnExit(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - - cmd := view.OnExit() - assert.Nil(t, cmd) -} - -func TestServicesListView_Init(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - - // Initialize the view first - view.OnEnter(ctx) - - // Test Init - cmd := view.Init() - assert.NotNil(t, cmd) -} - -func TestServicesListView_Init_NoSearchBar(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - - // Test Init without OnEnter (searchBar is nil) - cmd := view.Init() - assert.Nil(t, cmd) -} - -func TestServicesListView_Update_WindowResize(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - msg := tea.WindowSizeMsg{Width: 120, Height: 50} - model, cmd := view.Update(msg) - - updatedView := model.(*ServicesListView) - assert.Equal(t, 120, updatedView.width) - assert.Equal(t, 50, updatedView.height) - assert.Nil(t, cmd) -} - -func TestServicesListView_Update_QuitKey(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - testCases := []struct { - name string - key string - }{ - {"q key", "q"}, - {"ctrl+c", "ctrl+c"}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tc.key)} - if tc.key == "ctrl+c" { - msg = tea.KeyMsg{Type: tea.KeyCtrlC} - } - - _, cmd := view.Update(msg) - assert.NotNil(t, cmd) - }) - } -} - -func TestServicesListView_Update_FocusSearch(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - // Blur the search bar first - view.searchBar.Blur() - assert.False(t, view.searchBar.Focused()) - - // Press '/' to focus search - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")} - model, cmd := view.Update(msg) - - updatedView := model.(*ServicesListView) - assert.True(t, updatedView.searchBar.Focused()) - assert.NotNil(t, cmd) -} - -func TestServicesListView_Update_EnterKey(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - msg := tea.KeyMsg{Type: tea.KeyEnter} - model, cmd := view.Update(msg) - - // Should return the view unchanged (detail view not implemented yet) - assert.NotNil(t, model) - assert.Nil(t, cmd) -} - -func TestServicesListView_Update_EscKeyClearsSearch(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - // Set a search value - view.searchBar.SetValue("postgres") - assert.Equal(t, "postgres", view.searchBar.Value()) - - // Press Esc to clear - msg := tea.KeyMsg{Type: tea.KeyEsc} - model, cmd := view.Update(msg) - - updatedView := model.(*ServicesListView) - assert.Equal(t, "", updatedView.searchBar.Value()) - assert.Nil(t, cmd) -} - -func TestServicesListView_FilterRows_EmptyQuery(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - // Filter with empty query should show all rows - view.filterRows("") - - assert.Equal(t, 4, view.dataTable.RowCount()) -} - -func TestServicesListView_FilterRows_MatchByName(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - // Filter by name "postgres" - view.filterRows("postgres") - - assert.Equal(t, 1, view.dataTable.RowCount()) - selectedRow := view.dataTable.SelectedRow() - require.NotNil(t, selectedRow) - assert.Contains(t, selectedRow[0], "postgres", "filtered row should contain postgres") -} - -func TestServicesListView_FilterRows_MatchByRole(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - // Filter by role "Data" (all mock services have Data role) - view.filterRows("Data") - - assert.Equal(t, 4, view.dataTable.RowCount(), "all mock services have Data role") -} - -func TestServicesListView_FilterRows_MatchByDescription(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - // Filter by description text - view.filterRows("cache") - - assert.Equal(t, 1, view.dataTable.RowCount()) - selectedRow := view.dataTable.SelectedRow() - require.NotNil(t, selectedRow) - assert.Contains(t, selectedRow[0], "redis", "cache should match redis") -} - -func TestServicesListView_FilterRows_CaseInsensitive(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - testCases := []struct { - name string - query string - expectedCount int - }{ - {"lowercase", "postgres", 1}, - {"uppercase", "POSTGRES", 1}, - {"mixed case", "PoStGrEs", 1}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - view.filterRows(tc.query) - assert.Equal(t, tc.expectedCount, view.dataTable.RowCount()) - }) - } -} - -func TestServicesListView_FilterRows_NoMatches(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - // Filter with non-matching query - view.filterRows("nonexistent") - - assert.Equal(t, 0, view.dataTable.RowCount()) -} - -func TestServicesListView_FilterRows_PartialMatch(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - // Filter with partial match "post" (should match "postgres") - view.filterRows("post") - - assert.Equal(t, 1, view.dataTable.RowCount()) - selectedRow := view.dataTable.SelectedRow() - require.NotNil(t, selectedRow) - assert.Contains(t, selectedRow[0], "postgres", "partial match should find postgres") -} - -func TestServicesListView_FilterRows_MultipleMatches(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - // Filter with "o" (should match postgres, mongodb, mongo) - view.filterRows("o") - - assert.GreaterOrEqual(t, view.dataTable.RowCount(), 2) -} - -func TestServicesListView_FilterRows_NilTable(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - - // Call filterRows before OnEnter (dataTable is nil) - // Should not panic - assert.NotPanics(t, func() { - view.filterRows("postgres") - }) -} - -func TestServicesListView_SearchBarIntegration(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - // Simulate typing in the search bar - initialCount := view.dataTable.RowCount() - assert.Equal(t, 4, initialCount) - - // Type "post" - should trigger onChange callback and filter rows - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("post")} - view.searchBar.Update(msg) - - // The search bar value should be updated and onChange triggered automatically - // But in our test, the Update happened on searchBar directly, so we need to - // manually check the value and filter (in real usage, view.Update handles this) - currentValue := view.searchBar.Value() - if currentValue != "" { - view.filterRows(currentValue) - } - - // Should show only postgres (matches "post") - assert.Equal(t, 1, view.dataTable.RowCount()) -} - -func TestServicesListView_View_Rendering(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - rendered := view.View() - - // Verify the view contains expected elements - assert.NotEmpty(t, rendered) - assert.Contains(t, rendered, "Search") // Search bar label - assert.Contains(t, rendered, "Service (Technology)") // Table header - assert.Contains(t, rendered, "Role") // Table header - assert.Contains(t, rendered, "Description") // Table header -} - -func TestServicesListView_View_ZeroDimensions(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - view.width = 0 - view.height = 0 - - rendered := view.View() - assert.Empty(t, rendered) -} - -func TestServicesListView_View_NegativeDimensions(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - view.width = -10 - view.height = -10 - - rendered := view.View() - assert.Empty(t, rendered) -} - -func TestServicesListView_ComponentIntegration(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - // Test that all components are properly integrated - assert.NotNil(t, view.searchBar, "SearchBar should be initialized") - assert.NotNil(t, view.dataTable, "DataTable should be initialized") - assert.NotNil(t, view.statusBar, "StatusBar should be initialized") - - // Test search bar configuration (starts empty, placeholder is not the value) - assert.Equal(t, "", view.searchBar.Value()) - assert.True(t, view.searchBar.Focused()) - - // Test data table configuration - assert.Equal(t, 4, view.dataTable.RowCount()) - - // Test that search bar onChange is wired correctly - view.searchBar.SetValue("postgres") - assert.Equal(t, 1, view.dataTable.RowCount()) -} - -func TestServicesListView_TableNavigation(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - // Blur search bar so table can receive input - view.searchBar.Blur() - - // Initial cursor should be at 0 - assert.Equal(t, 0, view.dataTable.Cursor()) - - // Press down arrow to move cursor - msg := tea.KeyMsg{Type: tea.KeyDown} - model, _ := view.Update(msg) - updatedView := model.(*ServicesListView) - - // Cursor should have moved - assert.Equal(t, 1, updatedView.dataTable.Cursor()) -} - -func TestServicesListView_SortFunctionality(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - // Blur search bar so table can receive input - view.searchBar.Blur() - - // Get initial first row (mock data starts with postgres, redis, mongodb, mysql) - initialFirstRow := view.dataTable.SelectedRow() - assert.Equal(t, "postgres (PostgreSQL)", initialFirstRow[0]) - - // Press 's' to sort by first column (Name) - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("s")} - model, _ := view.Update(msg) - updatedView := model.(*ServicesListView) - - // After sorting ascending by name, rows should be in alphabetical order - // The cursor stays at position 0, so we get the first row which should now be "mongodb" - // (mongodb, mysql, postgres, redis) - firstRow := updatedView.dataTable.SelectedRow() - // Note: The table might reorder, but cursor position doesn't change automatically - // So we should check row count is still 4 and sorting happened - assert.Equal(t, 4, updatedView.dataTable.RowCount()) - assert.NotNil(t, firstRow) -} - -func TestServicesListView_FilterPreservesOriginalData(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - // Store original row count - originalCount := len(view.allRows) - assert.Equal(t, 4, originalCount) - - // Filter to reduce visible rows - view.filterRows("postgres") - assert.Equal(t, 1, view.dataTable.RowCount()) - - // Verify original data is unchanged - assert.Equal(t, 4, len(view.allRows)) - - // Clear filter - view.filterRows("") - assert.Equal(t, 4, view.dataTable.RowCount()) - - // Verify all original rows are back - assert.Equal(t, originalCount, view.dataTable.RowCount()) -} - -func TestServicesListView_ResponsiveLayout(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - testCases := []struct { - name string - width int - height int - }{ - {"large screen", 120, 60}, - {"medium screen", 80, 40}, - {"small screen", 60, 20}, - {"minimal screen", 40, 10}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - msg := tea.WindowSizeMsg{Width: tc.width, Height: tc.height} - model, _ := view.Update(msg) - updatedView := model.(*ServicesListView) - - // Verify dimensions are updated - assert.Equal(t, tc.width, updatedView.width) - assert.Equal(t, tc.height, updatedView.height) - - // Verify view can be rendered without panic - assert.NotPanics(t, func() { - rendered := updatedView.View() - assert.NotEmpty(t, rendered) - }) - }) - } -} - -func TestServicesListView_StatusBarMessage(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - // Filter to show partial results - view.filterRows("running") - - rendered := view.View() - - // Status bar should show filtered count message - // Note: The actual message format may vary, just check it's present - assert.NotEmpty(t, rendered) -} - -func TestServicesListView_EmptyTable(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - - // Set empty rows before OnEnter - view.allRows = []table.Row{} - - view.OnEnter(ctx) - - // Should handle empty table gracefully - assert.NotPanics(t, func() { - rendered := view.View() - assert.NotEmpty(t, rendered) // Still has search bar and status - }) -} - -func TestServicesListView_LargeDataset(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - - // Create a large dataset - largeRows := make([]table.Row, 100) - for i := 0; i < 100; i++ { - largeRows[i] = table.Row{ - "service" + string(rune(i)), - "running", - "8080", - } - } - - view.OnEnter(ctx) - view.allRows = largeRows - view.dataTable.SetRows(largeRows) - - // Test filtering on large dataset - view.filterRows("service1") - - // Should efficiently filter without panic - assert.NotPanics(t, func() { - rendered := view.View() - assert.NotEmpty(t, rendered) - }) -} - -func TestServicesListView_ImplementsViewInterface(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - - // Verify view implements engine.View interface - var _ engine.View = view - - // Test all required methods are implemented - assert.NotPanics(t, func() { - _ = view.Init() - _, _ = view.Update(tea.KeyMsg{}) - _ = view.View() - _ = view.OnEnter(createServicesListViewContext()) - _ = view.OnExit() - _ = view.Name() - _ = view.Keybindings() - }) -} - -// Benchmark tests -func BenchmarkServicesListView_FilterRows(b *testing.B) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - view.filterRows("postgres") - } -} - -func BenchmarkServicesListView_View(b *testing.B) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = view.View() - } -} - -func BenchmarkServicesListView_Update(b *testing.B) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - msg := tea.KeyMsg{Type: tea.KeyDown} - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _ = view.Update(msg) - } -} - -// T118: Test search functionality (type "/redis") -func TestServicesListView_SearchFunctionality_T118(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - // Initial state: all rows visible - initialCount := view.dataTable.RowCount() - assert.Equal(t, 4, initialCount, "should start with all mock rows") - - t.Run("forward slash focuses search bar", func(t *testing.T) { - // Blur search bar first - view.searchBar.Blur() - assert.False(t, view.searchBar.Focused()) - - // Press "/" to focus search - model, cmd := view.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) - updatedView := model.(*ServicesListView) - - assert.True(t, updatedView.searchBar.Focused(), "search bar should be focused after /") - assert.NotNil(t, cmd, "should return focus command") - }) - - t.Run("typing filters table in real-time", func(t *testing.T) { - // Reset to clean state - view.searchBar.SetValue("") - view.filterRows("") - - // Type "redis" character by character - searchTerm := "redis" - for _, char := range searchTerm { - // Update search bar value - currentValue := view.searchBar.Value() + string(char) - view.searchBar.SetValue(currentValue) - // Trigger filter (in real app, this is done via onChange callback) - view.filterRows(currentValue) - } - - // Should show only redis row - assert.Equal(t, 1, view.dataTable.RowCount(), "should filter to 1 redis row") - - selectedRow := view.dataTable.SelectedRow() - require.NotNil(t, selectedRow) - assert.Contains(t, strings.ToLower(selectedRow[0]), "redis", "filtered row should contain 'redis'") - }) - - t.Run("clearing search restores all rows", func(t *testing.T) { - // Set a filter first - view.searchBar.SetValue("postgres") - view.filterRows("postgres") - assert.Equal(t, 1, view.dataTable.RowCount()) - - // Clear search - view.searchBar.SetValue("") - view.filterRows("") - - // All rows should be back - assert.Equal(t, 4, view.dataTable.RowCount(), "clearing search should restore all rows") - }) - - t.Run("search is case-insensitive", func(t *testing.T) { - testCases := []string{"REDIS", "Redis", "redis", "ReDiS"} - for _, searchTerm := range testCases { - view.searchBar.SetValue(searchTerm) - view.filterRows(searchTerm) - assert.Equal(t, 1, view.dataTable.RowCount(), "search for %q should find redis", searchTerm) - } - }) - - t.Run("search matches any column", func(t *testing.T) { - // Search by role "Data" should match multiple services - view.searchBar.SetValue("Data") - view.filterRows("Data") - assert.GreaterOrEqual(t, view.dataTable.RowCount(), 1, "should match services with Data role") - }) -} - -// T119: Test detail navigation (select service, press Enter, press Backspace) -func TestServicesListView_DetailNavigation_T119(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - t.Run("enter key triggers navigation", func(t *testing.T) { - // Select first service (cursor starts at 0) - assert.Equal(t, 0, view.dataTable.Cursor()) - - // Press Enter - model, _ := view.Update(tea.KeyMsg{Type: tea.KeyEnter}) - updatedView := model.(*ServicesListView) - - // Should return the view (detail view not implemented yet in current code) - assert.NotNil(t, updatedView, "view should be returned") - // In future implementation, cmd would navigate to detail view - // For now, we just verify no crash - }) - - t.Run("backspace in search bar deletes characters", func(t *testing.T) { - // Type "postgres" - view.searchBar.SetValue("postgres") - view.filterRows("postgres") - assert.Equal(t, 1, view.dataTable.RowCount()) - - // Focus search bar - view.searchBar.Focus() - - // Press backspace - model, _ := view.Update(tea.KeyMsg{Type: tea.KeyBackspace}) - updatedView := model.(*ServicesListView) - - // Search value should be updated (bubbles handles the actual deletion) - assert.NotNil(t, updatedView, "view should handle backspace") - }) - - t.Run("esc key clears search when focused", func(t *testing.T) { - // Set search value - view.searchBar.SetValue("postgres") - view.searchBar.Focus() - assert.True(t, view.searchBar.Focused()) - - // Press Esc - model, _ := view.Update(tea.KeyMsg{Type: tea.KeyEsc}) - updatedView := model.(*ServicesListView) - - // Search should be cleared - assert.Equal(t, "", updatedView.searchBar.Value(), "esc should clear search") - }) - - t.Run("arrow keys navigate table when search unfocused", func(t *testing.T) { - // Blur search bar - view.searchBar.Blur() - - // Verify cursor starts at 0 - initialCursor := view.dataTable.Cursor() - assert.Equal(t, 0, initialCursor) - - // Press down arrow - model, _ := view.Update(tea.KeyMsg{Type: tea.KeyDown}) - updatedView := model.(*ServicesListView) - - // Cursor should have moved - assert.Equal(t, 1, updatedView.dataTable.Cursor(), "down arrow should move cursor") - }) -} - -// T120: Test with 50+ services (pagination) -func TestServicesListView_LargeDataset_Pagination_T120(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - - t.Run("handles 100 services without performance issues", func(t *testing.T) { - // Create 100 services - largeRows := make([]table.Row, 100) - for i := 0; i < 100; i++ { - largeRows[i] = table.Row{ - fmt.Sprintf("service-%03d (Technology-%d)", i, i), - "Data", - fmt.Sprintf("Description for service %d", i), - } - } - - // Set rows in context - ctx.Args["rows"] = largeRows - - // Initialize view - view.OnEnter(ctx) - - // Verify all rows are loaded - assert.Equal(t, 100, len(view.allRows), "should load all 100 rows") - assert.Equal(t, 100, view.dataTable.RowCount(), "table should show all 100 rows") - }) - - t.Run("navigation works smoothly with large dataset", func(t *testing.T) { - // Create 100 services - largeRows := make([]table.Row, 100) - for i := 0; i < 100; i++ { - largeRows[i] = table.Row{ - fmt.Sprintf("service-%03d", i), - "running", - fmt.Sprintf("%d", 5000+i), - } - } - - ctx.Args["rows"] = largeRows - view.OnEnter(ctx) - view.searchBar.Blur() // Unfocus search so table receives input - - // Navigate down 50 times - for i := 0; i < 50; i++ { - model, _ := view.Update(tea.KeyMsg{Type: tea.KeyDown}) - view = model.(*ServicesListView) - } - - // Should be at position 50 - assert.Equal(t, 50, view.dataTable.Cursor(), "should navigate to position 50") - - // Navigate up 25 times - for i := 0; i < 25; i++ { - model, _ := view.Update(tea.KeyMsg{Type: tea.KeyUp}) - view = model.(*ServicesListView) - } - - // Should be at position 25 - assert.Equal(t, 25, view.dataTable.Cursor(), "should navigate back to position 25") - }) - - t.Run("search filtering works efficiently with large dataset", func(t *testing.T) { - // Create 100 services - largeRows := make([]table.Row, 100) - for i := 0; i < 100; i++ { - name := fmt.Sprintf("service-%03d", i) - if i%10 == 5 { - name = fmt.Sprintf("redis-%03d", i) // Every 10th service is redis-* - } - largeRows[i] = table.Row{name, "running", fmt.Sprintf("%d", 5000+i)} - } - - ctx.Args["rows"] = largeRows - view.OnEnter(ctx) - - // Filter by "redis" - view.filterRows("redis") - - // Should show only redis services (10 total: indices 5, 15, 25, ..., 95) - assert.Equal(t, 10, view.dataTable.RowCount(), "should filter to 10 redis services") - - // Verify first filtered result - row := view.dataTable.SelectedRow() - require.NotNil(t, row) - assert.Contains(t, row[0], "redis", "filtered row should contain 'redis'") - - // Navigate through some filtered results to verify they all match - view.searchBar.Blur() // Ensure table can receive input - for i := 0; i < 5; i++ { - model, _ := view.Update(tea.KeyMsg{Type: tea.KeyDown}) - view = model.(*ServicesListView) - row := view.dataTable.SelectedRow() - assert.Contains(t, row[0], "redis", "all filtered rows should contain 'redis'") - } - }) - - t.Run("rendering large dataset completes without panic", func(t *testing.T) { - // Create 200 services for stress test - largeRows := make([]table.Row, 200) - for i := 0; i < 200; i++ { - largeRows[i] = table.Row{ - fmt.Sprintf("service-%03d", i), - "running", - fmt.Sprintf("Description for service %d with some longer text", i), - } - } - - ctx.Args["rows"] = largeRows - view.OnEnter(ctx) - - // Render should complete without panic - assert.NotPanics(t, func() { - output := view.View() - assert.NotEmpty(t, output, "should render large dataset") - }) - }) - - t.Run("status bar shows filtered count for large dataset", func(t *testing.T) { - // Create 150 services - largeRows := make([]table.Row, 150) - for i := 0; i < 150; i++ { - role := "Data" - if i%3 == 0 { - role = "Infrastructure" - } - largeRows[i] = table.Row{ - fmt.Sprintf("service-%03d", i), - role, - "Description", - } - } - - ctx.Args["rows"] = largeRows - view.OnEnter(ctx) - - // Filter by "Infrastructure" - view.filterRows("Infrastructure") - - // Should show filtered count (50 services) - filteredCount := view.dataTable.RowCount() - assert.Equal(t, 50, filteredCount, "should filter to 50 infrastructure services") - - // Render and check output contains filtered count info - output := view.View() - assert.NotEmpty(t, output, "should render with filtered count") - }) -} - -// T243: JSON marshaling tests for ServicesListView.ToJSON -func TestServicesListView_ToJSON(t *testing.T) { - factory := createServicesListFactory() - view := NewServicesListView(factory) - ctx := createServicesListViewContext() - view.OnEnter(ctx) - - t.Run("implements JSONExporter interface", func(t *testing.T) { - var _ engine.JSONExporter = view - }) - - t.Run("returns JSON-marshallable data", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - require.NoError(t, err) - assert.NotEmpty(t, data) - }) - - t.Run("contains expected top-level keys", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - - assert.Contains(t, result, "services") - assert.Contains(t, result, "count") - }) - - t.Run("count matches number of services", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - - count := result["count"].(float64) - assert.Equal(t, float64(4), count, "count should equal number of mock services") - }) - - t.Run("services contains correct fields", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - - services := result["services"].([]any) - require.Len(t, services, 4) - - first := services[0].(map[string]any) - assert.Contains(t, first, "service") - assert.Contains(t, first, "role") - assert.Contains(t, first, "description") - assert.Equal(t, "postgres (PostgreSQL)", first["service"]) - }) - - t.Run("empty view returns count zero", func(t *testing.T) { - emptyView := NewServicesListView(factory) - // Do not call OnEnter — allRows stays empty - - data, err := json.Marshal(emptyView.ToJSON()) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - - count := result["count"].(float64) - assert.Equal(t, float64(0), count) - - services := result["services"].([]any) - assert.Empty(t, services) - }) -} diff --git a/pkg/ui/views/themelistview.go b/pkg/ui/views/themelistview.go deleted file mode 100644 index c1f4235..0000000 --- a/pkg/ui/views/themelistview.go +++ /dev/null @@ -1,354 +0,0 @@ -package views - -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components/search" - "github.com/arc-framework/arc-cli/pkg/ui/components/status" - "github.com/arc-framework/arc-cli/pkg/ui/components/table" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -const viewThemeList = "theme-list" - -// ThemeListView displays all available themes in a searchable DataTable -// with a color preview column. -// -// Layout: -// -// ┌────────────────────────────────────────────────┐ -// │ Search: [enterprise___________________________] │ -// ├───────────────┬───────────────┬────────────────┤ -// │ Theme │ Description │ Colors │ -// │───────────────┼───────────────┼────────────────│ -// │ enterprise │ Corporate… │ ██ ██ ██ ██ │ -// │ saiyan │ Dragon Ball… │ ██ ██ ██ ██ │ -// ├────────────────────────────────────────────────┤ -// │ ↑/↓: navigate • /: search • q: quit │ -// └────────────────────────────────────────────────┘ -// -// Design: 017-ui-engine Phase 9 (T350-T351) -type ThemeListView struct { - factory ui.ComponentFactory - searchBar *search.SearchBar - dataTable *table.DataTable - statusBar *status.StatusBar - allRows []table.Row - allThemes []*themes.Theme - width int - height int -} - -// NewThemeListView creates a new ThemeListView with the given ComponentFactory. -func NewThemeListView(factory ui.ComponentFactory) *ThemeListView { - return &ThemeListView{ - factory: factory, - width: 80, - height: 40, - allRows: []table.Row{}, - } -} - -// Init initializes the ThemeListView (Bubble Tea lifecycle). -func (v *ThemeListView) Init() tea.Cmd { - if v.searchBar != nil { - return v.searchBar.Init() - } - return nil -} - -// Update handles messages and updates the ThemeListView state. -func (v *ThemeListView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - return v.handleWindowResize(msg) - case tea.KeyMsg: - return v.handleKeyPress(msg) - } - return v.updateComponents(msg) -} - -// handleWindowResize updates component dimensions on terminal resize. -// -//nolint:dupl // boilerplate resize logic is structurally identical across list views by design -func (v *ThemeListView) handleWindowResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { - v.width = msg.Width - v.height = msg.Height - - if v.searchBar != nil { - v.searchBar.SetWidth(v.width - 4) - } - if v.dataTable != nil { - v.dataTable.SetWidth(v.width) - // Search bar renders with RoundedBorder = 3 lines; 2 empty spacers; 1 status bar = 6 total - tableHeight := v.height - 6 - if tableHeight < 3 { - tableHeight = 3 - } - v.dataTable.SetHeight(tableHeight) - } - return v, nil -} - -// handleKeyPress processes keyboard input. -func (v *ThemeListView) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", keyCtrlC: - return v, tea.Quit - - case "/": - if v.searchBar != nil { - return v, v.searchBar.Focus() - } - return v, nil - - case keyEsc: - if v.searchBar != nil && v.searchBar.Focused() { - searchModel, cmd := v.searchBar.Update(msg) - v.searchBar = searchModel.(*search.SearchBar) - return v, cmd - } - return v, nil - } - - return v.updateComponents(msg) -} - -// updateComponents delegates messages to child components. -func (v *ThemeListView) updateComponents(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - if v.searchBar != nil { - searchModel, cmd := v.searchBar.Update(msg) - v.searchBar = searchModel.(*search.SearchBar) - cmds = append(cmds, cmd) - } - - if v.dataTable != nil && (v.searchBar == nil || !v.searchBar.Focused()) { - tableModel, cmd := v.dataTable.Update(msg) - v.dataTable = tableModel.(*table.DataTable) - cmds = append(cmds, cmd) - } - - return v, tea.Batch(cmds...) -} - -// View renders the ThemeListView as a string. -func (v *ThemeListView) View() string { - if v.width <= 0 || v.height <= 0 { - return "" - } - - var searchContent string - if v.searchBar != nil { - searchContent = v.searchBar.View() - } - - var tableContent string - if v.dataTable != nil { - tableContent = v.dataTable.View() - } - - var statusContent string - if v.statusBar != nil { - message := "" - if v.dataTable != nil { - rowCount := v.dataTable.RowCount() - total := len(v.allRows) - if rowCount < total { - message = lipgloss.NewStyle().Render( - strings.Join([]string{ - "Showing", intToString(rowCount), - "of", intToString(total), - "themes", - }, " "), - ) - } - } - statusContent = v.statusBar.Render(v.width, v.Keybindings(), message) - } - - return lipgloss.JoinVertical( - lipgloss.Left, - searchContent, - "", - tableContent, - "", - statusContent, - ) -} - -// OnEnter is called when the ThemeListView becomes active. -// Loads all themes from the repository and initializes components. -func (v *ThemeListView) OnEnter(ctx *engine.ViewContext) tea.Cmd { - theme := ctx.Theme - - v.width = ctx.Width - v.height = ctx.Height - - // Initialize search bar - v.searchBar = search.NewSearchBar(theme, "Search themes...") - v.searchBar.SetWidth(v.width - 4) - v.searchBar.SetOnChange(func(query string) { - v.filterRows(query) - }) - - // Define columns — T351: includes a color preview column - columns := []table.Column{ - {Title: "Theme", Width: 18}, - {Title: "Description", Width: 40}, - {Title: "Colors", Width: 14}, - } - - // Load all themes - loader := themes.NewLoader() - themeNames, err := loader.List() - if err == nil { - v.allThemes = make([]*themes.Theme, 0, len(themeNames)) - v.allRows = make([]table.Row, 0, len(themeNames)) - for _, name := range themeNames { - t, loadErr := loader.Load(name) - if loadErr != nil { - continue - } - v.allThemes = append(v.allThemes, t) - - desc := t.Description - if len(desc) > 38 { - desc = desc[:35] + "..." - } - - // T351: Build color preview swatches from banner gradient - colorPreview := buildColorSwatches(t.Colors.BannerGradient) - - v.allRows = append(v.allRows, table.Row{name, desc, colorPreview}) - } - } - - v.dataTable = table.NewDataTable(columns, v.allRows, theme) - - // Search bar with border = 3 lines; 2 spacers; 1 status = 6 overhead - tableHeight := v.height - 6 - if tableHeight < 3 { - tableHeight = 3 - } - v.dataTable.SetWidth(v.width) - v.dataTable.SetHeight(tableHeight) - - v.statusBar = status.NewStatusBar(theme) - - return v.searchBar.Init() -} - -// OnExit is called when the view is replaced. -func (v *ThemeListView) OnExit() tea.Cmd { - return nil -} - -// Name returns the unique identifier for this view. -func (v *ThemeListView) Name() string { - return viewThemeList -} - -// Keybindings returns the keyboard shortcuts for this view. -func (v *ThemeListView) Keybindings() []engine.KeyBinding { - return []engine.KeyBinding{ - {Key: "↑/↓", Description: "navigate"}, - {Key: "/", Description: "search"}, - {Key: "q", Description: "quit"}, - } -} - -// filterRows filters displayed rows by the search query. -func (v *ThemeListView) filterRows(query string) { - if v.dataTable == nil { - return - } - if query == "" { - v.dataTable.SetRows(v.allRows) - return - } - queryLower := strings.ToLower(query) - var filtered []table.Row - for _, row := range v.allRows { - // Only search name and description (not the ANSI color preview) - for i, cell := range row { - if i >= 2 { - break - } - if strings.Contains(strings.ToLower(cell), queryLower) { - filtered = append(filtered, row) - break - } - } - } - v.dataTable.SetRows(filtered) -} - -// ToJSON returns the view's data for JSON output mode. -func (v *ThemeListView) ToJSON() any { - items := make([]map[string]any, 0, len(v.allThemes)) - for i, t := range v.allThemes { - name := "" - if i < len(v.allRows) { - name = v.allRows[i][0] - } - items = append(items, map[string]any{ - "name": name, - "description": t.Description, - "primary": t.Colors.Primary, - "secondary": t.Colors.Secondary, - }) - } - return map[string]any{ - "themes": items, - "count": len(v.allThemes), - } -} - -// buildColorSwatches renders colored block characters for the banner gradient colors. -// Returns a string with up to 4 colored "██" swatches separated by spaces. -func buildColorSwatches(gradient []string) string { - if len(gradient) == 0 { - return "" - } - - // Limit to 4 colors so the column stays narrow - limit := len(gradient) - if limit > 4 { - limit = 4 - } - - parts := make([]string, 0, limit) - for _, hex := range gradient[:limit] { - swatch := lipgloss.NewStyle().Foreground(lipgloss.Color(hex)).Render("██") - parts = append(parts, swatch) - } - return strings.Join(parts, " ") -} - -// intToString converts an int to string for display (avoids fmt import). -func intToString(n int) string { - if n == 0 { - return "0" - } - neg := false - if n < 0 { - neg = true - n = -n - } - digits := make([]byte, 0, 10) - for n > 0 { - digits = append([]byte{byte('0' + n%10)}, digits...) - n /= 10 - } - if neg { - digits = append([]byte{'-'}, digits...) - } - return string(digits) -} diff --git a/pkg/ui/views/themelistview_test.go b/pkg/ui/views/themelistview_test.go deleted file mode 100644 index 95392d8..0000000 --- a/pkg/ui/views/themelistview_test.go +++ /dev/null @@ -1,248 +0,0 @@ -package views - -import ( - "encoding/json" - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -func createThemeListFactory() ui.ComponentFactory { - profileCtx := profiles.GetDefaultProfileContext() - return ui.NewComponentFactory(profileCtx, components.BorderTierNone) -} - -func createThemeListContext() *engine.ViewContext { - profileCtx := profiles.GetDefaultProfileContext() - return engine.NewViewContext(profileCtx.Profile(), profileCtx.Theme(), 80, 40, nil) -} - -func TestNewThemeListView(t *testing.T) { - factory := createThemeListFactory() - view := NewThemeListView(factory) - - require.NotNil(t, view) - assert.Equal(t, 80, view.width) - assert.Equal(t, 40, view.height) - assert.NotNil(t, view.factory) -} - -func TestThemeListView_Name(t *testing.T) { - factory := createThemeListFactory() - view := NewThemeListView(factory) - - assert.Equal(t, viewThemeList, view.Name()) -} - -func TestThemeListView_Init(t *testing.T) { - factory := createThemeListFactory() - view := NewThemeListView(factory) - - // Before OnEnter, Init returns nil - cmd := view.Init() - assert.Nil(t, cmd) -} - -func TestThemeListView_OnEnter(t *testing.T) { - factory := createThemeListFactory() - view := NewThemeListView(factory) - - ctx := createThemeListContext() - cmd := view.OnEnter(ctx) - - // Init returns non-nil from searchBar.Init() - _ = cmd - - assert.NotNil(t, view.searchBar) - assert.NotNil(t, view.dataTable) - assert.NotNil(t, view.statusBar) - assert.Equal(t, 80, view.width) - assert.Equal(t, 40, view.height) -} - -func TestThemeListView_OnEnter_LoadsThemes(t *testing.T) { - factory := createThemeListFactory() - view := NewThemeListView(factory) - - ctx := createThemeListContext() - view.OnEnter(ctx) - - // Should load at least some themes from the embedded theme files - assert.NotEmpty(t, view.allRows, "should load at least one theme") - assert.Len(t, view.allRows, len(view.allThemes), "rows and themes must have same count") -} - -func TestThemeListView_View_BeforeOnEnter(t *testing.T) { - factory := createThemeListFactory() - view := NewThemeListView(factory) - - // Before OnEnter, components are nil — View returns placeholder layout (may include empty lines) - // This is expected and acceptable behavior for the JoinVertical skeleton - output := view.View() - // Strip whitespace for the assertion — the layout is effectively empty - stripped := strings.TrimSpace(output) - assert.Empty(t, stripped, "View without components should render nothing meaningful") -} - -func TestThemeListView_View_AfterOnEnter(t *testing.T) { - factory := createThemeListFactory() - view := NewThemeListView(factory) - - ctx := createThemeListContext() - view.OnEnter(ctx) - - output := view.View() - assert.NotEmpty(t, output) -} - -func TestThemeListView_View_ZeroDimensions(t *testing.T) { - factory := createThemeListFactory() - view := NewThemeListView(factory) - - ctx := createThemeListContext() - view.OnEnter(ctx) - - view.width = 0 - view.height = 0 - - output := view.View() - assert.Empty(t, output, "View with zero dimensions should return empty string") -} - -func TestThemeListView_Update_WindowResize(t *testing.T) { - factory := createThemeListFactory() - view := NewThemeListView(factory) - - ctx := createThemeListContext() - view.OnEnter(ctx) - - msg := tea.WindowSizeMsg{Width: 120, Height: 50} - updatedModel, cmd := view.Update(msg) - - assert.Nil(t, cmd) - updated := updatedModel.(*ThemeListView) - assert.Equal(t, 120, updated.width) - assert.Equal(t, 50, updated.height) -} - -func TestThemeListView_Update_Quit(t *testing.T) { - factory := createThemeListFactory() - view := NewThemeListView(factory) - - ctx := createThemeListContext() - view.OnEnter(ctx) - - _, cmd := view.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) - assert.NotNil(t, cmd, "q key should produce quit command") -} - -func TestThemeListView_OnExit(t *testing.T) { - factory := createThemeListFactory() - view := NewThemeListView(factory) - - cmd := view.OnExit() - assert.Nil(t, cmd) -} - -func TestThemeListView_Keybindings(t *testing.T) { - factory := createThemeListFactory() - view := NewThemeListView(factory) - - kbs := view.Keybindings() - assert.NotEmpty(t, kbs) - - keys := map[string]bool{} - for _, kb := range kbs { - keys[kb.Key] = true - } - assert.True(t, keys["↑/↓"]) - assert.True(t, keys["/"]) - assert.True(t, keys["q"]) -} - -func TestThemeListView_ToJSON(t *testing.T) { - factory := createThemeListFactory() - view := NewThemeListView(factory) - - ctx := createThemeListContext() - view.OnEnter(ctx) - - data := view.ToJSON() - require.NotNil(t, data) - - raw, err := json.Marshal(data) - require.NoError(t, err) - assert.True(t, json.Valid(raw)) - - var result map[string]any - require.NoError(t, json.Unmarshal(raw, &result)) - - assert.Contains(t, result, "themes") - assert.Contains(t, result, "count") - - themesSlice, ok := result["themes"].([]any) - require.True(t, ok, "'themes' must be an array") - assert.NotEmpty(t, themesSlice, "should contain at least one theme") - - // Each theme entry must have name and description - for _, item := range themesSlice { - themeMap, ok := item.(map[string]any) - require.True(t, ok) - assert.Contains(t, themeMap, "name") - assert.Contains(t, themeMap, "description") - } -} - -func TestThemeListView_ImplementsView(t *testing.T) { - factory := createThemeListFactory() - view := NewThemeListView(factory) - - var _ engine.View = view - var _ tea.Model = view - var _ engine.JSONExporter = view -} - -func TestThemeListView_ColorSwatches(t *testing.T) { - factory := createThemeListFactory() - view := NewThemeListView(factory) - - ctx := createThemeListContext() - view.OnEnter(ctx) - - // Each row should have 3 columns: name, description, colors - for _, row := range view.allRows { - require.Len(t, row, 3, "each row should have 3 columns") - // The Colors column (index 2) should not be empty for themes with gradients - // (some themes may have empty gradients, so we just verify it doesn't panic) - _ = row[2] - } -} - -// T351: TestBuildColorSwatches tests the helper that renders ANSI color blocks. -func TestBuildColorSwatches(t *testing.T) { - t.Run("empty gradient", func(t *testing.T) { - result := buildColorSwatches([]string{}) - assert.Empty(t, result) - }) - - t.Run("single color", func(t *testing.T) { - result := buildColorSwatches([]string{"#FF0000"}) - assert.NotEmpty(t, result) - }) - - t.Run("limits to 4 colors", func(t *testing.T) { - gradient := []string{"#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF"} - result := buildColorSwatches(gradient) - // Should have at most 4 swatches (each "██" renders as 2 chars + ANSI codes) - // We just verify the result is non-empty and doesn't panic - assert.NotEmpty(t, result) - }) -} diff --git a/pkg/ui/views/versionview.go b/pkg/ui/views/versionview.go deleted file mode 100644 index 022bb3c..0000000 --- a/pkg/ui/views/versionview.go +++ /dev/null @@ -1,300 +0,0 @@ -package views - -import ( - "runtime" - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components/badge" - "github.com/arc-framework/arc-cli/pkg/ui/components/status" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -const ( - viewVersion = "version" -) - -// VersionView displays CLI version information. -// Compact mode: shows a badge with version string. -// Verbose mode: shows detailed table with version, commit, build date, Go version. -// -// Layout (compact): -// -// ┌────────────────────────────────────────┐ -// │ │ -// │ [ARC CLI v1.0.0] │ -// │ │ -// ├────────────────────────────────────────┤ -// │ q: quit │ -// └────────────────────────────────────────┘ -// -// Layout (verbose): -// -// ┌────────────────────────────────────────┐ -// │ │ -// │ Version: 1.0.0 │ -// │ Commit: abc1234 │ -// │ Build Date: 2026-02-28T10:00:00Z │ -// │ Go Version: go1.24.0 │ -// │ │ -// ├────────────────────────────────────────┤ -// │ q: quit │ -// └────────────────────────────────────────┘ -// -// Design: 017-ui-engine Phase 6 (T256-T261) -type VersionView struct { - factory ui.ComponentFactory - version string - commit string - buildDate string - verbose bool - width int - height int - statusBar *status.StatusBar - theme *themes.Theme -} - -// NewVersionView creates a new VersionView with the given ComponentFactory and version info. -// The factory is used to access profile and theme information for rendering. -// When verbose is false, a compact badge is shown; when true, a detailed table is rendered. -func NewVersionView(factory ui.ComponentFactory, version, commit, buildDate string, verbose bool) *VersionView { - return &VersionView{ - factory: factory, - version: version, - commit: commit, - buildDate: buildDate, - verbose: verbose, - width: 80, // Default width - height: 24, // Default height - } -} - -// Init initializes the VersionView (Bubble Tea lifecycle). -// No commands are needed for this static view. -func (v *VersionView) Init() tea.Cmd { - return nil -} - -// Update handles messages and updates the VersionView state. -// Handles window resize and keyboard input (q/ctrl+c to quit). -func (v *VersionView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - return v, nil - - case tea.KeyMsg: - switch msg.String() { - case "q", keyCtrlC: - return v, tea.Quit - } - } - - return v, nil -} - -// View renders the VersionView as a string. -// Compact mode: renders a badge with the version string centered on screen. -// Verbose mode: renders a detailed table with all version fields + status bar. -func (v *VersionView) View() string { - if v.width <= 0 || v.height <= 0 { - return "" - } - - var content string - if v.verbose { - content = v.renderVerboseTable() - } else { - content = v.renderCompactBadge() - } - - // Render status bar at the bottom - var statusContent string - if v.statusBar != nil && v.theme != nil { - statusContent = v.statusBar.Render(v.width, v.Keybindings(), "") - } - - // Calculate vertical centering - contentHeight := lipgloss.Height(content) - statusHeight := 0 - if statusContent != "" { - statusHeight = lipgloss.Height(statusContent) - } - availableSpace := v.height - contentHeight - statusHeight - - var spacer string - if availableSpace > 0 { - topPadding := availableSpace / 2 - if topPadding > 0 { - spacer = lipgloss.NewStyle().Height(topPadding).Render("") - } - } - - if statusContent != "" { - return lipgloss.JoinVertical( - lipgloss.Left, - spacer, - content, - statusContent, - ) - } - - return lipgloss.JoinVertical( - lipgloss.Left, - spacer, - content, - ) -} - -// OnEnter is called when the VersionView becomes active. -// Initializes the status bar component and stores theme from context. -func (v *VersionView) OnEnter(ctx *engine.ViewContext) tea.Cmd { - // Store theme for rendering - v.theme = ctx.Theme - - // Initialize status bar with theme from context - v.statusBar = status.NewStatusBar(ctx.Theme) - - // Update dimensions from context - v.width = ctx.Width - v.height = ctx.Height - - return nil -} - -// OnExit is called when the VersionView is replaced by another view. -// No cleanup needed for this view. -func (v *VersionView) OnExit() tea.Cmd { - return nil -} - -// Name returns the unique identifier for this view. -func (v *VersionView) Name() string { - return viewVersion -} - -// Keybindings returns the keyboard shortcuts for the VersionView. -func (v *VersionView) Keybindings() []engine.KeyBinding { - return []engine.KeyBinding{ - {Key: "q", Description: "quit"}, - } -} - -// ToJSON returns version data for JSON output mode. -// Returns a map with version, commit, buildDate, and goVersion fields. -func (v *VersionView) ToJSON() any { - return map[string]any{ - "version": v.version, - "commit": v.commit, - "buildDate": v.buildDate, - "goVersion": runtime.Version(), - } -} - -// renderCompactBadge renders a centered badge displaying the version string. -// Uses the theme's primary color when a theme is available, falling back to Info style. -func (v *VersionView) renderCompactBadge() string { - text := "ARC CLI v" + v.version - - var badgeStr string - if v.theme != nil { - // Use theme primary color for badge background - bgColor := v.theme.Colors.PrimaryColor() - badgeStyle := lipgloss.NewStyle(). - Background(bgColor). - Foreground(lipgloss.Color("#FFFFFF")). - Padding(0, 2). - Bold(true) - badgeStr = badgeStyle.Render(text) - } else { - // Fallback: use badge component with Info style and a nil theme stub - b := badge.NewBadge(text, badge.Info, nil) - badgeStr = b.Render() - if badgeStr == "" { - // If badge returns empty (nil theme), render plain text - badgeStr = text - } - } - - // Center the badge horizontally - if v.width > 0 { - return lipgloss.NewStyle().Width(v.width).Align(lipgloss.Center).Render(badgeStr) - } - return badgeStr -} - -// renderVerboseTable renders a detailed table showing all version fields. -// Each row shows a label and a value aligned in a two-column layout. -func (v *VersionView) renderVerboseTable() string { - goVersion := runtime.Version() - - rows := []struct { - label string - value string - }{ - {"Version", v.version}, - {"Commit", v.commit}, - {"Build Date", v.buildDate}, - {"Go Version", goVersion}, - } - - // Determine column widths - maxLabel := 0 - for _, row := range rows { - if len(row.label) > maxLabel { - maxLabel = len(row.label) - } - } - - // Build styles - var labelStyle, valueStyle lipgloss.Style - if v.theme != nil { - labelStyle = lipgloss.NewStyle(). - Foreground(v.theme.Colors.PrimaryColor()). - Bold(true). - Width(maxLabel + 2) - valueStyle = lipgloss.NewStyle(). - Foreground(v.theme.Colors.ForegroundColor()) - } else { - labelStyle = lipgloss.NewStyle().Bold(true).Width(maxLabel + 2) - valueStyle = lipgloss.NewStyle() - } - - // Build each row - lines := make([]string, 0, len(rows)) - for _, row := range rows { - label := labelStyle.Render(row.label + ":") - value := valueStyle.Render(row.value) - lines = append(lines, lipgloss.JoinHorizontal(lipgloss.Top, label, value)) - } - - // Add a title above the table - var titleStyle lipgloss.Style - if v.theme != nil { - titleStyle = lipgloss.NewStyle(). - Foreground(v.theme.Colors.PrimaryColor()). - Bold(true). - MarginBottom(1) - } else { - titleStyle = lipgloss.NewStyle().Bold(true).MarginBottom(1) - } - - title := titleStyle.Render("ARC CLI — Version Information") - tableContent := strings.Join(lines, "\n") - - combined := lipgloss.JoinVertical(lipgloss.Left, title, tableContent) - - // Add left padding and center vertically - paddedStyle := lipgloss.NewStyle().Padding(1, 4) - padded := paddedStyle.Render(combined) - - if v.width > 0 { - return lipgloss.NewStyle().Width(v.width).Align(lipgloss.Left).Render(padded) - } - return padded -} diff --git a/pkg/ui/views/versionview_test.go b/pkg/ui/views/versionview_test.go deleted file mode 100644 index 5730eef..0000000 --- a/pkg/ui/views/versionview_test.go +++ /dev/null @@ -1,351 +0,0 @@ -package views - -import ( - "runtime" - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// createVersionTestProfile creates a test profile for VersionView tests. -func createVersionTestProfile() *profiles.Profile { - return &profiles.Profile{ - ID: "test-profile", - Name: "Test Profile", - Logo: "TEST", - PrimaryColor: "#00ADD8", - TierNames: []string{"Basic", "Standard", "Premium"}, - } -} - -// createVersionTestTheme creates a test theme for VersionView tests. -func createVersionTestTheme() *themes.Theme { - return &themes.Theme{ - Name: "test-theme", - Colors: themes.ColorSet{ - Primary: "#00ADD8", - Secondary: "#00FF00", - Background: "#000000", - Foreground: "#FFFFFF", - Muted: "#888888", - Border: "#666666", - Success: "#00FF00", - Warning: "#FFFF00", - Error: "#FF0000", - Info: "#00FFFF", - }, - } -} - -func TestNewVersionView(t *testing.T) { - view := NewVersionView(nil, "1.2.3", "abc1234", "2026-02-28T10:00:00Z", false) - - assert.NotNil(t, view) - assert.Nil(t, view.factory) - assert.Equal(t, "1.2.3", view.version) - assert.Equal(t, "abc1234", view.commit) - assert.Equal(t, "2026-02-28T10:00:00Z", view.buildDate) - assert.False(t, view.verbose) - assert.Equal(t, 80, view.width, "Default width should be 80") - assert.Equal(t, 24, view.height, "Default height should be 24") - assert.Nil(t, view.statusBar, "StatusBar not initialized until OnEnter") - assert.Nil(t, view.theme, "Theme not initialized until OnEnter") -} - -func TestNewVersionView_VerboseMode(t *testing.T) { - view := NewVersionView(nil, "2.0.0", "def5678", "2026-01-01T00:00:00Z", true) - - assert.NotNil(t, view) - assert.True(t, view.verbose) -} - -func TestVersionView_Name(t *testing.T) { - view := NewVersionView(nil, "1.0.0", "unknown", "unknown", false) - - assert.Equal(t, "version", view.Name()) -} - -func TestVersionView_Init(t *testing.T) { - view := NewVersionView(nil, "1.0.0", "unknown", "unknown", false) - - cmd := view.Init() - assert.Nil(t, cmd, "Init should return no command") -} - -func TestVersionView_Keybindings(t *testing.T) { - view := NewVersionView(nil, "1.0.0", "unknown", "unknown", false) - - bindings := view.Keybindings() - - require.Len(t, bindings, 1) - assert.Equal(t, "q", bindings[0].Key) - assert.Equal(t, "quit", bindings[0].Description) -} - -func TestVersionView_OnEnter(t *testing.T) { - view := NewVersionView(nil, "1.0.0", "abc1234", "2026-02-28T00:00:00Z", false) - - profile := createVersionTestProfile() - theme := createVersionTestTheme() - ctx := engine.NewViewContext(profile, theme, 120, 40, nil) - - cmd := view.OnEnter(ctx) - - assert.Nil(t, cmd, "OnEnter should return no command") - assert.NotNil(t, view.statusBar, "StatusBar should be initialized after OnEnter") - assert.NotNil(t, view.theme, "Theme should be set after OnEnter") - assert.Equal(t, 120, view.width, "Width should be updated from context") - assert.Equal(t, 40, view.height, "Height should be updated from context") -} - -func TestVersionView_OnExit(t *testing.T) { - view := NewVersionView(nil, "1.0.0", "unknown", "unknown", false) - - cmd := view.OnExit() - assert.Nil(t, cmd, "OnExit should return no command") -} - -func TestVersionView_UpdateWindowSize(t *testing.T) { - view := NewVersionView(nil, "1.0.0", "unknown", "unknown", false) - - msg := tea.WindowSizeMsg{Width: 160, Height: 50} - updatedModel, cmd := view.Update(msg) - - assert.Nil(t, cmd) - updatedView := updatedModel.(*VersionView) - assert.Equal(t, 160, updatedView.width) - assert.Equal(t, 50, updatedView.height) -} - -func TestVersionView_UpdateQuitKeys(t *testing.T) { - tests := []struct { - name string - key string - shouldQuit bool - }{ - {"q key quits", "q", true}, - {"ctrl+c quits", "ctrl+c", true}, - {"other key does nothing", "a", false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - view := NewVersionView(nil, "1.0.0", "unknown", "unknown", false) - - var msg tea.KeyMsg - if tt.key == "ctrl+c" { - msg = tea.KeyMsg{Type: tea.KeyCtrlC} - } else { - msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)} - } - - _, cmd := view.Update(msg) - - if tt.shouldQuit { - assert.NotNil(t, cmd, "Expected quit command") - } else { - assert.Nil(t, cmd, "Expected no command") - } - }) - } -} - -func TestVersionView_CompactView(t *testing.T) { - t.Run("before OnEnter with zero dimensions returns empty", func(t *testing.T) { - view := NewVersionView(nil, "1.2.3", "abc1234", "2026-02-28T00:00:00Z", false) - // Width/height are 0 by default in this scenario - we manually set them - view.width = 0 - view.height = 0 - output := view.View() - assert.Equal(t, "", output, "Should return empty string with zero dimensions") - }) - - t.Run("compact output contains version string", func(t *testing.T) { - view := NewVersionView(nil, "1.2.3", "abc1234", "2026-02-28T00:00:00Z", false) - profile := createVersionTestProfile() - theme := createVersionTestTheme() - ctx := engine.NewViewContext(profile, theme, 80, 24, nil) - view.OnEnter(ctx) - - output := view.View() - - assert.NotEmpty(t, output, "Output should not be empty") - assert.Contains(t, output, "1.2.3", "Output should contain version number") - }) - - t.Run("compact output contains ARC CLI prefix", func(t *testing.T) { - view := NewVersionView(nil, "3.0.0", "fff9999", "2026-02-28T00:00:00Z", false) - profile := createVersionTestProfile() - theme := createVersionTestTheme() - ctx := engine.NewViewContext(profile, theme, 80, 24, nil) - view.OnEnter(ctx) - - output := view.View() - assert.Contains(t, output, "ARC CLI", "Output should contain 'ARC CLI'") - }) - - t.Run("compact output without theme still renders version", func(t *testing.T) { - view := NewVersionView(nil, "1.0.0", "unknown", "unknown", false) - // Use a context with a nil theme to test fallback path - ctx := engine.NewViewContext(nil, nil, 80, 24, nil) - view.OnEnter(ctx) - - // Manually force width and height so View() runs - view.width = 80 - view.height = 24 - - output := view.View() - // Even without theme, version string is rendered - assert.Contains(t, output, "1.0.0", "Output should still contain version") - }) -} - -func TestVersionView_VerboseView(t *testing.T) { - t.Run("verbose output contains commit", func(t *testing.T) { - view := NewVersionView(nil, "1.2.3", "abc1234", "2026-02-28T10:00:00Z", true) - profile := createVersionTestProfile() - theme := createVersionTestTheme() - ctx := engine.NewViewContext(profile, theme, 80, 40, nil) - view.OnEnter(ctx) - - output := view.View() - - assert.NotEmpty(t, output) - assert.Contains(t, output, "abc1234", "Output should contain commit hash") - }) - - t.Run("verbose output contains buildDate", func(t *testing.T) { - view := NewVersionView(nil, "1.2.3", "abc1234", "2026-02-28T10:00:00Z", true) - profile := createVersionTestProfile() - theme := createVersionTestTheme() - ctx := engine.NewViewContext(profile, theme, 80, 40, nil) - view.OnEnter(ctx) - - output := view.View() - - assert.Contains(t, output, "2026-02-28T10:00:00Z", "Output should contain build date") - }) - - t.Run("verbose output contains version", func(t *testing.T) { - view := NewVersionView(nil, "1.2.3", "abc1234", "2026-02-28T10:00:00Z", true) - profile := createVersionTestProfile() - theme := createVersionTestTheme() - ctx := engine.NewViewContext(profile, theme, 80, 40, nil) - view.OnEnter(ctx) - - output := view.View() - - assert.Contains(t, output, "1.2.3", "Output should contain version number") - }) - - t.Run("verbose output contains Go version label", func(t *testing.T) { - view := NewVersionView(nil, "1.2.3", "abc1234", "2026-02-28T10:00:00Z", true) - profile := createVersionTestProfile() - theme := createVersionTestTheme() - ctx := engine.NewViewContext(profile, theme, 80, 40, nil) - view.OnEnter(ctx) - - output := view.View() - - assert.Contains(t, output, "Go Version", "Output should contain 'Go Version' label") - }) - - t.Run("verbose output contains version info title", func(t *testing.T) { - view := NewVersionView(nil, "1.2.3", "abc1234", "2026-02-28T10:00:00Z", true) - profile := createVersionTestProfile() - theme := createVersionTestTheme() - ctx := engine.NewViewContext(profile, theme, 80, 40, nil) - view.OnEnter(ctx) - - output := view.View() - - assert.Contains(t, output, "Version Information", "Output should contain title") - }) -} - -func TestVersionView_ToJSON(t *testing.T) { - view := NewVersionView(nil, "1.2.3", "abc1234", "2026-02-28T10:00:00Z", false) - - data := view.ToJSON() - - require.NotNil(t, data) - - m, ok := data.(map[string]any) - require.True(t, ok, "ToJSON should return a map[string]any") - - assert.Equal(t, "1.2.3", m["version"], "JSON should have correct version") - assert.Equal(t, "abc1234", m["commit"], "JSON should have correct commit") - assert.Equal(t, "2026-02-28T10:00:00Z", m["buildDate"], "JSON should have correct buildDate") - assert.Equal(t, runtime.Version(), m["goVersion"], "JSON should have correct goVersion") - - // Verify all expected keys are present - expectedKeys := []string{"version", "commit", "buildDate", "goVersion"} - for _, key := range expectedKeys { - _, exists := m[key] - assert.True(t, exists, "JSON should contain key: %s", key) - } -} - -func TestVersionView_ToJSON_AllFields(t *testing.T) { - view := NewVersionView(nil, "dev", "unknown", "unknown", true) - - data := view.ToJSON() - m, ok := data.(map[string]any) - require.True(t, ok) - - assert.Equal(t, "dev", m["version"]) - assert.Equal(t, "unknown", m["commit"]) - assert.Equal(t, "unknown", m["buildDate"]) - assert.NotEmpty(t, m["goVersion"], "goVersion should not be empty") -} - -func TestVersionView_ImplementsViewInterface(t *testing.T) { - // Compile-time check that VersionView implements engine.View - var _ engine.View = (*VersionView)(nil) -} - -func TestVersionView_ImplementsBubbleTeaModel(t *testing.T) { - // Compile-time check that VersionView implements tea.Model - var _ tea.Model = (*VersionView)(nil) -} - -func TestVersionView_IntegrationLifecycle(t *testing.T) { - // Test the full lifecycle: NewVersionView -> Init -> OnEnter -> Update -> View -> OnExit - view := NewVersionView(nil, "1.0.0", "abc1234", "2026-01-01T00:00:00Z", false) - - // 1. Init - cmd := view.Init() - assert.Nil(t, cmd) - - // 2. OnEnter - profile := createVersionTestProfile() - theme := createVersionTestTheme() - ctx := engine.NewViewContext(profile, theme, 100, 30, nil) - cmd = view.OnEnter(ctx) - assert.Nil(t, cmd) - assert.NotNil(t, view.statusBar) - assert.NotNil(t, view.theme) - - // 3. Update with window resize - sizeMsg := tea.WindowSizeMsg{Width: 120, Height: 40} - updatedModel, cmd := view.Update(sizeMsg) - assert.Nil(t, cmd) - updatedView := updatedModel.(*VersionView) - assert.Equal(t, 120, updatedView.width) - assert.Equal(t, 40, updatedView.height) - - // 4. View renders non-empty content - output := updatedView.View() - assert.NotEmpty(t, output) - assert.Contains(t, output, "1.0.0") - - // 5. OnExit - cmd = updatedView.OnExit() - assert.Nil(t, cmd) -} diff --git a/pkg/ui/views/workspacehistoryview.go b/pkg/ui/views/workspacehistoryview.go deleted file mode 100644 index 03b5c6c..0000000 --- a/pkg/ui/views/workspacehistoryview.go +++ /dev/null @@ -1,361 +0,0 @@ -package views - -// WorkspaceHistoryView displays workspace operation history using a DataTable. -// Columns: Timestamp, Operation, Details, Status -// -// Design: 017-ui-engine Phase 8 (T306-T311) - -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components/search" - "github.com/arc-framework/arc-cli/pkg/ui/components/status" - "github.com/arc-framework/arc-cli/pkg/ui/components/table" - "github.com/arc-framework/arc-cli/pkg/ui/engine" -) - -// WorkspaceHistoryView displays workspace operation history in a searchable table. -// Layout: -// -// ┌────────────────────────────────────────┐ -// │ Search: [filter history___________] │ -// ├────────────────────────────────────────┤ -// │ Timestamp │ Operation │ Details │ Sts │ -// │────────────┼───────────┼─────────┼─────│ -// │ 2026-02-28 │ create │ ws-1 │ ok │ -// │ 2026-02-27 │ update │ ws-1 │ ok │ -// ├────────────────────────────────────────┤ -// │ ↑/↓: navigate • /: search • q: quit │ -// └────────────────────────────────────────┘ -type WorkspaceHistoryView struct { - factory ui.ComponentFactory - searchBar *search.SearchBar - dataTable *table.DataTable - statusBar *status.StatusBar - allRows []table.Row - history []map[string]any - limit int - width int - height int -} - -// NewWorkspaceHistoryView creates a new WorkspaceHistoryView with the given ComponentFactory. -func NewWorkspaceHistoryView(factory ui.ComponentFactory) *WorkspaceHistoryView { - return &WorkspaceHistoryView{ - factory: factory, - limit: 50, - width: 80, - height: 40, - allRows: []table.Row{}, - } -} - -// Init initializes the WorkspaceHistoryView (Bubble Tea lifecycle). -// Returns a command to start the search bar cursor blinking. -func (v *WorkspaceHistoryView) Init() tea.Cmd { - if v.searchBar != nil { - return v.searchBar.Init() - } - return nil -} - -// Update handles messages and updates the WorkspaceHistoryView state. -func (v *WorkspaceHistoryView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - return v.handleWindowResize(msg) - case tea.KeyMsg: - return v.handleKeyPress(msg) - } - - return v.updateComponents(msg) -} - -// handleWindowResize processes window resize messages. -// -//nolint:dupl // boilerplate resize logic is structurally identical across list views by design -func (v *WorkspaceHistoryView) handleWindowResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { - v.width = msg.Width - v.height = msg.Height - - if v.searchBar != nil { - v.searchBar.SetWidth(v.width - 4) - } - if v.dataTable != nil { - v.dataTable.SetWidth(v.width) - // Search bar with border = 3 lines; 2 spacers; 1 status = 6 overhead - tableHeight := v.height - 6 - if tableHeight < 3 { - tableHeight = 3 - } - v.dataTable.SetHeight(tableHeight) - } - return v, nil -} - -// handleKeyPress processes keyboard input for view-level actions. -func (v *WorkspaceHistoryView) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", keyCtrlC: - return v, tea.Quit - - case "/": - if v.searchBar != nil { - return v, v.searchBar.Focus() - } - return v, nil - - case keyEsc: - if v.searchBar != nil && v.searchBar.Focused() { - var searchModel tea.Model - searchModel, cmd := v.searchBar.Update(msg) - v.searchBar = searchModel.(*search.SearchBar) - return v, cmd - } - return v, nil - } - - return v.updateComponents(msg) -} - -// updateComponents updates child components with the given message. -// -//nolint:dupl // boilerplate update-dispatch is structurally identical across list views by design -func (v *WorkspaceHistoryView) updateComponents(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - if v.searchBar != nil { - var searchModel tea.Model - searchModel, cmd := v.searchBar.Update(msg) - v.searchBar = searchModel.(*search.SearchBar) - cmds = append(cmds, cmd) - } - - if v.dataTable != nil && (v.searchBar == nil || !v.searchBar.Focused()) { - var tableModel tea.Model - tableModel, cmd := v.dataTable.Update(msg) - v.dataTable = tableModel.(*table.DataTable) - cmds = append(cmds, cmd) - } - - return v, tea.Batch(cmds...) -} - -// View renders the WorkspaceHistoryView as a string. -func (v *WorkspaceHistoryView) View() string { - if v.width <= 0 || v.height <= 0 { - return "" - } - - // If core components aren't initialized, return empty - if v.dataTable == nil || v.statusBar == nil { - return "" - } - - var searchContent string - if v.searchBar != nil { - searchContent = v.searchBar.View() - } - - var tableContent string - if v.dataTable != nil { - tableContent = v.dataTable.View() - } - - var statusContent string - if v.statusBar != nil { - message := "" - if v.dataTable != nil { - rowCount := v.dataTable.RowCount() - total := len(v.allRows) - if rowCount < total { - message = lipgloss.NewStyle().Render( - strings.Join([]string{ - "Showing", - intToString(rowCount), - "of", - intToString(total), - "entries", - }, " "), - ) - } - } - statusContent = v.statusBar.Render(v.width, v.Keybindings(), message) - } - - return lipgloss.JoinVertical( - lipgloss.Left, - searchContent, - "", - tableContent, - "", - statusContent, - ) -} - -// OnEnter is called when the WorkspaceHistoryView becomes active. -// Reads history from ctx.Args["history"] and limit from ctx.Args["limit"]. -func (v *WorkspaceHistoryView) OnEnter(ctx *engine.ViewContext) tea.Cmd { - theme := ctx.Theme - - // Extract limit from context args (default 50) - v.limit = 50 - if lim, ok := ctx.Args["limit"].(int); ok && lim > 0 { - v.limit = lim - } - - // Extract history from context args - v.history = nil - if hist, ok := ctx.Args["history"].([]map[string]any); ok { - v.history = hist - } - - // Build table rows from history data - v.allRows = v.buildRows() - - // Initialize search bar - v.searchBar = search.NewSearchBar(theme, "Filter history...") - v.searchBar.SetWidth(v.width - 4) - v.searchBar.SetOnChange(func(query string) { - v.filterRows(query) - }) - - // Define columns - columns := []table.Column{ - {Title: "Timestamp", Width: 20}, - {Title: "Operation", Width: 15}, - {Title: "Details", Width: 30}, - {Title: "Status", Width: 10}, - } - - v.dataTable = table.NewDataTable(columns, v.allRows, theme) - - tableHeight := v.height - 5 - if tableHeight < 3 { - tableHeight = 3 - } - v.dataTable.SetWidth(v.width) - v.dataTable.SetHeight(tableHeight) - - // Initialize status bar - v.statusBar = status.NewStatusBar(theme) - - // Update dimensions from context - v.width = ctx.Width - v.height = ctx.Height - - return v.searchBar.Init() -} - -// OnExit is called when the WorkspaceHistoryView is replaced by another view. -// No cleanup needed for this view. -func (v *WorkspaceHistoryView) OnExit() tea.Cmd { - return nil -} - -// Name returns the unique identifier for this view. -func (v *WorkspaceHistoryView) Name() string { - return "workspace-history" -} - -// Keybindings returns the keyboard shortcuts for the WorkspaceHistoryView. -func (v *WorkspaceHistoryView) Keybindings() []engine.KeyBinding { - return []engine.KeyBinding{ - {Key: "↑/↓", Description: "navigate"}, - {Key: "/", Description: "search"}, - {Key: "q", Description: "quit"}, - } -} - -// buildRows converts history entries into table rows, applying the limit. -func (v *WorkspaceHistoryView) buildRows() []table.Row { - if len(v.history) == 0 { - return []table.Row{} - } - - src := v.history - if v.limit > 0 && len(src) > v.limit { - src = src[:v.limit] - } - - rows := make([]table.Row, 0, len(src)) - for _, entry := range src { - ts := stringFromMap(entry, "timestamp") - op := stringFromMap(entry, "operation") - details := stringFromMap(entry, "details") - st := stringFromMap(entry, "status") - rows = append(rows, table.Row{ts, op, details, st}) - } - return rows -} - -// filterRows filters the data table rows based on the search query. -func (v *WorkspaceHistoryView) filterRows(query string) { - if v.dataTable == nil { - return - } - - if query == "" { - v.dataTable.SetRows(v.allRows) - return - } - - queryLower := strings.ToLower(query) - filtered := []table.Row{} - - for _, row := range v.allRows { - for _, cell := range row { - if strings.Contains(strings.ToLower(cell), queryLower) { - filtered = append(filtered, row) - break - } - } - } - - v.dataTable.SetRows(filtered) -} - -// stringFromMap returns a string value from a map by key, or empty string. -func stringFromMap(m map[string]any, key string) string { - if v, ok := m[key]; ok { - if s, isStr := v.(string); isStr { - return s - } - } - return "" -} - -// ToJSON returns the history rows for JSON output mode. -func (v *WorkspaceHistoryView) ToJSON() any { - rows := make([]map[string]any, 0, len(v.allRows)) - for _, row := range v.allRows { - entry := map[string]any{ - "timestamp": "", - "operation": "", - "details": "", - "status": "", - } - if len(row) > 0 { - entry["timestamp"] = row[0] - } - if len(row) > 1 { - entry["operation"] = row[1] - } - if len(row) > 2 { - entry["details"] = row[2] - } - if len(row) > 3 { - entry["status"] = row[3] - } - rows = append(rows, entry) - } - return map[string]any{ - "history": rows, - "count": len(v.allRows), - "limit": v.limit, - } -} diff --git a/pkg/ui/views/workspacehistoryview_test.go b/pkg/ui/views/workspacehistoryview_test.go deleted file mode 100644 index d35c184..0000000 --- a/pkg/ui/views/workspacehistoryview_test.go +++ /dev/null @@ -1,352 +0,0 @@ -package views - -import ( - "encoding/json" - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -// createWorkspaceHistoryFactory creates a ComponentFactory for WorkspaceHistoryView tests. -func createWorkspaceHistoryFactory() ui.ComponentFactory { - profileCtx := profiles.GetDefaultProfileContext() - return ui.NewComponentFactory(profileCtx, components.BorderTierBlock) -} - -// createWorkspaceHistoryContext creates a ViewContext for WorkspaceHistoryView tests. -func createWorkspaceHistoryContext(args map[string]any) *engine.ViewContext { - profileCtx := profiles.GetDefaultProfileContext() - return &engine.ViewContext{ - Profile: profileCtx.Profile(), - Theme: profileCtx.Theme(), - Width: 80, - Height: 40, - Args: args, - } -} - -// sampleHistory returns sample history entries for testing. -func sampleHistory() []map[string]any { - return []map[string]any{ - { - "timestamp": "2026-02-28T10:00:00Z", - "operation": "create", - "details": "workspace-1", - "status": "success", - }, - { - "timestamp": "2026-02-27T15:30:00Z", - "operation": "update", - "details": "workspace-1", - "status": "success", - }, - { - "timestamp": "2026-02-26T09:00:00Z", - "operation": "delete", - "details": "workspace-2", - "status": "failed", - }, - } -} - -func TestNewWorkspaceHistoryView(t *testing.T) { - factory := createWorkspaceHistoryFactory() - view := NewWorkspaceHistoryView(factory) - - require.NotNil(t, view) - assert.Equal(t, 80, view.width) - assert.Equal(t, 40, view.height) - assert.Equal(t, 50, view.limit) - assert.Empty(t, view.allRows) - assert.NotNil(t, view.factory) -} - -func TestWorkspaceHistoryView_Name(t *testing.T) { - factory := createWorkspaceHistoryFactory() - view := NewWorkspaceHistoryView(factory) - - assert.Equal(t, "workspace-history", view.Name()) -} - -func TestWorkspaceHistoryView_Init(t *testing.T) { - factory := createWorkspaceHistoryFactory() - view := NewWorkspaceHistoryView(factory) - - // Before OnEnter, searchBar is nil so Init returns nil - cmd := view.Init() - assert.Nil(t, cmd) -} - -func TestWorkspaceHistoryView_OnEnter(t *testing.T) { - t.Run("with history data", func(t *testing.T) { - factory := createWorkspaceHistoryFactory() - view := NewWorkspaceHistoryView(factory) - - hist := sampleHistory() - ctx := createWorkspaceHistoryContext(map[string]any{ - "history": hist, - "limit": 10, - }) - - cmd := view.OnEnter(ctx) - assert.NotNil(t, cmd, "OnEnter should return searchBar init command") - - require.NotNil(t, view.searchBar, "searchBar not initialized") - require.NotNil(t, view.dataTable, "dataTable not initialized") - require.NotNil(t, view.statusBar, "statusBar not initialized") - assert.Equal(t, 10, view.limit) - assert.Len(t, view.allRows, 3) - }) - - t.Run("without history data", func(t *testing.T) { - factory := createWorkspaceHistoryFactory() - view := NewWorkspaceHistoryView(factory) - - ctx := createWorkspaceHistoryContext(map[string]any{}) - - cmd := view.OnEnter(ctx) - assert.NotNil(t, cmd) - - assert.Empty(t, view.allRows) - assert.Equal(t, 50, view.limit) // default - }) - - t.Run("respects limit", func(t *testing.T) { - factory := createWorkspaceHistoryFactory() - view := NewWorkspaceHistoryView(factory) - - hist := sampleHistory() // 3 entries - ctx := createWorkspaceHistoryContext(map[string]any{ - "history": hist, - "limit": 2, - }) - - view.OnEnter(ctx) - assert.Len(t, view.allRows, 2, "limit should restrict rows") - }) - - t.Run("uses default limit when not provided", func(t *testing.T) { - factory := createWorkspaceHistoryFactory() - view := NewWorkspaceHistoryView(factory) - - hist := sampleHistory() - ctx := createWorkspaceHistoryContext(map[string]any{ - "history": hist, - }) - - view.OnEnter(ctx) - assert.Equal(t, 50, view.limit) - assert.Len(t, view.allRows, 3) // all 3 rows fit within default limit of 50 - }) -} - -func TestWorkspaceHistoryView_View(t *testing.T) { - t.Run("returns empty before initialization", func(t *testing.T) { - factory := createWorkspaceHistoryFactory() - view := NewWorkspaceHistoryView(factory) - - output := view.View() - assert.Empty(t, output) - }) - - t.Run("returns non-empty after OnEnter", func(t *testing.T) { - factory := createWorkspaceHistoryFactory() - view := NewWorkspaceHistoryView(factory) - - ctx := createWorkspaceHistoryContext(map[string]any{ - "history": sampleHistory(), - }) - view.OnEnter(ctx) - - output := view.View() - assert.NotEmpty(t, output) - }) - - t.Run("returns empty when dimensions are zero", func(t *testing.T) { - factory := createWorkspaceHistoryFactory() - view := NewWorkspaceHistoryView(factory) - - ctx := createWorkspaceHistoryContext(map[string]any{ - "history": sampleHistory(), - }) - view.OnEnter(ctx) - - view.width = 0 - view.height = 0 - output := view.View() - assert.Empty(t, output) - }) -} - -func TestWorkspaceHistoryView_ToJSON(t *testing.T) { - t.Run("implements JSONExporter interface", func(t *testing.T) { - factory := createWorkspaceHistoryFactory() - view := NewWorkspaceHistoryView(factory) - var _ engine.JSONExporter = view - }) - - t.Run("returns history and count", func(t *testing.T) { - factory := createWorkspaceHistoryFactory() - view := NewWorkspaceHistoryView(factory) - - ctx := createWorkspaceHistoryContext(map[string]any{ - "history": sampleHistory(), - }) - view.OnEnter(ctx) - - raw := view.ToJSON() - data, err := json.Marshal(raw) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - - assert.Contains(t, result, "history") - assert.Contains(t, result, "count") - assert.Contains(t, result, "limit") - - count := int(result["count"].(float64)) - assert.Equal(t, 3, count) - }) - - t.Run("returns empty history when no data", func(t *testing.T) { - factory := createWorkspaceHistoryFactory() - view := NewWorkspaceHistoryView(factory) - - ctx := createWorkspaceHistoryContext(map[string]any{}) - view.OnEnter(ctx) - - raw := view.ToJSON() - data, err := json.Marshal(raw) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - - histSlice := result["history"].([]any) - assert.Empty(t, histSlice) - }) - - t.Run("history rows have expected columns", func(t *testing.T) { - factory := createWorkspaceHistoryFactory() - view := NewWorkspaceHistoryView(factory) - - ctx := createWorkspaceHistoryContext(map[string]any{ - "history": sampleHistory(), - }) - view.OnEnter(ctx) - - raw := view.ToJSON() - data, err := json.Marshal(raw) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - - histSlice := result["history"].([]any) - require.NotEmpty(t, histSlice) - - firstRow := histSlice[0].(map[string]any) - assert.Contains(t, firstRow, "timestamp") - assert.Contains(t, firstRow, "operation") - assert.Contains(t, firstRow, "details") - assert.Contains(t, firstRow, "status") - }) - - t.Run("is JSON-marshallable without OnEnter", func(t *testing.T) { - factory := createWorkspaceHistoryFactory() - view := NewWorkspaceHistoryView(factory) - - _, err := json.Marshal(view.ToJSON()) - assert.NoError(t, err) - }) -} - -func TestWorkspaceHistoryView_ImplementsView(t *testing.T) { - factory := createWorkspaceHistoryFactory() - view := NewWorkspaceHistoryView(factory) - - var _ engine.View = view - var _ tea.Model = view -} - -func TestWorkspaceHistoryView_Update_WindowResize(t *testing.T) { - factory := createWorkspaceHistoryFactory() - view := NewWorkspaceHistoryView(factory) - - ctx := createWorkspaceHistoryContext(map[string]any{ - "history": sampleHistory(), - }) - view.OnEnter(ctx) - - msg := tea.WindowSizeMsg{Width: 120, Height: 60} - updatedModel, cmd := view.Update(msg) - - assert.Nil(t, cmd) - updated := updatedModel.(*WorkspaceHistoryView) - assert.Equal(t, 120, updated.width) - assert.Equal(t, 60, updated.height) -} - -func TestWorkspaceHistoryView_Update_Quit(t *testing.T) { - factory := createWorkspaceHistoryFactory() - view := NewWorkspaceHistoryView(factory) - - ctx := createWorkspaceHistoryContext(map[string]any{}) - view.OnEnter(ctx) - - _, cmd := view.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) - assert.NotNil(t, cmd, "expected quit command") -} - -func TestWorkspaceHistoryView_OnExit(t *testing.T) { - factory := createWorkspaceHistoryFactory() - view := NewWorkspaceHistoryView(factory) - - cmd := view.OnExit() - assert.Nil(t, cmd) -} - -func TestWorkspaceHistoryView_Keybindings(t *testing.T) { - factory := createWorkspaceHistoryFactory() - view := NewWorkspaceHistoryView(factory) - - kbs := view.Keybindings() - assert.NotEmpty(t, kbs) - - keys := map[string]bool{} - for _, kb := range kbs { - keys[kb.Key] = true - } - assert.True(t, keys["↑/↓"]) - assert.True(t, keys["/"]) - assert.True(t, keys["q"]) -} - -func TestWorkspaceHistoryView_SearchFiltering(t *testing.T) { - factory := createWorkspaceHistoryFactory() - view := NewWorkspaceHistoryView(factory) - - ctx := createWorkspaceHistoryContext(map[string]any{ - "history": sampleHistory(), - }) - view.OnEnter(ctx) - - // Verify all rows present before filtering - assert.Len(t, view.allRows, 3) - - // Apply a filter that matches one row - view.filterRows("delete") - assert.Equal(t, 1, view.dataTable.RowCount()) - - // Clear filter to restore all rows - view.filterRows("") - assert.Equal(t, 3, view.dataTable.RowCount()) -} diff --git a/pkg/ui/views/workspaceinfoview.go b/pkg/ui/views/workspaceinfoview.go deleted file mode 100644 index c877c8b..0000000 --- a/pkg/ui/views/workspaceinfoview.go +++ /dev/null @@ -1,261 +0,0 @@ -package views - -// WorkspaceInfoView displays information about the current workspace. -// It uses the Tree component to show workspace details hierarchically. -// -// Design: 017-ui-engine Phase 8 (T296-T302) - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components/status" - "github.com/arc-framework/arc-cli/pkg/ui/components/tree" - "github.com/arc-framework/arc-cli/pkg/ui/engine" -) - -// WorkspaceInfoView displays information about the current workspace. -// Layout: -// -// ┌────────────────────────────────────────┐ -// │ Workspace Information │ -// │ ├── Name: my-workspace │ -// │ ├── Status: active │ -// │ └── Services │ -// │ ├── postgres │ -// │ └── redis │ -// ├────────────────────────────────────────┤ -// │ ↑/↓: navigate • q: quit │ -// └────────────────────────────────────────┘ -type WorkspaceInfoView struct { - factory ui.ComponentFactory - tree *tree.Tree - statusBar *status.StatusBar - workspaceData map[string]any - width int - height int -} - -// NewWorkspaceInfoView creates a new WorkspaceInfoView with the given ComponentFactory. -func NewWorkspaceInfoView(factory ui.ComponentFactory) *WorkspaceInfoView { - return &WorkspaceInfoView{ - factory: factory, - width: 80, - height: 40, - } -} - -// Init initializes the WorkspaceInfoView (Bubble Tea lifecycle). -// No commands are needed for this static view. -func (v *WorkspaceInfoView) Init() tea.Cmd { - return nil -} - -// Update handles messages and updates the WorkspaceInfoView state. -// Handles window resize and keyboard input for navigation and quitting. -func (v *WorkspaceInfoView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - return v, nil - - case tea.KeyMsg: - switch msg.String() { - case "q", keyCtrlC: - return v, tea.Quit - case "j", keyDown: - return v, nil - case "k", keyUp: - return v, nil - } - } - - return v, nil -} - -// View renders the WorkspaceInfoView as a string. -// Displays the workspace info tree in the middle and status bar at bottom. -func (v *WorkspaceInfoView) View() string { - if v.width <= 0 || v.height <= 0 { - return "" - } - - if v.tree == nil || v.statusBar == nil { - return "" - } - - treeContent := v.tree.Render(v.width) - statusContent := v.statusBar.Render(v.width, v.Keybindings(), "") - - return lipgloss.JoinVertical( - lipgloss.Left, - treeContent, - "", - statusContent, - ) -} - -// OnEnter is called when the WorkspaceInfoView becomes active. -// Reads workspace data from ctx.Args["workspace"] and initializes components. -func (v *WorkspaceInfoView) OnEnter(ctx *engine.ViewContext) tea.Cmd { - // Extract workspace data from context args - if ws, ok := ctx.Args["workspace"].(map[string]any); ok { - v.workspaceData = ws - } - - // Build tree structure from workspace data - treeRoot := v.buildWorkspaceTree() - v.tree = tree.NewTree(treeRoot, ctx.Theme) - - // Initialize status bar with theme from context - v.statusBar = status.NewStatusBar(ctx.Theme) - - // Update dimensions from context - v.width = ctx.Width - v.height = ctx.Height - - return nil -} - -// OnExit is called when the WorkspaceInfoView is replaced by another view. -// No cleanup needed for this view. -func (v *WorkspaceInfoView) OnExit() tea.Cmd { - return nil -} - -// Name returns the unique identifier for this view. -func (v *WorkspaceInfoView) Name() string { - return "workspace-info" -} - -// Keybindings returns the keyboard shortcuts for the WorkspaceInfoView. -func (v *WorkspaceInfoView) Keybindings() []engine.KeyBinding { - return []engine.KeyBinding{ - {Key: "↑/↓", Description: "navigate"}, - {Key: "q", Description: "quit"}, - } -} - -// buildWorkspaceTree constructs a hierarchical tree from workspace data. -func (v *WorkspaceInfoView) buildWorkspaceTree() *tree.TreeNode { - if v.workspaceData == nil { - return &tree.TreeNode{ - Label: "Workspace Information", - Value: "(no data available)", - Children: []*tree.TreeNode{}, - Expanded: true, - } - } - - data := v.workspaceData - children := make([]*tree.TreeNode, 0, len(data)) - - // Name - if name, ok := data["name"].(string); ok && name != "" { - children = append(children, &tree.TreeNode{ - Label: "Name", - Value: name, - Expanded: true, - }) - } - - // Status - if st, ok := data["status"].(string); ok && st != "" { - children = append(children, &tree.TreeNode{ - Label: "Status", - Value: st, - Expanded: true, - }) - } - - // Services list - if svcRaw, ok := data["services"]; ok { - serviceNode := v.buildServicesNode(svcRaw) - if serviceNode != nil { - children = append(children, serviceNode) - } - } - - // Additional metadata keys (skip already handled ones) - handled := map[string]bool{"name": true, "status": true, "services": true} - for k, val := range data { - if handled[k] { - continue - } - strVal := "" - switch vt := val.(type) { - case string: - strVal = vt - case int: - strVal = intToString(vt) - case bool: - if vt { - strVal = "true" - } else { - strVal = "false" - } - default: - continue // skip complex types - } - children = append(children, &tree.TreeNode{ - Label: k, - Value: strVal, - Expanded: true, - }) - } - - return &tree.TreeNode{ - Label: "Workspace Information", - Children: children, - Expanded: true, - } -} - -// buildServicesNode converts the services entry into a TreeNode. -func (v *WorkspaceInfoView) buildServicesNode(svcRaw any) *tree.TreeNode { - var serviceChildren []*tree.TreeNode - - switch svcs := svcRaw.(type) { - case []string: - for _, s := range svcs { - serviceChildren = append(serviceChildren, &tree.TreeNode{ - Label: s, - Expanded: true, - }) - } - case []any: - for _, s := range svcs { - if name, ok := s.(string); ok { - serviceChildren = append(serviceChildren, &tree.TreeNode{ - Label: name, - Expanded: true, - }) - } - } - } - - if len(serviceChildren) == 0 { - return nil - } - - return &tree.TreeNode{ - Label: "Services", - Children: serviceChildren, - Expanded: true, - } -} - -// ToJSON returns the workspace data for JSON output mode. -// Returns a map with workspace details. -func (v *WorkspaceInfoView) ToJSON() any { - if v.workspaceData == nil { - return map[string]any{ - "error": "no workspace data available", - } - } - return map[string]any{ - "workspace": v.workspaceData, - } -} diff --git a/pkg/ui/views/workspaceinfoview_test.go b/pkg/ui/views/workspaceinfoview_test.go deleted file mode 100644 index ec10b63..0000000 --- a/pkg/ui/views/workspaceinfoview_test.go +++ /dev/null @@ -1,303 +0,0 @@ -package views - -import ( - "encoding/json" - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -// createWorkspaceInfoFactory creates a ComponentFactory for WorkspaceInfoView tests. -func createWorkspaceInfoFactory() ui.ComponentFactory { - profileCtx := profiles.GetDefaultProfileContext() - return ui.NewComponentFactory(profileCtx, components.BorderTierBlock) -} - -// createWorkspaceInfoContext creates a ViewContext for WorkspaceInfoView tests. -func createWorkspaceInfoContext(args map[string]any) *engine.ViewContext { - profileCtx := profiles.GetDefaultProfileContext() - return &engine.ViewContext{ - Profile: profileCtx.Profile(), - Theme: profileCtx.Theme(), - Width: 80, - Height: 40, - Args: args, - } -} - -// sampleWorkspaceData returns a sample workspace map for testing. -func sampleWorkspaceData() map[string]any { - return map[string]any{ - "name": "my-workspace", - "status": "active", - "services": []any{ - "postgres", - "redis", - }, - } -} - -func TestNewWorkspaceInfoView(t *testing.T) { - factory := createWorkspaceInfoFactory() - view := NewWorkspaceInfoView(factory) - - require.NotNil(t, view) - assert.Equal(t, 80, view.width) - assert.Equal(t, 40, view.height) - assert.Nil(t, view.workspaceData) - assert.NotNil(t, view.factory) -} - -func TestWorkspaceInfoView_Name(t *testing.T) { - factory := createWorkspaceInfoFactory() - view := NewWorkspaceInfoView(factory) - - assert.Equal(t, "workspace-info", view.Name()) -} - -func TestWorkspaceInfoView_Init(t *testing.T) { - factory := createWorkspaceInfoFactory() - view := NewWorkspaceInfoView(factory) - - cmd := view.Init() - assert.Nil(t, cmd, "Init() should return nil for static view") -} - -func TestWorkspaceInfoView_OnEnter(t *testing.T) { - t.Run("with workspace data", func(t *testing.T) { - factory := createWorkspaceInfoFactory() - view := NewWorkspaceInfoView(factory) - - ws := sampleWorkspaceData() - ctx := createWorkspaceInfoContext(map[string]any{"workspace": ws}) - - cmd := view.OnEnter(ctx) - assert.Nil(t, cmd) - - require.NotNil(t, view.tree, "tree component not initialized") - require.NotNil(t, view.statusBar, "statusBar component not initialized") - assert.Equal(t, ws, view.workspaceData) - assert.Equal(t, 80, view.width) - assert.Equal(t, 40, view.height) - }) - - t.Run("without workspace data", func(t *testing.T) { - factory := createWorkspaceInfoFactory() - view := NewWorkspaceInfoView(factory) - - ctx := createWorkspaceInfoContext(map[string]any{}) - - cmd := view.OnEnter(ctx) - assert.Nil(t, cmd) - - require.NotNil(t, view.tree, "tree component not initialized") - require.NotNil(t, view.statusBar, "statusBar component not initialized") - assert.Nil(t, view.workspaceData) - - root := view.tree.GetRoot() - require.NotNil(t, root) - assert.Equal(t, "Workspace Information", root.Label) - assert.Contains(t, root.Value, "no data available") - }) - - t.Run("with nil workspace arg", func(t *testing.T) { - factory := createWorkspaceInfoFactory() - view := NewWorkspaceInfoView(factory) - - ctx := createWorkspaceInfoContext(map[string]any{"workspace": nil}) - - cmd := view.OnEnter(ctx) - assert.Nil(t, cmd) - assert.Nil(t, view.workspaceData) - }) -} - -func TestWorkspaceInfoView_View(t *testing.T) { - t.Run("returns empty before initialization", func(t *testing.T) { - factory := createWorkspaceInfoFactory() - view := NewWorkspaceInfoView(factory) - - output := view.View() - assert.Empty(t, output) - }) - - t.Run("returns non-empty after OnEnter", func(t *testing.T) { - factory := createWorkspaceInfoFactory() - view := NewWorkspaceInfoView(factory) - - ctx := createWorkspaceInfoContext(map[string]any{"workspace": sampleWorkspaceData()}) - view.OnEnter(ctx) - - output := view.View() - assert.NotEmpty(t, output) - assert.Contains(t, output, "Workspace Information") - }) - - t.Run("returns empty when dimensions are zero", func(t *testing.T) { - factory := createWorkspaceInfoFactory() - view := NewWorkspaceInfoView(factory) - - ctx := createWorkspaceInfoContext(map[string]any{"workspace": sampleWorkspaceData()}) - view.OnEnter(ctx) - - view.width = 0 - view.height = 0 - output := view.View() - assert.Empty(t, output) - }) -} - -func TestWorkspaceInfoView_ToJSON(t *testing.T) { - t.Run("implements JSONExporter interface", func(t *testing.T) { - factory := createWorkspaceInfoFactory() - view := NewWorkspaceInfoView(factory) - var _ engine.JSONExporter = view - }) - - t.Run("returns error map when no data", func(t *testing.T) { - factory := createWorkspaceInfoFactory() - view := NewWorkspaceInfoView(factory) - - raw := view.ToJSON() - data, err := json.Marshal(raw) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - assert.Contains(t, result, "error") - }) - - t.Run("returns workspace key when data available", func(t *testing.T) { - factory := createWorkspaceInfoFactory() - view := NewWorkspaceInfoView(factory) - - ws := sampleWorkspaceData() - ctx := createWorkspaceInfoContext(map[string]any{"workspace": ws}) - view.OnEnter(ctx) - - raw := view.ToJSON() - data, err := json.Marshal(raw) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - assert.Contains(t, result, "workspace") - }) - - t.Run("is JSON-marshallable", func(t *testing.T) { - factory := createWorkspaceInfoFactory() - view := NewWorkspaceInfoView(factory) - - ctx := createWorkspaceInfoContext(map[string]any{"workspace": sampleWorkspaceData()}) - view.OnEnter(ctx) - - raw := view.ToJSON() - _, err := json.Marshal(raw) - assert.NoError(t, err) - }) -} - -func TestWorkspaceInfoView_ImplementsView(t *testing.T) { - factory := createWorkspaceInfoFactory() - view := NewWorkspaceInfoView(factory) - - var _ engine.View = view - var _ tea.Model = view -} - -func TestWorkspaceInfoView_Update_WindowResize(t *testing.T) { - factory := createWorkspaceInfoFactory() - view := NewWorkspaceInfoView(factory) - - ctx := createWorkspaceInfoContext(map[string]any{"workspace": sampleWorkspaceData()}) - view.OnEnter(ctx) - - msg := tea.WindowSizeMsg{Width: 120, Height: 60} - updatedModel, cmd := view.Update(msg) - - assert.Nil(t, cmd) - updated := updatedModel.(*WorkspaceInfoView) - assert.Equal(t, 120, updated.width) - assert.Equal(t, 60, updated.height) -} - -func TestWorkspaceInfoView_Update_Quit(t *testing.T) { - factory := createWorkspaceInfoFactory() - view := NewWorkspaceInfoView(factory) - - ctx := createWorkspaceInfoContext(map[string]any{}) - view.OnEnter(ctx) - - _, cmd := view.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) - assert.NotNil(t, cmd, "expected quit command") -} - -func TestWorkspaceInfoView_OnExit(t *testing.T) { - factory := createWorkspaceInfoFactory() - view := NewWorkspaceInfoView(factory) - - cmd := view.OnExit() - assert.Nil(t, cmd) -} - -func TestWorkspaceInfoView_Keybindings(t *testing.T) { - factory := createWorkspaceInfoFactory() - view := NewWorkspaceInfoView(factory) - - kbs := view.Keybindings() - assert.NotEmpty(t, kbs) - - keys := map[string]bool{} - for _, kb := range kbs { - keys[kb.Key] = true - } - assert.True(t, keys["↑/↓"]) - assert.True(t, keys["q"]) -} - -func TestWorkspaceInfoView_TreeStructure(t *testing.T) { - factory := createWorkspaceInfoFactory() - view := NewWorkspaceInfoView(factory) - - ws := map[string]any{ - "name": "test-ws", - "status": "running", - "services": []any{ - "postgres", - "redis", - "mongodb", - }, - } - ctx := createWorkspaceInfoContext(map[string]any{"workspace": ws}) - view.OnEnter(ctx) - - root := view.tree.GetRoot() - require.NotNil(t, root) - assert.Equal(t, "Workspace Information", root.Label) - assert.NotEmpty(t, root.Children) - - labelSet := map[string]bool{} - for _, child := range root.Children { - labelSet[child.Label] = true - } - assert.True(t, labelSet["Name"]) - assert.True(t, labelSet["Status"]) - assert.True(t, labelSet["Services"]) - - // Verify Services node has children - var servicesChildCount int - for _, child := range root.Children { - if child.Label == "Services" { - servicesChildCount = len(child.Children) - break - } - } - assert.Equal(t, 3, servicesChildCount, "expected 3 service children") -} diff --git a/pkg/ui/views/workspaceinitview.go b/pkg/ui/views/workspaceinitview.go deleted file mode 100644 index 5fa5dad..0000000 --- a/pkg/ui/views/workspaceinitview.go +++ /dev/null @@ -1,220 +0,0 @@ -package views - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components/status" - "github.com/arc-framework/arc-cli/pkg/ui/components/wizard" - "github.com/arc-framework/arc-cli/pkg/ui/engine" -) - -// WorkspaceInitWizardView guides the user through initializing a new workspace -// with a 3-step wizard: workspace name, service selection, and confirmation. -// -// Layout: -// -// ┌────────────────────────────────────────┐ -// │ Step 1 of 3 │ -// │ │ -// │ Workspace Name │ -// │ Enter the name for your new workspace │ -// │ │ -// │ > [my-workspace______________] │ -// ├────────────────────────────────────────┤ -// │ enter: next • backspace: back • esc: cancel│ -// └────────────────────────────────────────┘ -// -// Design: 017-ui-engine Phase 5 (T323-T332) -type WorkspaceInitWizardView struct { - factory ui.ComponentFactory - wiz *wizard.Wizard - statusBar *status.StatusBar - workspaceName string - services []string - confirmed bool - width int - height int -} - -// NewWorkspaceInitWizardView creates a new WorkspaceInitWizardView with the given ComponentFactory. -func NewWorkspaceInitWizardView(factory ui.ComponentFactory) *WorkspaceInitWizardView { - return &WorkspaceInitWizardView{ - factory: factory, - services: []string{}, - width: 80, - height: 40, - } -} - -// Init initializes the WorkspaceInitWizardView (Bubble Tea lifecycle). -func (v *WorkspaceInitWizardView) Init() tea.Cmd { - if v.wiz != nil { - return v.wiz.Init() - } - return nil -} - -// Update handles messages and updates the WorkspaceInitWizardView state. -func (v *WorkspaceInitWizardView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - if v.wiz != nil { - v.wiz.SetSize(v.width, v.height) - } - return v, nil - - case tea.KeyMsg: - switch msg.String() { - case runKeyCancelString, keyEsc: - return v, tea.Quit - } - } - - // Delegate to wizard - if v.wiz != nil { - m, cmd := v.wiz.Update(msg) - v.wiz = m.(*wizard.Wizard) - return v, cmd - } - - return v, nil -} - -// View renders the WorkspaceInitWizardView as a string. -func (v *WorkspaceInitWizardView) View() string { - if v.width <= 0 || v.height <= 0 { - return "" - } - - var wizView string - if v.wiz != nil { - wizView = v.wiz.View() - } - - var statusContent string - if v.statusBar != nil { - statusContent = v.statusBar.Render(v.width, v.Keybindings(), "") - } - - if statusContent != "" { - return lipgloss.JoinVertical(lipgloss.Left, wizView, statusContent) - } - return wizView -} - -// OnEnter is called when the WorkspaceInitWizardView becomes active. -// Builds the 3-step wizard and initializes child components. -func (v *WorkspaceInitWizardView) OnEnter(ctx *engine.ViewContext) tea.Cmd { - theme := ctx.Theme - - v.width = ctx.Width - v.height = ctx.Height - - // Step 1: Workspace Name - step1Form := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Workspace name"). - Placeholder("e.g. my-workspace"). - Value(&v.workspaceName), - ), - ) - - // Step 2: Service Selection - selectedServices := &[]string{} - step2Form := huh.NewForm( - huh.NewGroup( - huh.NewMultiSelect[string](). - Title("Select services"). - Options( - huh.NewOption("PostgreSQL", "postgres"), - huh.NewOption("Redis", "redis"), - huh.NewOption("MongoDB", "mongodb"), - huh.NewOption("MySQL", "mysql"), - huh.NewOption("RabbitMQ", "rabbitmq"), - ). - Value(selectedServices), - ), - ) - - // Step 3: Confirmation - step3Form := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Confirm workspace initialization"). - Description("Proceed with the selected configuration?"). - Value(&v.confirmed), - ), - ) - - steps := []wizard.WizardStep{ - { - Title: "Workspace Name", - Description: "Enter a name for your new workspace", - Form: step1Form, - }, - { - Title: "Service Selection", - Description: "Choose which services to include", - Form: step2Form, - }, - { - Title: "Confirmation", - Description: "Review and confirm your workspace setup", - Form: step3Form, - }, - } - - v.wiz = wizard.NewWizard(steps, theme) - v.wiz.SetSize(v.width, v.height) - - // Initialize status bar - v.statusBar = status.NewStatusBar(theme) - - return v.wiz.Init() -} - -// OnExit is called when the WorkspaceInitWizardView is replaced by another view. -func (v *WorkspaceInitWizardView) OnExit() tea.Cmd { - return nil -} - -// Name returns the unique identifier for this view. -func (v *WorkspaceInitWizardView) Name() string { - return "workspace-init" -} - -// Keybindings returns the keyboard shortcuts for the WorkspaceInitWizardView. -func (v *WorkspaceInitWizardView) Keybindings() []engine.KeyBinding { - return []engine.KeyBinding{ - {Key: "enter", Description: "next"}, - {Key: "backspace", Description: "back"}, - {Key: "esc", Description: "cancel"}, - } -} - -// ToJSON returns wizard state as a JSON-marshallable map. -func (v *WorkspaceInitWizardView) ToJSON() any { - currentStep := 0 - totalSteps := 3 - isComplete := false - if v.wiz != nil { - currentStep = v.wiz.CurrentStep() - totalSteps = v.wiz.TotalSteps() - isComplete = v.wiz.IsComplete() - } - - return map[string]any{ - "workspaceName": v.workspaceName, - "services": v.services, - "confirmed": v.confirmed, - "currentStep": currentStep, - "totalSteps": totalSteps, - "complete": isComplete, - } -} diff --git a/pkg/ui/views/workspaceinitview_test.go b/pkg/ui/views/workspaceinitview_test.go deleted file mode 100644 index 8857a17..0000000 --- a/pkg/ui/views/workspaceinitview_test.go +++ /dev/null @@ -1,251 +0,0 @@ -package views - -import ( - "encoding/json" - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -// createWorkspaceInitFactory creates a ComponentFactory for WorkspaceInitWizardView testing. -func createWorkspaceInitFactory() ui.ComponentFactory { - profileCtx := profiles.GetDefaultProfileContext() - return ui.NewComponentFactory(profileCtx, components.BorderTierBlock) -} - -// createWorkspaceInitViewContext creates a ViewContext for WorkspaceInitWizardView testing. -func createWorkspaceInitViewContext() *engine.ViewContext { - profileCtx := profiles.GetDefaultProfileContext() - return &engine.ViewContext{ - Profile: profileCtx.Profile(), - Theme: profileCtx.Theme(), - Width: 80, - Height: 40, - Args: make(map[string]any), - } -} - -func TestNewWorkspaceInitView(t *testing.T) { - factory := createWorkspaceInitFactory() - view := NewWorkspaceInitWizardView(factory) - - assert.NotNil(t, view) - assert.NotNil(t, view.factory) - assert.Equal(t, 80, view.width) - assert.Equal(t, 40, view.height) - assert.Empty(t, view.services) - assert.False(t, view.confirmed) - assert.Nil(t, view.wiz) - assert.Nil(t, view.statusBar) -} - -func TestWorkspaceInitView_Name(t *testing.T) { - factory := createWorkspaceInitFactory() - view := NewWorkspaceInitWizardView(factory) - - assert.Equal(t, "workspace-init", view.Name()) -} - -func TestWorkspaceInitView_Keybindings(t *testing.T) { - factory := createWorkspaceInitFactory() - view := NewWorkspaceInitWizardView(factory) - - kb := view.Keybindings() - require.Len(t, kb, 3) - - keys := map[string]string{} - for _, k := range kb { - keys[k.Key] = k.Description - } - - assert.Equal(t, "next", keys["enter"]) - assert.Equal(t, "back", keys["backspace"]) - assert.Equal(t, "cancel", keys["esc"]) -} - -func TestWorkspaceInitView_OnEnter(t *testing.T) { - factory := createWorkspaceInitFactory() - view := NewWorkspaceInitWizardView(factory) - - ctx := createWorkspaceInitViewContext() - cmd := view.OnEnter(ctx) - - // Should return a wizard init command - assert.NotNil(t, cmd) - - assert.NotNil(t, view.wiz) - assert.NotNil(t, view.statusBar) - assert.Equal(t, 80, view.width) - assert.Equal(t, 40, view.height) -} - -func TestWorkspaceInitView_OnEnter_WizardSteps(t *testing.T) { - factory := createWorkspaceInitFactory() - view := NewWorkspaceInitWizardView(factory) - - ctx := createWorkspaceInitViewContext() - view.OnEnter(ctx) - - require.NotNil(t, view.wiz) - assert.Equal(t, 3, view.wiz.TotalSteps()) - assert.Equal(t, 0, view.wiz.CurrentStep()) - assert.False(t, view.wiz.IsComplete()) -} - -func TestWorkspaceInitView_View(t *testing.T) { - t.Run("before OnEnter returns empty", func(t *testing.T) { - factory := createWorkspaceInitFactory() - view := NewWorkspaceInitWizardView(factory) - output := view.View() - assert.Equal(t, "", output) - }) - - t.Run("zero dimensions returns empty", func(t *testing.T) { - factory := createWorkspaceInitFactory() - view := NewWorkspaceInitWizardView(factory) - ctx := createWorkspaceInitViewContext() - ctx.Width = 0 - ctx.Height = 0 - view.OnEnter(ctx) - assert.Equal(t, "", view.View()) - }) - - t.Run("after OnEnter renders wizard content", func(t *testing.T) { - factory := createWorkspaceInitFactory() - view := NewWorkspaceInitWizardView(factory) - ctx := createWorkspaceInitViewContext() - view.OnEnter(ctx) - - output := view.View() - assert.NotEmpty(t, output) - // Wizard should show step progress - assert.Contains(t, output, "Step 1 of 3") - }) -} - -func TestWorkspaceInitView_Update_Cancel(t *testing.T) { - factory := createWorkspaceInitFactory() - view := NewWorkspaceInitWizardView(factory) - ctx := createWorkspaceInitViewContext() - view.OnEnter(ctx) - - t.Run("ctrl+c cancels", func(t *testing.T) { - msg := tea.KeyMsg{Type: tea.KeyCtrlC} - updatedModel, cmd := view.Update(msg) - assert.NotNil(t, updatedModel) - assert.NotNil(t, cmd, "ctrl+c should return tea.Quit") - }) - - t.Run("esc cancels", func(t *testing.T) { - msg := tea.KeyMsg{Type: tea.KeyEsc} - updatedModel, cmd := view.Update(msg) - assert.NotNil(t, updatedModel) - assert.NotNil(t, cmd, "esc should return tea.Quit") - }) -} - -func TestWorkspaceInitView_Update_WindowResize(t *testing.T) { - factory := createWorkspaceInitFactory() - view := NewWorkspaceInitWizardView(factory) - ctx := createWorkspaceInitViewContext() - view.OnEnter(ctx) - - msg := tea.WindowSizeMsg{Width: 120, Height: 50} - updatedModel, cmd := view.Update(msg) - - assert.Nil(t, cmd) - updated := updatedModel.(*WorkspaceInitWizardView) - assert.Equal(t, 120, updated.width) - assert.Equal(t, 50, updated.height) -} - -func TestWorkspaceInitView_ToJSON(t *testing.T) { - factory := createWorkspaceInitFactory() - view := NewWorkspaceInitWizardView(factory) - ctx := createWorkspaceInitViewContext() - view.OnEnter(ctx) - - t.Run("returns JSON-marshallable data", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - require.NoError(t, err) - assert.NotEmpty(t, data) - }) - - t.Run("contains expected keys", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - - assert.Contains(t, result, "workspaceName") - assert.Contains(t, result, "services") - assert.Contains(t, result, "confirmed") - assert.Contains(t, result, "currentStep") - assert.Contains(t, result, "totalSteps") - assert.Contains(t, result, "complete") - }) - - t.Run("initial state reflects wizard start", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - - assert.Equal(t, float64(0), result["currentStep"]) - assert.Equal(t, float64(3), result["totalSteps"]) - assert.Equal(t, false, result["complete"]) - }) - - t.Run("works before OnEnter", func(t *testing.T) { - uninitView := NewWorkspaceInitWizardView(factory) - data, err := json.Marshal(uninitView.ToJSON()) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - assert.Contains(t, result, "workspaceName") - }) -} - -func TestWorkspaceInitView_Init(t *testing.T) { - t.Run("before OnEnter returns nil", func(t *testing.T) { - factory := createWorkspaceInitFactory() - view := NewWorkspaceInitWizardView(factory) - cmd := view.Init() - assert.Nil(t, cmd) - }) - - t.Run("after OnEnter returns wizard init command", func(t *testing.T) { - factory := createWorkspaceInitFactory() - view := NewWorkspaceInitWizardView(factory) - ctx := createWorkspaceInitViewContext() - view.OnEnter(ctx) - - cmd := view.Init() - assert.NotNil(t, cmd) - }) -} - -func TestWorkspaceInitView_OnExit(t *testing.T) { - factory := createWorkspaceInitFactory() - view := NewWorkspaceInitWizardView(factory) - cmd := view.OnExit() - assert.Nil(t, cmd) -} - -func TestWorkspaceInitView_ImplementsViewInterface(t *testing.T) { - var _ engine.View = (*WorkspaceInitWizardView)(nil) -} - -func TestWorkspaceInitView_ImplementsBubbleTeaModel(t *testing.T) { - var _ tea.Model = (*WorkspaceInitWizardView)(nil) -} diff --git a/pkg/ui/views/workspacerunview.go b/pkg/ui/views/workspacerunview.go deleted file mode 100644 index 30ebf5a..0000000 --- a/pkg/ui/views/workspacerunview.go +++ /dev/null @@ -1,235 +0,0 @@ -package views - -import ( - "strings" - - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/components/progress" - "github.com/arc-framework/arc-cli/pkg/ui/components/status" - "github.com/arc-framework/arc-cli/pkg/ui/engine" -) - -const ( - runStatusRunning = "running" - runStatusDone = "done" - runStatusError = "error" - runKeyCancelString = "ctrl+c" -) - -// WorkspaceRunView displays a workspace run in progress, showing a spinner, -// progress bar, and scrolling log lines. -// -// Layout: -// -// ┌────────────────────────────────────────┐ -// │ ⠸ Running workspace: my-workspace │ -// │ │ -// │ [████████░░░░░░░░] 50% │ -// │ │ -// │ [INFO] Starting services... │ -// │ [INFO] Pulling images... │ -// │ [OK] postgres started │ -// ├────────────────────────────────────────┤ -// │ ctrl+c: cancel │ -// └────────────────────────────────────────┘ -// -// Design: 017-ui-engine Phase 5 (T315-T322) -type WorkspaceRunView struct { - factory ui.ComponentFactory - spinnerModel spinner.Model - progressBar *progress.Progress - statusBar *status.StatusBar - workspaceName string - logs []string - progressVal float64 // 0.0-1.0 - runStatus string // "running", "done", "error" - width int - height int - initialized bool -} - -// NewWorkspaceRunView creates a new WorkspaceRunView with the given ComponentFactory. -func NewWorkspaceRunView(factory ui.ComponentFactory) *WorkspaceRunView { - return &WorkspaceRunView{ - factory: factory, - width: 80, - height: 40, - logs: []string{}, - progressVal: 0.0, - runStatus: runStatusRunning, - initialized: false, - } -} - -// Init initializes the WorkspaceRunView (Bubble Tea lifecycle). -// Returns a command to start the spinner ticking if initialized. -func (v *WorkspaceRunView) Init() tea.Cmd { - if !v.initialized { - return nil - } - return v.spinnerModel.Tick -} - -// Update handles messages and updates the WorkspaceRunView state. -func (v *WorkspaceRunView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - return v, nil - - case tea.KeyMsg: - if msg.String() == runKeyCancelString { - return v, tea.Quit - } - - case spinner.TickMsg: - if v.initialized { - var cmd tea.Cmd - v.spinnerModel, cmd = v.spinnerModel.Update(msg) - return v, cmd - } - } - - return v, nil -} - -// View renders the WorkspaceRunView as a string. -func (v *WorkspaceRunView) View() string { - if !v.initialized || v.width <= 0 || v.height <= 0 { - return "" - } - - sections := []string{v.renderHeader(), ""} - - // Progress bar - if v.progressBar != nil { - barWidth := v.width - 4 - if barWidth < 10 { - barWidth = 10 - } - pct := v.progressVal * 100.0 - sections = append(sections, v.progressBar.Render(barWidth, pct, ""), "") - } - - // Log lines - logSection := v.renderLogs() - if logSection != "" { - sections = append(sections, logSection, "") - } - - // Status bar - if v.statusBar != nil { - sections = append(sections, v.statusBar.Render(v.width, v.Keybindings(), "")) - } - - return lipgloss.JoinVertical(lipgloss.Left, sections...) -} - -// renderHeader renders the spinner + workspace name header line. -func (v *WorkspaceRunView) renderHeader() string { - spinnerView := v.spinnerModel.View() - - var label string - switch v.runStatus { - case runStatusDone: - label = "Completed workspace: " + v.workspaceName - case runStatusError: - label = "Error in workspace: " + v.workspaceName - default: - label = "Running workspace: " + v.workspaceName - } - - return spinnerView + " " + label -} - -// renderLogs renders the visible log lines, trimming to available height. -func (v *WorkspaceRunView) renderLogs() string { - if len(v.logs) == 0 { - return "" - } - - // Calculate max visible log lines. - // Reserve lines for: header (1), blank (1), progress (2), blank (1), status (2) = 7. - maxLines := v.height - 7 - if maxLines < 1 { - maxLines = 1 - } - - start := 0 - if len(v.logs) > maxLines { - start = len(v.logs) - maxLines - } - visible := v.logs[start:] - - return strings.Join(visible, "\n") -} - -// OnEnter is called when the WorkspaceRunView becomes active. -// Reads args and initializes all child components. -func (v *WorkspaceRunView) OnEnter(ctx *engine.ViewContext) tea.Cmd { - theme := ctx.Theme - - // Read args - if name, ok := ctx.Args["workspaceName"].(string); ok { - v.workspaceName = name - } - if logs, ok := ctx.Args["logs"].([]string); ok { - v.logs = logs - } - if p, ok := ctx.Args["progress"].(float64); ok { - v.progressVal = p - } - if s, ok := ctx.Args["status"].(string); ok { - v.runStatus = s - } - - // Update dimensions - v.width = ctx.Width - v.height = ctx.Height - - // Initialize spinner - v.spinnerModel = components.NewThemedSpinner(theme) - - // Initialize progress bar - v.progressBar = progress.NewProgress(theme) - - // Initialize status bar - v.statusBar = status.NewStatusBar(theme) - - v.initialized = true - - return v.spinnerModel.Tick -} - -// OnExit is called when the WorkspaceRunView is replaced by another view. -func (v *WorkspaceRunView) OnExit() tea.Cmd { - return nil -} - -// Name returns the unique identifier for this view. -func (v *WorkspaceRunView) Name() string { - return "workspace-run" -} - -// Keybindings returns the keyboard shortcuts for the WorkspaceRunView. -func (v *WorkspaceRunView) Keybindings() []engine.KeyBinding { - return []engine.KeyBinding{ - {Key: runKeyCancelString, Description: "cancel"}, - } -} - -// ToJSON returns the run status as a JSON-marshallable map. -func (v *WorkspaceRunView) ToJSON() any { - return map[string]any{ - "workspaceName": v.workspaceName, - "status": v.runStatus, - "progress": v.progressVal, - "logs": v.logs, - } -} diff --git a/pkg/ui/views/workspacerunview_test.go b/pkg/ui/views/workspacerunview_test.go deleted file mode 100644 index 3c140ab..0000000 --- a/pkg/ui/views/workspacerunview_test.go +++ /dev/null @@ -1,272 +0,0 @@ -package views - -import ( - "encoding/json" - "testing" - - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -// createWorkspaceRunFactory creates a ComponentFactory for WorkspaceRunView testing. -func createWorkspaceRunFactory() ui.ComponentFactory { - profileCtx := profiles.GetDefaultProfileContext() - return ui.NewComponentFactory(profileCtx, components.BorderTierBlock) -} - -// createWorkspaceRunViewContext creates a ViewContext for WorkspaceRunView testing. -func createWorkspaceRunViewContext() *engine.ViewContext { - profileCtx := profiles.GetDefaultProfileContext() - return &engine.ViewContext{ - Profile: profileCtx.Profile(), - Theme: profileCtx.Theme(), - Width: 80, - Height: 40, - Args: make(map[string]any), - } -} - -func TestNewWorkspaceRunView(t *testing.T) { - factory := createWorkspaceRunFactory() - view := NewWorkspaceRunView(factory) - - assert.NotNil(t, view) - assert.NotNil(t, view.factory) - assert.Equal(t, 80, view.width) - assert.Equal(t, 40, view.height) - assert.Empty(t, view.logs) - assert.Equal(t, 0.0, view.progressVal) - assert.Equal(t, runStatusRunning, view.runStatus) - assert.Nil(t, view.progressBar) - assert.Nil(t, view.statusBar) -} - -func TestWorkspaceRunView_Name(t *testing.T) { - factory := createWorkspaceRunFactory() - view := NewWorkspaceRunView(factory) - - assert.Equal(t, "workspace-run", view.Name()) -} - -func TestWorkspaceRunView_Keybindings(t *testing.T) { - factory := createWorkspaceRunFactory() - view := NewWorkspaceRunView(factory) - - kb := view.Keybindings() - require.Len(t, kb, 1) - assert.Equal(t, "ctrl+c", kb[0].Key) - assert.Equal(t, "cancel", kb[0].Description) -} - -func TestWorkspaceRunView_OnEnter(t *testing.T) { - factory := createWorkspaceRunFactory() - view := NewWorkspaceRunView(factory) - - ctx := createWorkspaceRunViewContext() - ctx.Args["workspaceName"] = "test-workspace" - ctx.Args["logs"] = []string{"Starting...", "Running postgres"} - ctx.Args["progress"] = 0.5 - ctx.Args["status"] = runStatusRunning - - cmd := view.OnEnter(ctx) - - // Should return the spinner tick command - assert.NotNil(t, cmd) - - assert.Equal(t, "test-workspace", view.workspaceName) - assert.Equal(t, []string{"Starting...", "Running postgres"}, view.logs) - assert.Equal(t, 0.5, view.progressVal) - assert.Equal(t, runStatusRunning, view.runStatus) - assert.Equal(t, 80, view.width) - assert.Equal(t, 40, view.height) - assert.NotNil(t, view.progressBar) - assert.NotNil(t, view.statusBar) -} - -func TestWorkspaceRunView_OnEnter_Defaults(t *testing.T) { - factory := createWorkspaceRunFactory() - view := NewWorkspaceRunView(factory) - - // OnEnter with empty args — should not panic, use defaults - ctx := createWorkspaceRunViewContext() - assert.NotPanics(t, func() { - view.OnEnter(ctx) - }) - - assert.Equal(t, "", view.workspaceName) - assert.Empty(t, view.logs) - assert.Equal(t, 0.0, view.progressVal) - assert.Equal(t, runStatusRunning, view.runStatus) -} - -func TestWorkspaceRunView_View(t *testing.T) { - t.Run("before OnEnter returns empty string", func(t *testing.T) { - factory := createWorkspaceRunFactory() - view := NewWorkspaceRunView(factory) - output := view.View() - assert.Equal(t, "", output) - }) - - t.Run("zero dimensions returns empty", func(t *testing.T) { - factory := createWorkspaceRunFactory() - view := NewWorkspaceRunView(factory) - ctx := createWorkspaceRunViewContext() - ctx.Width = 0 - ctx.Height = 0 - view.OnEnter(ctx) - assert.Equal(t, "", view.View()) - }) - - t.Run("after OnEnter renders content", func(t *testing.T) { - factory := createWorkspaceRunFactory() - view := NewWorkspaceRunView(factory) - ctx := createWorkspaceRunViewContext() - ctx.Args["workspaceName"] = "my-workspace" - ctx.Args["logs"] = []string{"log line 1", "log line 2"} - ctx.Args["progress"] = 0.75 - ctx.Args["status"] = runStatusRunning - view.OnEnter(ctx) - - output := view.View() - assert.NotEmpty(t, output) - assert.Contains(t, output, "my-workspace") - assert.Contains(t, output, "log line 1") - assert.Contains(t, output, "log line 2") - }) - - t.Run("done status renders correctly", func(t *testing.T) { - factory := createWorkspaceRunFactory() - view := NewWorkspaceRunView(factory) - ctx := createWorkspaceRunViewContext() - ctx.Args["workspaceName"] = "done-ws" - ctx.Args["status"] = runStatusDone - view.OnEnter(ctx) - - output := view.View() - assert.NotEmpty(t, output) - assert.Contains(t, output, "done-ws") - }) - - t.Run("error status renders correctly", func(t *testing.T) { - factory := createWorkspaceRunFactory() - view := NewWorkspaceRunView(factory) - ctx := createWorkspaceRunViewContext() - ctx.Args["workspaceName"] = "err-ws" - ctx.Args["status"] = runStatusError - view.OnEnter(ctx) - - output := view.View() - assert.NotEmpty(t, output) - assert.Contains(t, output, "err-ws") - }) -} - -func TestWorkspaceRunView_Update_Cancel(t *testing.T) { - factory := createWorkspaceRunFactory() - view := NewWorkspaceRunView(factory) - ctx := createWorkspaceRunViewContext() - view.OnEnter(ctx) - - msg := tea.KeyMsg{Type: tea.KeyCtrlC} - updatedModel, cmd := view.Update(msg) - - assert.NotNil(t, updatedModel) - assert.NotNil(t, cmd, "ctrl+c should return tea.Quit command") -} - -func TestWorkspaceRunView_Update_WindowResize(t *testing.T) { - factory := createWorkspaceRunFactory() - view := NewWorkspaceRunView(factory) - ctx := createWorkspaceRunViewContext() - view.OnEnter(ctx) - - msg := tea.WindowSizeMsg{Width: 120, Height: 50} - updatedModel, cmd := view.Update(msg) - - assert.Nil(t, cmd) - updated := updatedModel.(*WorkspaceRunView) - assert.Equal(t, 120, updated.width) - assert.Equal(t, 50, updated.height) -} - -func TestWorkspaceRunView_Update_SpinnerTick(t *testing.T) { - factory := createWorkspaceRunFactory() - view := NewWorkspaceRunView(factory) - ctx := createWorkspaceRunViewContext() - view.OnEnter(ctx) - - // Spinner tick should be handled and return a new tick command - tickMsg := spinner.TickMsg{} - updatedModel, cmd := view.Update(tickMsg) - - assert.NotNil(t, updatedModel) - assert.NotNil(t, cmd, "spinner tick should return a tick command") -} - -func TestWorkspaceRunView_ToJSON(t *testing.T) { - factory := createWorkspaceRunFactory() - view := NewWorkspaceRunView(factory) - ctx := createWorkspaceRunViewContext() - ctx.Args["workspaceName"] = "json-workspace" - ctx.Args["logs"] = []string{"line 1", "line 2"} - ctx.Args["progress"] = 0.8 - ctx.Args["status"] = runStatusRunning - view.OnEnter(ctx) - - t.Run("implements JSONExporter interface", func(t *testing.T) { - var _ engine.JSONExporter = view - }) - - t.Run("returns JSON-marshallable data", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - require.NoError(t, err) - assert.NotEmpty(t, data) - }) - - t.Run("contains expected keys", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - - assert.Contains(t, result, "workspaceName") - assert.Contains(t, result, "status") - assert.Contains(t, result, "progress") - assert.Contains(t, result, "logs") - }) - - t.Run("values match context args", func(t *testing.T) { - data, err := json.Marshal(view.ToJSON()) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - - assert.Equal(t, "json-workspace", result["workspaceName"]) - assert.Equal(t, runStatusRunning, result["status"]) - assert.InDelta(t, 0.8, result["progress"].(float64), 0.001) - }) -} - -func TestWorkspaceRunView_ImplementsViewInterface(t *testing.T) { - var _ engine.View = (*WorkspaceRunView)(nil) -} - -func TestWorkspaceRunView_ImplementsBubbleTeaModel(t *testing.T) { - var _ tea.Model = (*WorkspaceRunView)(nil) -} - -func TestWorkspaceRunView_OnExit(t *testing.T) { - factory := createWorkspaceRunFactory() - view := NewWorkspaceRunView(factory) - cmd := view.OnExit() - assert.Nil(t, cmd) -} diff --git a/pkg/workspace/errors.go b/pkg/workspace/errors.go index a0183a2..d30ae43 100644 --- a/pkg/workspace/errors.go +++ b/pkg/workspace/errors.go @@ -23,32 +23,32 @@ func (e *ManifestValidationError) Error() string { sb.WriteString("manifest validation failed") if e.Path != "" { - sb.WriteString(fmt.Sprintf(" in %s", e.Path)) + fmt.Fprintf(&sb, " in %s", e.Path) } if e.Line > 0 { - sb.WriteString(fmt.Sprintf(" at line %d", e.Line)) + fmt.Fprintf(&sb, " at line %d", e.Line) if e.Column > 0 { - sb.WriteString(fmt.Sprintf(", column %d", e.Column)) + fmt.Fprintf(&sb, ", column %d", e.Column) } } if e.Field != "" { - sb.WriteString(fmt.Sprintf(": field '%s'", e.Field)) + fmt.Fprintf(&sb, ": field '%s'", e.Field) } if e.Message != "" { - sb.WriteString(fmt.Sprintf(": %s", e.Message)) + fmt.Fprintf(&sb, ": %s", e.Message) } if e.Expected != "" && e.Actual != "" { - sb.WriteString(fmt.Sprintf(" (expected: %s, got: %s)", e.Expected, e.Actual)) + fmt.Fprintf(&sb, " (expected: %s, got: %s)", e.Expected, e.Actual) } if len(e.Errors) > 0 { sb.WriteString("\n Additional errors:") for _, err := range e.Errors { - sb.WriteString(fmt.Sprintf("\n - %s", err)) + fmt.Fprintf(&sb, "\n - %s", err) } } @@ -68,10 +68,10 @@ type PermissionDeniedError struct { func (e *PermissionDeniedError) Error() string { var sb strings.Builder - sb.WriteString(fmt.Sprintf("permission denied: cannot %s '%s'", e.Operation, e.Path)) + fmt.Fprintf(&sb, "permission denied: cannot %s '%s'", e.Operation, e.Path) if e.CurrentMode != "" && e.RequiredMode != "" { - sb.WriteString(fmt.Sprintf(" (current: %s, required: %s)", e.CurrentMode, e.RequiredMode)) + fmt.Fprintf(&sb, " (current: %s, required: %s)", e.CurrentMode, e.RequiredMode) } return sb.String() @@ -157,15 +157,15 @@ func (e *ConfigurationError) Error() string { sb.WriteString("configuration error") if e.Component != "" { - sb.WriteString(fmt.Sprintf(" in %s", e.Component)) + fmt.Fprintf(&sb, " in %s", e.Component) } if e.Field != "" { - sb.WriteString(fmt.Sprintf(" (field: %s)", e.Field)) + fmt.Fprintf(&sb, " (field: %s)", e.Field) } if e.Message != "" { - sb.WriteString(fmt.Sprintf(": %s", e.Message)) + fmt.Fprintf(&sb, ": %s", e.Message) } return sb.String() @@ -189,19 +189,19 @@ func (e *GenerationError) Error() string { sb.WriteString("generation failed") if e.Phase != "" { - sb.WriteString(fmt.Sprintf(" during %s", e.Phase)) + fmt.Fprintf(&sb, " during %s", e.Phase) } if e.Template != "" { - sb.WriteString(fmt.Sprintf(" processing template '%s'", e.Template)) + fmt.Fprintf(&sb, " processing template '%s'", e.Template) } if e.Message != "" { - sb.WriteString(fmt.Sprintf(": %s", e.Message)) + fmt.Fprintf(&sb, ": %s", e.Message) } if e.Cause != nil { - sb.WriteString(fmt.Sprintf(" (%v)", e.Cause)) + fmt.Fprintf(&sb, " (%v)", e.Cause) } return sb.String() diff --git a/pkg/workspace/formatter.go b/pkg/workspace/formatter.go index ff86ccf..8b10501 100644 --- a/pkg/workspace/formatter.go +++ b/pkg/workspace/formatter.go @@ -8,7 +8,7 @@ import ( "github.com/arc-framework/arc-cli/internal/preferences" "github.com/arc-framework/arc-cli/internal/state" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" + uithemeldr "github.com/arc-framework/arc-cli/pkg/ui/theme" ) // Tier ID constants @@ -28,38 +28,35 @@ func NewFormatter(useColor bool) *Formatter { return &Formatter{useColor: useColor} } -// resolveTierName converts a tier ID to a display name using the active profile -// Falls back to generic "Tier N" names if profile resolution fails func (f *Formatter) resolveTierName(tierID string) string { - // Map tier IDs to tier indices (0-2) tierIndex := getTierIndex(tierID) if tierIndex == -1 { - // Unknown tier ID, return as-is return tierID } - // Try to resolve using profile system prefs, err := preferences.Load() if err != nil { - // If preferences fail to load, fall back to generic names return getGenericTierName(tierIndex) } - // Load profiles and create resolver - repo, err := profiles.NewRepository() + loader, err := uithemeldr.NewLoader() if err != nil { - // If profile loading fails, fall back to generic names return getGenericTierName(tierIndex) } - resolver := profiles.NewResolver(repo, prefs) - tierName, err := resolver.GetActiveTierName(tierIndex) + profileID := prefs.GetProfile() + if profileID == "" { + profileID = "enterprise" + } + + profile, err := loader.GetProfile(profileID) if err != nil { - // If resolution fails, fall back to generic names return getGenericTierName(tierIndex) } - - return tierName + if tierIndex >= len(profile.TierNames) { + return getGenericTierName(tierIndex) + } + return profile.TierNames[tierIndex] } // getTierIndex maps tier IDs to tier indices (0-2) @@ -121,7 +118,7 @@ func (f *Formatter) FormatWorkspaceInfo(info *WorkspaceInfo) string { sb.WriteString(" (none)\n") } else { for _, feature := range info.EnabledFeatures { - sb.WriteString(fmt.Sprintf(" • %s\n", feature)) + fmt.Fprintf(&sb, " • %s\n", feature) } } @@ -145,8 +142,8 @@ func (f *Formatter) FormatWorkspaceInfo(info *WorkspaceInfo) string { sb.WriteString(f.formatOperation(op)) } if len(info.OperationHistory) > limit { - sb.WriteString(fmt.Sprintf(" ... and %d more (use 'arc workspace history' to see all)\n", - len(info.OperationHistory)-limit)) + fmt.Fprintf(&sb, " ... and %d more (use 'arc workspace history' to see all)\n", + len(info.OperationHistory)-limit) } } @@ -170,7 +167,7 @@ func (f *Formatter) FormatHistory(operations []*state.Operation) string { sb.WriteString(f.formatHistoryRow(op)) } - sb.WriteString(fmt.Sprintf("\nTotal: %d operations\n", len(operations))) + fmt.Fprintf(&sb, "\nTotal: %d operations\n", len(operations)) return sb.String() } @@ -187,10 +184,10 @@ func (f *Formatter) FormatGeneratedFiles(workspaceRoot string, files []string) s sb.WriteString("\n") generatedDir := filepath.Join(workspaceRoot, ".arc", "generated") - sb.WriteString(fmt.Sprintf("Location: %s\n\n", generatedDir)) + fmt.Fprintf(&sb, "Location: %s\n\n", generatedDir) for _, file := range files { - sb.WriteString(fmt.Sprintf(" • %s\n", file)) + fmt.Fprintf(&sb, " • %s\n", file) } return sb.String() @@ -239,14 +236,14 @@ func (f *Formatter) formatLastGeneration(gen *state.GenerationResult) string { if len(gen.GeneratedFiles) > 0 { sb.WriteString(f.formatKeyValue("Files Generated", fmt.Sprintf("%d", len(gen.GeneratedFiles)))) for _, file := range gen.GeneratedFiles { - sb.WriteString(fmt.Sprintf(" - %s\n", file)) + fmt.Fprintf(&sb, " - %s\n", file) } } if len(gen.Errors) > 0 { sb.WriteString(" Errors:\n") for _, err := range gen.Errors { - sb.WriteString(fmt.Sprintf(" ! %s\n", err)) + fmt.Fprintf(&sb, " ! %s\n", err) } } diff --git a/pkg/workspace/formatter_test.go b/pkg/workspace/formatter_test.go index 8babdae..b9f3af5 100644 --- a/pkg/workspace/formatter_test.go +++ b/pkg/workspace/formatter_test.go @@ -804,20 +804,6 @@ func TestTierResolution_WithProfiles(t *testing.T) { shouldResolve bool description string }{ - { - name: "super-saiyan resolves to profile tier name or fallback", - tierID: "super-saiyan", - expectedFallback: "Tier 1", - shouldResolve: true, - description: "Legacy DBZ tier ID should resolve to profile tier name or generic fallback", - }, - { - name: "super-saiyan-blue resolves to profile tier name or fallback", - tierID: "super-saiyan-blue", - expectedFallback: "Tier 2", - shouldResolve: true, - description: "Legacy DBZ tier ID should resolve to profile tier name or generic fallback", - }, { name: "ultra-instinct resolves to profile tier name or fallback", tierID: "ultra-instinct", diff --git a/pkg/workspace/initializer.go b/pkg/workspace/initializer.go index 3124c73..240c75e 100644 --- a/pkg/workspace/initializer.go +++ b/pkg/workspace/initializer.go @@ -94,7 +94,7 @@ func (i *Initializer) Initialize(opts InitializeOptions) error { // Set default tier if not specified tier := opts.Tier if tier == "" { - tier = tierIDSuperSaiyan // Default to Super Saiyan tier + tier = tierIDUltraInstinct // Default to Ultra Instinct tier } templateData := map[string]string{ "Tier": tier, diff --git a/pkg/workspace/messages.go b/pkg/workspace/messages.go index ac8067c..c644516 100644 --- a/pkg/workspace/messages.go +++ b/pkg/workspace/messages.go @@ -27,12 +27,12 @@ func (m *UserMessage) String() string { if len(m.Suggestions) > 0 { sb.WriteString("\n\nSuggested actions:") for _, s := range m.Suggestions { - sb.WriteString(fmt.Sprintf("\n - %s", s)) + fmt.Fprintf(&sb, "\n - %s", s) } } if m.DocLink != "" { - sb.WriteString(fmt.Sprintf("\n\nFor more information, see: %s", m.DocLink)) + fmt.Fprintf(&sb, "\n\nFor more information, see: %s", m.DocLink) } return sb.String() diff --git a/pkg/workspace/services/mapping.go b/pkg/workspace/services/mapping.go index 31539fe..29c7d41 100644 --- a/pkg/workspace/services/mapping.go +++ b/pkg/workspace/services/mapping.go @@ -38,7 +38,7 @@ func NewMapper() *Mapper { // 4. Return the complete list of services to be generated // // Returns an error if a service references an unknown dependency. -func (m *Mapper) MapFeaturesToServices(manifest *manifest.Manifest) ([]*ServiceDefinition, error) { +func (m *Mapper) MapFeaturesToServices(mf *manifest.Manifest) ([]*ServiceDefinition, error) { serviceMap := make(map[string]*ServiceDefinition) // Phase 1: Always include base infrastructure (gateway, etc.) @@ -49,7 +49,7 @@ func (m *Mapper) MapFeaturesToServices(manifest *manifest.Manifest) ([]*ServiceD // Phase 2: Add services for each enabled feature // Services declare which features they belong to via FeatureFlags - for featureName, enabled := range manifest.Features { + for featureName, enabled := range mf.Features { if !enabled { continue } diff --git a/specs/015-ui-refactor/contracts/component-factory.go b/specs/015-ui-refactor/contracts/component-factory.go deleted file mode 100644 index 98bd21c..0000000 --- a/specs/015-ui-refactor/contracts/component-factory.go +++ /dev/null @@ -1,129 +0,0 @@ -// Package contracts defines the interfaces for the 015-ui-refactor feature. -// These are DESIGN CONTRACTS — not compilable code. They specify the API surface -// that implementations must satisfy. -// -// Location: specs/015-ui-refactor/contracts/ -// These contracts will be moved to their implementation packages during development. -package contracts - -import ( - "github.com/charmbracelet/lipgloss" - - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// BorderTier represents the three tiers of border rendering capability. -type BorderTier int - -const ( - // BorderTierNone is Tier 1: borderless, uses spacing + color only. Cannot break. - BorderTierNone BorderTier = iota - // BorderTierBlock is Tier 2: half-block borders (▀▄▌▐). High terminal compatibility. - BorderTierBlock - // BorderTierClassic is Tier 3: classic Unicode borders (╭╮╰╯). Opt-in only. - BorderTierClassic -) - -// ComponentFactory produces pre-themed, render-ready UI components. -// Created once per command execution from ProfileContext + BorderMode. -// All methods return rendered strings ready for terminal output. -// -// Design Pattern: styled-components — colors come from profile, not hardcoded. -// Thread Safety: NOT thread-safe. Create one per goroutine or command. -// -// Usage: -// -// factory := NewComponentFactory(profileCtx, borderMode) -// output := factory.Card("System Info", content) -type ComponentFactory interface { - // Card renders a bordered content card with a title. - // Title is rendered in profile primary color. - // Border style depends on current BorderTier. - Card(title, content string) string - - // CardFocused renders a card with a highlighted border (for active selection). - CardFocused(title, content string) string - - // CardGrid renders a responsive grid of cards. - // Cards reflow from multi-column to single-column based on width. - // Breakpoint: width < 100 → single column. - CardGrid(cards []CardData, width int) string - - // TabBar renders a horizontal tab navigation bar. - // Active tab has filled/bright styling, inactive tabs are muted. - TabBar(tabs []TabItem, activeIdx, width int) string - - // SplitPane renders a left/right split layout with a divider. - // ratio is left pane proportion (0.0-1.0), typically 0.3. - SplitPane(left, right string, ratio float64, width int) string - - // StatusRail renders a bottom status bar with sections. - StatusRail(sections []RailSection, width int) string - - // Toast renders an overlay notification with severity theming. - Toast(message string, severity Severity) string - - // SectionHeader renders a themed section divider with icon and title. - // Uses profile primary color + bold. - SectionHeader(icon, title string) string - - // Table renders a themed table with headers and rows. - Table(headers []string, rows [][]string) string - - // Border returns the lipgloss.Border for the current tier. - // Tier 1: HiddenBorder(), Tier 2: OuterHalfBlockBorder(), Tier 3: RoundedBorder() - Border() lipgloss.Border - - // SetBorderMode changes the border tier at runtime. - // Triggers style cache invalidation. - SetBorderMode(tier BorderTier) - - // ProfileContext returns the underlying ProfileContext. - ProfileContext() *profiles.ProfileContext - - // Theme returns the current theme (shortcut for ProfileContext().Theme()). - Theme() *themes.Theme -} - -// CardData holds the data for a single card in the grid. -type CardData struct { - Title string - Icon string - Rows []KeyValueRow -} - -// KeyValueRow is a single key-value pair for display in cards. -type KeyValueRow struct { - Key string - Value string -} - -// TabItem represents a single tab in the tab bar. -type TabItem struct { - ID int - Label string - Icon string -} - -// RailSection represents a single section in the status rail. -type RailSection struct { - Icon string - Label string - Value string -} - -// Severity represents error/warning/info levels. -type Severity int - -const ( - SeverityError Severity = iota - SeverityWarning - SeverityInfo -) - -// NewComponentFactory creates a ComponentFactory from a ProfileContext and BorderTier. -// If profileCtx is nil, falls back to enterprise profile (never panics). -// Styles are pre-computed and cached on construction. -// -// func NewComponentFactory(profileCtx *profiles.ProfileContext, tier BorderTier) ComponentFactory diff --git a/specs/015-ui-refactor/contracts/dashboard-model.go b/specs/015-ui-refactor/contracts/dashboard-model.go deleted file mode 100644 index b3bd87c..0000000 --- a/specs/015-ui-refactor/contracts/dashboard-model.go +++ /dev/null @@ -1,105 +0,0 @@ -package contracts - -import ( - tea "github.com/charmbracelet/bubbletea" - - "github.com/arc-framework/arc-cli/internal/app" - "github.com/arc-framework/arc-cli/pkg/catalog" -) - -// DashboardApp is the entry point for launching the full-screen dashboard. -// Called from root.go when `arc` is invoked with no subcommand. -// -// Design: This is the "React App" — the root component that owns all state. -// It follows Bubble Tea's Elm Architecture: Model → Update → View. -// -// Usage in root.go: -// -// if shouldLaunchDashboard(cmd) { -// return dashboard.Launch(appContext) -// } -// -// func Launch(ctx *app.Context) error - -// TabID identifies dashboard tabs. -type TabID int - -const ( - TabDashboard TabID = iota // Tab 1: System info, profile, service overview - TabServices // Tab 2: Split-pane service browser - TabWorkspace // Tab 3: Workspace status + operations - TabConfig // Tab 4: Settings editor -) - -// DashboardModel is the root tea.Model for the full-screen dashboard. -// -// State Machine: -// -// ┌──────────────┐ -// Init() ─────► │ Loading │ (waiting for WindowSizeMsg) -// └──────┬───────┘ -// │ WindowSizeMsg -// ▼ -// ┌──────────────┐ -// ┌─────│ Dashboard │◄───┐ -// │ └──────────────┘ │ -// Tab/1-4 │ │ Tab/1-4 -// │ ┌──────────────┐ │ -// ├────►│ Services │────┤ -// │ └──────────────┘ │ -// │ ┌──────────────┐ │ -// ├────►│ Workspace │────┤ -// │ └──────────────┘ │ -// │ ┌──────────────┐ │ -// └────►│ Config │────┘ -// └──────────────┘ -// │ -// q/Ctrl+C/Esc -// ▼ -// ┌──────────────┐ -// │ Quit │ -// └──────────────┘ -// -// Keybindings: -// - Tab, →, l: Next tab -// - Shift+Tab, ←, h: Previous tab -// - 1-4: Jump to tab by number -// - q, Ctrl+C: Quit (from root view) -// - Esc: Back to root / dismiss toast -// - ?: Toggle help bar -// -// Each tab view is a "sub-component" that receives delegated Update messages -// when its tab is active. The root model handles global keys first. -type DashboardModel interface { - tea.Model - - // ActiveTab returns the currently active tab. - ActiveTab() TabID - - // ShowToast displays a toast notification overlay. - // Auto-dismisses after duration or on keypress. - ShowToast(message string, severity Severity) tea.Cmd - - // SwitchTab changes the active tab with optional animation. - SwitchTab(tab TabID) tea.Cmd -} - -// NewDashboardModel creates the root dashboard model. -// Loads initial data from app.Context (catalog, profile, preferences). -// -// func NewDashboardModel(ctx *app.Context) DashboardModel - -// ShouldLaunchDashboard determines if the dashboard should launch. -// Returns false if: -// - ARC_NO_TUI=1 is set -// - Output is not a TTY (piped) -// - --help or --json flags are set -// - A subcommand is specified -// -// func ShouldLaunchDashboard(ctx *app.Context, args []string) bool - -// Ensure imports are used -var ( - _ *app.Context - _ catalog.Catalog -) diff --git a/specs/015-ui-refactor/contracts/error-boundary.go b/specs/015-ui-refactor/contracts/error-boundary.go deleted file mode 100644 index 9a32bbb..0000000 --- a/specs/015-ui-refactor/contracts/error-boundary.go +++ /dev/null @@ -1,102 +0,0 @@ -package contracts - -import ( - "github.com/spf13/cobra" - - "github.com/arc-framework/arc-cli/pkg/ui" -) - -// ErrorBoundary wraps Cobra RunE functions with unified error handling. -// Like React's ErrorBoundary, it catches all errors from child components -// (commands) and renders them through a single themed pipeline. -// -// Design Pattern: Middleware — wraps RunE, intercepts errors, renders uniformly. -// -// Usage in root.go PersistentPreRunE: -// -// boundary := NewErrorBoundary(factory, uiService, hintRegistry) -// // Wrap all RunE functions: -// cmd.RunE = boundary.Wrap(cmd.RunE) -type ErrorBoundary interface { - // Wrap wraps a Cobra RunE function with error handling middleware. - // Returns a new RunE function that: - // 1. Executes the original function - // 2. If error returned, enriches it (adds hints via HintRegistry) - // 3. Renders the error through the appropriate path (TTY/non-TTY/JSON/dashboard) - // 4. Returns the error (for exit code handling) - Wrap(fn func(cmd *cobra.Command, args []string) error) func(cmd *cobra.Command, args []string) error - - // RenderError renders an error through the appropriate path. - // Called by Wrap, but also available for manual use. - RenderError(err error) - - // SetDashboardMode enables toast-style error rendering. - // When true, errors are rendered as overlay notifications instead of full ErrorBoxes. - SetDashboardMode(enabled bool) - - // SetJSONMode enables JSON error output. - SetJSONMode(enabled bool) -} - -// ArcError is a rich error type with context, hints, and severity. -// -// Usage: -// -// return arcerr.New("Failed to load workspace", err). -// WithHint("Ensure arc.yaml exists in the current directory"). -// WithSeverity(SeverityError) -// -// Plain errors also work — ErrorBoundary auto-detects context from cmd.Use -// and matches hints from HintRegistry. -type ArcError interface { - error - - // GetContext returns the human-readable error context. - GetContext() string - - // GetHint returns the actionable suggestion. - GetHint() string - - // GetSeverity returns the error severity level. - GetSeverity() Severity - - // GetExitCode returns the process exit code. - GetExitCode() int - - // Unwrap returns the wrapped error for errors.Is/As support. - Unwrap() error -} - -// HintRegistry maps error message patterns to actionable hints. -// -// Usage: -// -// registry := NewHintRegistry() -// registry.Register(`permission denied`, "Check file permissions or try with sudo") -// hint := registry.Match("open /etc/file: permission denied") -// // hint == "Check file permissions or try with sudo" -type HintRegistry interface { - // Register adds a pattern → hint mapping. - // Pattern is a regex string (compiled on registration). - Register(pattern, hint string) error - - // Match finds the first matching hint for an error message. - // Returns empty string if no pattern matches. - // Falls back to default hint: "Run with --verbose for more details" - Match(errorMessage string) string -} - -// NewErrorBoundary creates an ErrorBoundary with the given dependencies. -// -// func NewErrorBoundary(factory ComponentFactory, ui *ui.Service, hints HintRegistry) ErrorBoundary - -// NewArcError creates a new ArcError wrapping the given error with context. -// -// func NewArcError(context string, err error) ArcError - -// NewHintRegistry creates a HintRegistry pre-loaded with default patterns. -// -// func NewHintRegistry() HintRegistry - -// Ensure ui import is used -var _ *ui.Service diff --git a/specs/015-ui-refactor/contracts/safe-border.go b/specs/015-ui-refactor/contracts/safe-border.go deleted file mode 100644 index 1a6b9ab..0000000 --- a/specs/015-ui-refactor/contracts/safe-border.go +++ /dev/null @@ -1,81 +0,0 @@ -package contracts - -import "github.com/charmbracelet/lipgloss" - -// SafeBorder detects terminal border rendering capability and provides -// the appropriate lipgloss.Border for the current environment. -// -// Design: Detect once, cache forever. Run at startup, store in app.Context. -// -// Detection priority: -// 1. ARC_BORDER_MODE env var (user override) -// 2. state.json border_mode preference (persisted choice) -// 3. Terminal capability auto-detection -// 4. Default: Tier 1 (borderless — cannot break) -// -// Usage: -// -// sb := NewSafeBorder() // Detects once -// border := sb.Border() // Cached result -// tier := sb.Tier() // For conditional logic -// factory := NewComponentFactory(profileCtx, sb.Tier()) -type SafeBorder interface { - // Tier returns the detected BorderTier. - Tier() BorderTier - - // Border returns the lipgloss.Border for the current tier. - // Tier 1: lipgloss.HiddenBorder() — invisible borders, preserves layout math - // Tier 2: lipgloss.OuterHalfBlockBorder() — half-block chars (▀▄▌▐) - // Tier 3: lipgloss.RoundedBorder() — classic Unicode (╭╮╰╯) - Border() lipgloss.Border - - // FocusBorder returns the border for focused/active elements. - // Same tier as Border() but may use a different style within that tier. - // Tier 1: lipgloss.HiddenBorder() (focus indicated by background color) - // Tier 2: lipgloss.OuterHalfBlockBorder() (focus indicated by brighter color) - // Tier 3: lipgloss.ThickBorder() (thicker border for focus) - FocusBorder() lipgloss.Border - - // IsClassic returns true if Tier 3 (classic Unicode) is active. - IsClassic() bool - - // IsBorderless returns true if Tier 1 (no visible borders) is active. - IsBorderless() bool - - // TerminalInfo returns detected terminal information for diagnostics. - // Useful for `arc info` display and debugging. - TerminalInfo() TerminalInfo - - // Override forces a specific tier. Used for runtime switching from Config tab. - // Persists to state.json if persist is true. - Override(tier BorderTier, persist bool) error -} - -// TerminalInfo holds detected terminal environment information. -type TerminalInfo struct { - // TermProgram is the TERM_PROGRAM env value (e.g., "iTerm.app", "vscode") - TermProgram string - // Term is the TERM env value (e.g., "xterm-256color") - Term string - // ColorTerm is the COLORTERM env value (e.g., "truecolor") - ColorTerm string - // HasWindowsTerminal is true if WT_SESSION env is set - HasWindowsTerminal bool - // DetectedTier is the auto-detected tier before any overrides - DetectedTier BorderTier - // ActiveTier is the current active tier (may differ from detected if overridden) - ActiveTier BorderTier - // OverrideSource describes where the override came from ("env", "config", "auto") - OverrideSource string -} - -// NewSafeBorder creates a SafeBorder, running detection immediately. -// Detection reads environment variables and caches the result. -// This function is safe to call multiple times (result is cached internally). -// -// func NewSafeBorder() SafeBorder - -// NewSafeBorderWithOverride creates a SafeBorder with a forced tier. -// Used for testing. -// -// func NewSafeBorderWithOverride(tier BorderTier) SafeBorder diff --git a/specs/018-ui-design/.speckit.json b/specs/018-ui-design/.speckit.json new file mode 100644 index 0000000..e1bb7b6 --- /dev/null +++ b/specs/018-ui-design/.speckit.json @@ -0,0 +1,4 @@ +{ + "workflow": "default", + "selectedAt": "2026-03-03T00:00:00.000Z" +} diff --git a/specs/018-ui-design/data-model.md b/specs/018-ui-design/data-model.md new file mode 100644 index 0000000..a2c009e --- /dev/null +++ b/specs/018-ui-design/data-model.md @@ -0,0 +1,606 @@ +# Data Model: UI Design & Architecture Rebuild + +**Feature**: 018-ui-design +**Date**: 2026-03-03 +**Status**: Living Document + +## Overview + +This document defines all data entities, their relationships, and lifecycle states for the UI rebuild. Use this as the single source of truth for structure definitions. + +--- + +## 1. Theme System Entities + +### 1.1 Theme + +**Purpose**: Defines color palette for visual styling. + +**Structure**: + +```go +type Theme struct { + ID string `yaml:"id"` // Unique identifier (e.g., "cyberstart") + Name string `yaml:"name"` // Display name (e.g., "CyberStart Dark") + Colors ColorSet `yaml:"colors"` // Color definitions +} + +type ColorSet struct { + Primary string `yaml:"primary"` // Primary brand color + Secondary string `yaml:"secondary"` // Secondary accent + Accent string `yaml:"accent"` // Highlight color + Background string `yaml:"background"` // Background color + Foreground string `yaml:"foreground"` // Text color + Success string `yaml:"success"` // Success state + Warning string `yaml:"warning"` // Warning state + Error string `yaml:"error"` // Error state + Muted string `yaml:"muted"` // Muted/disabled + Border string `yaml:"border"` // Border color +} +``` + +**Validation Rules**: + +- ID must be lowercase, alphanumeric, hyphens only +- Colors must be valid hex codes (#RRGGBB) or named ANSI colors +- All 10 color fields required + +**Relationships**: + +- Referenced by Profile.ThemeID (1:N) + +**Storage**: `pkg/ui/theme/embedded/themes/{id}.yaml` + +**Example**: + +```yaml +id: cyberstart +name: CyberStart Dark +colors: + primary: "#00B4D8" + secondary: "#0077B6" + accent: "#90E0EF" + background: "#03045E" + foreground: "#CAF0F8" + success: "#06FFA5" + warning: "#FFD60A" + error: "#FF006E" + muted: "#8D99AE" + border: "#0077B6" +``` + +### 1.2 Profile + +**Purpose**: Defines branding (logo, tier names, theme association). + +**Structure**: + +```go +type Profile struct { + ID string `yaml:"id"` // Unique identifier + Name string `yaml:"name"` // Display name + TierNames []string `yaml:"tier_names"` // 3 tier names [Tier1, Tier2, Tier3] + Logo string `yaml:"logo"` // ASCII logo filename + ThemeID string `yaml:"theme_id"` // Reference to Theme.ID +} +``` + +**Validation Rules**: + +- ID must be lowercase, alphanumeric, hyphens only +- TierNames must have exactly 3 elements +- Logo must be filename (stored in embedded/logos/) +- ThemeID must reference existing Theme + +**Relationships**: + +- References Theme (N:1) +- Referenced by StateManager (current profile) + +**Storage**: `pkg/ui/theme/embedded/profiles/{id}.yaml` + +**Example**: + +```yaml +id: cyberstart +name: CyberStart +tier_names: ["Trainee", "Associate", "Lead"] +logo: "cyberstart.txt" +theme_id: cyberstart +``` + +### 1.3 Skin + +**Purpose**: Defines layout rules (navigation style, borders, density). + +**Structure**: + +```go +type Skin struct { + ID string `yaml:"id"` // Unique identifier + Name string `yaml:"name"` // Display name + Navigation NavigationStyle `yaml:"navigation"` // Layout config + Borders BorderStyle `yaml:"borders"` // Border config + Density DensityLevel `yaml:"density"` // Spacing config +} + +type NavigationStyle struct { + Style string `yaml:"style"` // "tab-bar" | "sidebar" + Position string `yaml:"position"` // "top" | "left" +} + +type BorderStyle struct { + Style string `yaml:"style"` // "rounded" | "square" | "thick" | "double" + Width string `yaml:"width"` // "thin" | "normal" | "thick" +} + +type DensityLevel string + +const ( + DensityCompact DensityLevel = "compact" + DensityComfortable DensityLevel = "comfortable" + DensitySpacious DensityLevel = "spacious" +) +``` + +**Validation Rules**: + +- Navigation.Style must be "tab-bar" or "sidebar" +- Navigation.Position must be "top" or "left" +- Borders.Style must be one of 4 options +- Borders.Width must be one of 3 options +- Density must be one of 3 options + +**Relationships**: + +- Referenced by StateManager (current skin) + +**Storage**: `pkg/ui/theme/embedded/skins/{id}.yaml` + +**Example**: + +```yaml +id: gh-dash +name: GitHub Dashboard Style +navigation: + style: tab-bar + position: top +borders: + style: rounded + width: normal +density: comfortable +``` + +### 1.4 Context + +**Purpose**: Unified dependency injection container passed to all components. + +**Structure**: + +```go +type Context struct { + Theme *Theme // Current theme (colors) + Profile *Profile // Current profile (branding) + Skin *Skin // Current skin (layout) + Registry *Registry // Style cache for lipgloss.Style +} + +func (c *Context) PrimaryStyle() lipgloss.Style { + return c.Registry.GetStyle("primary", func() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(c.Theme.Colors.Primary)) + }) +} +``` + +**Lifecycle**: + +1. Created by StateManager during initialization +2. Passed to all components as first parameter +3. Updated when profile/theme/skin changes +4. Cached styles invalidated on theme change + +**Relationships**: + +- Composes Theme, Profile, Skin (1:1:1) +- Owns Registry (1:1) +- Passed to all components (1:N) + +--- + +## 2. Engine Entities + +### 2.1 View (Interface) + +**Purpose**: Defines contract for pluggable content panels. + +**Structure**: + +```go +type View interface { + // Lifecycle methods + Init() tea.Cmd + Update(msg tea.Msg) (View, tea.Cmd) + View() string + OnEnter(ctx *ViewContext) tea.Cmd + OnExit() tea.Cmd + + // Metadata + Name() string + Keybindings() []KeyBinding +} +``` + +**States**: + +``` +[Uninitialized] --Init()--> [Initialized] --OnEnter()--> [Active] --OnExit()--> [Inactive] + | + +--Update()--> [Active] +``` + +**Implementations** (9 total): + +- home, services_list, service_detail, workspace_info, workspace_history, workspace_run, config_overview, version, init_wizard + +### 2.2 ViewContext + +**Purpose**: Props passed to views on OnEnter (read-only). + +**Structure**: + +```go +type ViewContext struct { + // Theming + Theme *theme.Theme + Profile *theme.Profile + Skin *theme.Skin + + // Dimensions (content area, excluding shell frame) + Width int + Height int + + // Route parameters + Args map[string]any + + // Backend services (dependency injection) + Catalog catalog.Catalog + Workspace *workspace.Manager + Store *store.Store + + // State reference (for triggering state changes) + State *StateManager +} +``` + +**Lifecycle**: + +1. Built by StateManager.BuildViewContext() +2. Passed to View.OnEnter() +3. View stores reference (optional) +4. Rebuilt on every navigation + +### 2.3 StateManager + +**Purpose**: Manages preferences, resolves theme/profile/skin, builds ViewContext. + +**Structure**: + +```go +type StateManager struct { + prefs *preferences.Preferences + themes map[string]*theme.Theme + profiles map[string]*theme.Profile + skins map[string]*theme.Skin + current *theme.Context + catalog catalog.Catalog + workspace *workspace.Manager + store *store.Store +} + +func (s *StateManager) BuildViewContext(args map[string]any) *ViewContext +func (s *StateManager) ChangeProfile(profileID string) error +func (s *StateManager) ChangeTheme(themeID string) error +func (s *StateManager) ChangeSkin(skinID string) error +func (s *StateManager) CurrentContext() *theme.Context +``` + +**Lifecycle**: + +1. Initialized in bootstrap.go +2. Loads themes/profiles/skins from embedded FS +3. Reads preferences from ~/.arc/state.json +4. Resolves current profile/theme/skin +5. Provides ViewContext to views + +### 2.4 Router + +**Purpose**: Manages view navigation with lifecycle guarantees. + +**Structure**: + +```go +type Router struct { + views map[string]View // Registered views + current View // Active view + history []string // Navigation history (max 10) + state *StateManager // For building ViewContext +} + +func (r *Router) Register(name string, view View) +func (r *Router) Navigate(name string, args map[string]any) (View, tea.Cmd) +func (r *Router) Back() (View, tea.Cmd) +func (r *Router) Current() View +``` + +**Navigation Flow**: + +1. Navigate(name, args) called +2. Current view OnExit() +3. New view OnEnter(ViewContext) +4. New view becomes current +5. History updated + +**Guarantees**: + +- OnEnter ALWAYS called before first View() render +- OnExit called when navigating away +- History preserved (max 10 levels) + +### 2.5 Shell (Root Model) + +**Purpose**: Root Bubble Tea model, renders frame (header + navigation + content + controlbar). + +**Structure**: + +```go +type Shell struct { + router *Router + state *StateManager + mode LaunchMode + width int + height int + quitting bool +} + +func (s Shell) Init() tea.Cmd +func (s Shell) Update(msg tea.Msg) (tea.Model, tea.Cmd) +func (s Shell) View() string +``` + +**Render Layout**: + +``` ++------------------------------------------------------------------+ +| Header (3 lines) | ++------------------------------------------------------------------+ +| Navigation (1 line, or sidebar 20 cols if skin=sidebar) | ++------------------------------------------------------------------+ +| | +| Content Area (router.Current().View()) | +| | ++------------------------------------------------------------------+ +| ControlBar (1 line) | ++------------------------------------------------------------------+ +``` + +**Launch Modes**: + +```go +const ( + DashboardMode LaunchMode = iota // Show all tabs, navigation visible + FocusedMode // Single view, no tabs + JSONMode // No TUI, JSON output only +) +``` + +--- + +## 3. Error Handling Entities + +### 3.1 Result (Shell Command Output) + +**Purpose**: Structured output from shell command execution. + +**Structure**: + +```go +type Result struct { + Command string // Command executed + Stdout string // Standard output + Stderr string // Standard error + ExitCode int // Exit code (0 = success) + Duration time.Duration // Execution duration + Err error // Go error (if any) +} + +func (r Result) Success() bool { + return r.ExitCode == 0 && r.Err == nil +} +``` + +**Usage**: + +```go +executor := shell.NewExecutor() +result := executor.Run(ctx, "docker", "ps") +if !result.Success() { + return ErrorMsg{ + Context: "Docker service check", + Err: fmt.Errorf("exit code %d: %s", result.ExitCode, result.Stderr), + } +} +``` + +### 3.2 ErrorMsg (Bubble Tea Message) + +**Purpose**: Async error message for Shell to handle. + +**Structure**: + +```go +type ErrorMsg struct { + Context string // What was being done (e.g., "Starting workspace") + Err error // The error that occurred + Severity ErrorSeverity // Error | Warning | Info + Timestamp time.Time // When it occurred + Dismissible bool // Can user dismiss with 'd' key? + Details string // Additional details (stderr, stack trace) +} + +type ErrorSeverity int + +const ( + ErrorSeverityInfo ErrorSeverity = iota + ErrorSeverityWarning + ErrorSeverityError +) +``` + +**Lifecycle**: + +1. View encounters error +2. View returns `func() tea.Msg { return ErrorMsg{...} }` +3. Shell.Update() receives ErrorMsg +4. Shell renders error with component.ErrorDisplay() +5. User dismisses with 'd' key + +### 3.3 StateChangedMsg (Bubble Tea Message) + +**Purpose**: Notify Shell that profile/theme/skin changed. + +**Structure**: + +```go +type StateChangedMsg struct { + ProfileID string + ThemeID string + SkinID string +} +``` + +**Lifecycle**: + +1. User changes profile in Config view +2. View calls StateManager.ChangeProfile() +3. View returns `func() tea.Msg { return StateChangedMsg{...} }` +4. Shell.Update() receives StateChangedMsg +5. Shell rebuilds ViewContext for current view +6. Shell calls router.Current().OnEnter(newCtx) +7. UI updates with new theme/skin + +--- + +## 4. Golden Test Matrix + +### 4.1 Test Strategy + +**Full Matrix**: 10 themes × 10 profiles × 2 skins = 200 combinations + +**Sampled Approach** (recommended): + +- **Theme tests**: 10 themes × 2 skins = 20 tests (T045) +- **Component tests**: 4 components × 10 themes × 1 skin = 40 tests (T046) +- **Total**: 60 golden file tests + +### 4.2 Test Coverage + +| Test Type | Combinations | Rationale | +| ---------------- | ------------------------ | ----------------------------------------- | +| Theme system | 10 themes × 2 skins = 20 | Verify all themes load, both skins render | +| Header component | 10 themes × 1 skin = 10 | Verify colored text | +| Hero component | 10 themes × 1 skin = 10 | Verify logo + branding | +| Table component | 10 themes × 1 skin = 10 | Verify data table styling | +| Card component | 10 themes × 1 skin = 10 | Verify bordered cards | + +**Total: 60 golden file tests** (manageable CI time, good coverage) + +### 4.3 Test Files + +``` +tests/visual/golden/ +├── themes/ +│ ├── cyberstart-gh-dash.golden +│ ├── cyberstart-minimal.golden +│ ├── devops-pro-gh-dash.golden +│ ├── devops-pro-minimal.golden +│ └── ... (20 files) +├── components/ +│ ├── header-cyberstart.golden +│ ├── header-devops-pro.golden +│ ├── hero-cyberstart.golden +│ └── ... (40 files) +``` + +--- + +## 5. JSON Mode Coverage + +### 5.1 Commands with JSON Support + +| Command | JSON Flag | Output | Task | +| -------------------------- | --------- | ---------------------------- | ---- | +| `arc services list` | `--json` | Array of service definitions | T053 | +| `arc services info ` | `--json` | Single service details | T053 | +| `arc workspace info` | `--json` | Workspace metadata | T061 | +| `arc workspace list` | `--json` | Array of workspaces | T061 | +| `arc version` | `--json` | Version info + system info | T061 | + +**Total: 100% coverage of data-retrieval commands** + +### 5.2 Commands WITHOUT JSON Support (Interactive Only) + +| Command | Reason | Alternative | +| ------------------- | --------------------- | ------------------------------------------- | +| `arc` (dashboard) | Interactive TUI only | N/A | +| `arc init` | Wizard requires input | Use `arc init --non-interactive` with flags | +| `arc workspace run` | Progress display | Output logs to file, retrieve via API | +| `arc config` | Interactive settings | Edit ~/.arc/arc.yaml directly | + +--- + +## 6. Entity Relationships Diagram + +``` +┌──────────────┐ +│ StateManager │ +└──────┬───────┘ + │ owns + ├─────► Theme (10) + ├─────► Profile (10) ─references─► Theme + ├─────► Skin (2) + │ + │ builds + ▼ +┌──────────────┐ +│ ViewContext │ +└──────┬───────┘ + │ passed to + ▼ +┌──────────────┐ ┌──────────────┐ +│ Router │◄─────►│ View │ (interface) +└──────┬───────┘ owns └──────┬───────┘ + │ │ implements + │ manages │ + ▼ ▼ +┌──────────────┐ ┌──────────────┐ +│ Shell │ │ home, services│ +│ (root model) │ │ workspace, etc│ +└──────────────┘ └──────────────┘ +``` + +--- + +## Summary + +**Total Entities**: 14 + +- Theme System: 4 entities (Theme, Profile, Skin, Context) +- Engine: 5 entities (View, ViewContext, StateManager, Router, Shell) +- Error Handling: 3 entities (Result, ErrorMsg, StateChangedMsg) +- Testing: 1 entity (Golden Test Matrix) +- JSON Mode: 1 entity (Coverage Matrix) + +**Validation**: All entities have validation rules, lifecycle states, and relationship definitions. + +**Next Steps**: See `contracts/` directory for Go interface definitions. diff --git a/specs/018-ui-design/home-rewrite-research.md b/specs/018-ui-design/home-rewrite-research.md new file mode 100644 index 0000000..91dea65 --- /dev/null +++ b/specs/018-ui-design/home-rewrite-research.md @@ -0,0 +1,296 @@ +# Research: Home View Redesign + +**Feature**: 018-ui-design / home-rewrite +**Date**: 2026-03-04 +**Status**: Approved — ready for implementation +**Branch**: current working branch + +--- + +## 1. Problem Statement + +The current `Home` view (`pkg/ui/view/home.go`) is a static card layout with no +interactivity inside the content area. It shows a Hero block (logo + profile name + +- tier badge) and two cards (Quick Actions, System Info). It does not reflect the + gh-dash–style interactive dashboard pattern that the rest of the engine is built + around. + +Specific problems: + +- Hero shows profile name + tier badge — this is theme/skin metadata, not + meaningful to a user on first launch. +- Quick Actions is a static hint list — no cursor, no feedback. +- System Info is incomplete and mixed with profile data. +- No live interactivity: cursor navigation is missing entirely. + +--- + +## 2. Reference: gh-dash Layout Pattern + +gh-dash (https://github.com/dlvhdr/gh-dash) uses a strict 3-column layout inside +its container area (between header tabs and footer keybindings): + +``` +LEFT sidebar │ CENTER list │ RIGHT detail pane +────────────────┼─────────────────────────┼────────────────────── +Repo/PR filters │ Scrollable item list │ Live detail of +(static) │ ▸ highlighted row │ highlighted item + │ · other rows (muted) │ updates as cursor moves +``` + +Key behavioural properties: + +1. Cursor moves in CENTER column only (↑/↓ / j/k). +2. RIGHT column re-renders entirely on each cursor move — no animation needed. +3. LEFT column is fully static (no interaction). +4. Columns are separated by a single `│` character at full content height + (achieved with lipgloss `BorderRight(true)` on a zero-width container). + +We adopt the same pattern. + +--- + +## 3. Component Archaeology + +### 3.1 `component.Hero` — current state + +```go +func Hero(ctx *theme.Context, tierLevel int) string +``` + +Renders: rounded-border box → (logo art, profile name, tier badge). +**Problem**: profile name and tier badge are theme metadata. On the Home view we +want the logo art as an identity anchor, not profile selector info. + +**Decision**: Rewrite `Hero` to render only: + +- Logo art from `ctx.Profile().Logo` (primary color) +- Tagline from `branding.Tagline` (muted color) +- Rounded border (existing style) + +Remove `profile name` and `TierBadge`. The `tierLevel int` param is dropped. +New signature: + +```go +func Hero(ctx *theme.Context) string +``` + +The Hero block becomes the pure brand identity block — art + tagline — which is +what the current home view intends but pollutes with metadata. + +### 3.2 `component.Card` + +```go +func Card(ctx *theme.Context, title, content string) string +``` + +Used as-is for the System card. Border style adapts from skin. Padding adapts +from density. No changes needed. + +### 3.3 `component.Badge` + +```go +func Badge(ctx *theme.Context, text string, kind BadgeType) string +``` + +Used inside the System card body to render the version string as a `BadgeInfo` +pill — provides immediate visual accent without being noisy. + +### 3.4 `component.Panel` + +Not used in the new home view — `lipgloss.JoinVertical` / `JoinHorizontal` are +used directly to guarantee the fixed column widths needed for the 3-column grid. + +### 3.5 Column separator — gh-dash technique + +```go +sep := lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderRight(true). + BorderForeground(lipgloss.Color(borderColor)). + Height(contentH). + Render("") +``` + +This produces a single `│` spanning the full content height. Two of these are +inserted between the three columns via `JoinHorizontal(Top, left, sep, center, +sep, right)`. + +--- + +## 4. Data Sources + +### 4.1 Logo art + +`ctx.Profile().Logo` — always available if `ctx != nil && ctx.Profile() != nil`. +Falls back to `branding.Name` plain text (same as `component.Logo` fallback). + +### 4.2 Tagline + +`branding.Tagline` — compile-time constant `"Agentic Reasoning Core"`. + +### 4.3 Version / commit / build date + +`version.Version`, `version.Commit`, `version.BuildDate` — build-time ldflags, +available as package-level vars. No I/O. Display as: + +- version: `Badge(ctx, "v"+version.Version, BadgeInfo)` +- commit: first 7 chars of `version.Commit` (or `"dev"`) +- built: first 10 chars of `version.BuildDate` (date part, or `"—"`) + +### 4.4 Platform / runtime + +`runtime.GOOS`, `runtime.GOARCH`, `runtime.Version()`, `runtime.NumCPU()` — +no I/O, always available. + +### 4.5 CPU model + hostname + +`branding.CollectSystemInfo()` — performs lightweight OS reads + optional one +`git` command. Called **once** in `OnEnter`, cached in `Home.sysInfo`. If it +errors, gracefully degraded (display `"—"` for unavailable fields). +Fields used: `CPUModel`, `Hostname`. + +### 4.6 Command metadata + +Defined as a **static slice** `homeCmds()` in `home.go`. Mirrors the visible +commands registered in `root.go`. No cobra import in the view package. + +```go +type homeCmd struct { + name string // "workspace" + short string // one-liner for center list + long string // paragraph for right detail pane + subs []homeSub // { name, short } subcommand rows + example string // raw multiline example block + navTo string // engine view name on enter ("" = no nav) +} +type homeSub struct{ name, short string } +``` + +--- + +## 5. Layout Specification + +### Column widths + +``` +leftW = 26 (fixed) +centerW = 28 (fixed) +sepW = 1 (single │) +rightW = ctx.Width - leftW - centerW - 2 (min 30) +h = ctx.Height (full content area) +``` + +### LEFT column (static, full height) + +``` +╭──────────────────────────╮ +│ ▀▌▛▘▛▘ │ ← logo art (primary color) +│ █▌▌ ▙▖ │ +│ │ +│ Agentic Reasoning Core │ ← branding.Tagline (muted) +╰──────────────────────────╯ ← component.Hero(ctx) — rounded border + +╭─ System ─────────────────╮ +│ [INFO] v0.1.0 │ ← Badge(BadgeInfo) +│ │ +│ commit abc1234 │ +│ built 2026-03-04 │ +│ platform darwin/arm64 │ +│ runtime go1.24.2 │ +│ cores 8 │ +│ cpu Apple M2 Pro │ +│ host workstation │ +╰──────────────────────────╯ ← component.Card(ctx, "System", ...) +``` + +### CENTER column (interactive) + +``` +╭─ Commands ───────────────╮ +│ │ +│ ▸ workspace │ ← cursor row: primary bold + ▸ glyph +│ · services │ ← others: muted · glyph +│ · config │ +│ · init │ +│ · version │ +│ │ +╰──────────────────────────╯ ← component.Card(ctx, "Commands", ...) +``` + +Cursor wraps top↔bottom. + +### RIGHT column (reactive, no border) + +``` +workspace +───────────────────────────────────── +Manage A.R.C. workspaces and +orchestrate containerized development +environments. + +Subcommands + init Initialize a new workspace + run Generate configs and launch + info Display workspace state + history Show operation history + +Examples + arc workspace init . + arc workspace run --detached + arc workspace info +``` + +Styled with: + +- title: bold foreground +- divider: muted `─` chars at `rightW` +- long desc: foreground, word-wrapped to `rightW` +- "Subcommands" / "Examples" labels: primary bold +- subcommand name col: fixed 12 chars, foreground bold +- subcommand desc: muted +- example lines: muted, 2-space indent + +--- + +## 6. Key Bindings + +| Key | Action | +| --------- | ------------------------------------- | +| `↑` / `k` | cursor up (wraps) | +| `↓` / `j` | cursor down (wraps) | +| `enter` | navigate to `cmd.navTo` view (if set) | + +These are added to `Home.Keybindings()` → appear in the shell `ControlBar`. + +--- + +## 7. State + +```go +type Home struct { + ctx engine.ViewContext + cmds []homeCmd + cursor int + sysInfo *branding.SystemInfo // nil if CollectSystemInfo failed + ready bool +} +``` + +`cursor` is clamped to `[0, len(cmds)-1]` on every move. No bubbles list model +is used — raw integer cursor is sufficient and matches gh-dash's internal pattern +for static lists. + +--- + +## 8. Decisions Log + +| Decision | Rationale | +| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| Rewrite `Hero` signature (drop tierLevel) | Tier metadata belongs in Config/Profile views, not on the home brand block | +| Static `homeCmds()` slice, no cobra import | Views must not depend on CLI layer; static data is simpler and easier to maintain | +| `CollectSystemInfo()` called once in `OnEnter` | Right balance — not on every render, not at startup | +| Raw cursor int, not `bubbles/list` | Static list of 5 items needs no filtering/pagination overhead | +| Right pane has no border | Breathing room; the two `│` separators already frame it visually | +| LEFT width 26, CENTER width 28 | Hero block art is ~8 chars wide; 26 gives comfortable padding. Center needs room for `▸ workspace` (12 chars) + card border | diff --git a/specs/018-ui-design/home-rewrite-tasks.md b/specs/018-ui-design/home-rewrite-tasks.md new file mode 100644 index 0000000..a9fa30c --- /dev/null +++ b/specs/018-ui-design/home-rewrite-tasks.md @@ -0,0 +1,318 @@ +# Tasks: Home View Redesign + +**Research**: [home-rewrite-research.md](./home-rewrite-research.md) +**Date**: 2026-03-04 +**Status**: Ready to implement +**Total Tasks**: 8 + +--- + +## Prerequisites + +- [x] `make prepare` passing clean (0 lint issues) +- [x] `go test ./...` passing clean +- [x] Research doc approved (`home-rewrite-research.md`) + +--- + +## Task List + +### T1 — Rewrite `component.Hero` + +**File**: `pkg/ui/component/hero.go` +**Type**: Breaking change (signature change) + +**What**: + +- Drop `tierLevel int` parameter — new signature: `Hero(ctx *theme.Context) string` +- Remove profile name (`p.Name`) render +- Remove `TierBadge(ctx, tierLevel)` render +- Keep: logo art from `ctx.Profile().Logo` (primary color, `Padding(1,2)`) +- Add: `branding.Tagline` below art (muted color) +- Keep: rounded border container (`lipgloss.RoundedBorder()`, primary `BorderForeground`) +- Fallback when `ctx == nil` or profile has no logo: plain `branding.Name` text + +**Callers to update** (only one): + +- `pkg/ui/view/home.go:47` → `component.Hero(tc, 0)` → `component.Hero(tc)` + +**Acceptance**: `go build ./...` clean. + +--- + +### T2 — Rewrite `pkg/ui/view/home.go` + +**File**: `pkg/ui/view/home.go` +**Type**: Full rewrite + +#### T2a — Data model + +Add to package-private types at top of file: + +```go +type homeCmd struct { + name string + short string + long string + subs []homeSub + example string + navTo string // engine view name; "" = no navigation +} +type homeSub struct{ name, short string } +``` + +Add `homeCmds() []homeCmd` returning the static command list: + +- `workspace` → navTo `"Workspace"` (or relevant view name) +- `services` → navTo `"Services"` +- `config` → navTo `"Config"` +- `init` → navTo `""` (no TUI view) +- `version` → navTo `"Version"` + +#### T2b — Struct + +```go +type Home struct { + ctx engine.ViewContext + cmds []homeCmd + cursor int + sysInfo *branding.SystemInfo // nil on error + ready bool +} +``` + +#### T2c — `OnEnter` + +- Set `v.cmds = homeCmds()` +- Call `branding.CollectSystemInfo()`, store in `v.sysInfo` (ignore error, leave nil) +- Clamp cursor to `[0, len(v.cmds)-1]` +- Set `ready = true` + +#### T2d — `Update` (cursor + navigation) + +Handle `tea.KeyMsg`: + +- `"up"`, `"k"` → cursor-- with wrap +- `"down"`, `"j"` → cursor++ with wrap +- `"enter"` → if `v.cmds[v.cursor].navTo != ""` emit `engine.NavigateMsg` + +#### T2e — `View` (3-column render) + +Column widths: + +``` +leftW = 26 +centerW = 28 +rightW = ctx.Width - leftW - centerW - 2 (min 30) +h = ctx.Height +``` + +**LEFT column** (`leftW` chars): + +1. `component.Hero(tc)` — logo art + tagline, rounded border +2. System card (`component.Card`) with rows: + - `Badge(ctx, "v"+version.Version, BadgeInfo)` on its own line + - blank line + - `commit ` + - `built ` + - `platform /` + - `runtime ` + - `cores ` + - `cpu ` (only if sysInfo != nil && CPUModel != "") + - `host ` (only if sysInfo != nil && Hostname != "") + - All label strings right-padded to 8 chars (muted). Value strings foreground. + - Width constrained: `lipgloss.NewStyle().Width(leftW - 4)` + +Left block: `lipgloss.JoinVertical(Top, heroBlock, "\n", sysCard)` +Then: `lipgloss.NewStyle().Width(leftW).Height(h).Render(leftBlock)` + +**Separator**: + +```go +sep := lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderRight(true). + BorderForeground(lipgloss.Color(ctx.Theme().Colors.Border)). + Height(h). + Render("") +``` + +**CENTER column** — commands card: + +- Title: `"Commands"` +- Body: iterate `v.cmds`, each row: + - cursor row: `primary.Bold(true).Render("▸ " + cmd.name)` + - others: `muted.Render("· " + cmd.name)` +- Wrapped in `component.Card(tc, "Commands", body)` +- Width: `lipgloss.NewStyle().Width(centerW).Height(h).Render(card)` + +**RIGHT column** — detail pane (no border): + +1. Title: `bold foreground` → `cmd.name` +2. Divider: `strings.Repeat("─", rightW)` in muted +3. Blank line +4. Long description: word-wrapped to `rightW`, foreground +5. If `len(cmd.subs) > 0`: + - `"Subcommands"` label in primary bold + - each sub: `nameCol(12 chars) + muted desc` +6. If `cmd.example != ""`: + - `"Examples"` label in primary bold + - example lines with 2-space indent, muted + +- Full block: `lipgloss.NewStyle().Width(rightW).Height(h).Render(...)` + +**Final join**: + +```go +return lipgloss.JoinHorizontal(lipgloss.Top, + leftBlock, sep, centerBlock, sep, rightBlock, +) +``` + +#### T2f — `Keybindings` + +```go +return []engine.KeyBinding{ + {Key: "↑/k", Desc: "up"}, + {Key: "↓/j", Desc: "down"}, + {Key: "enter", Desc: "open"}, +} +``` + +--- + +### T3 — Verify build + +```bash +go build ./... +``` + +Expected: clean. + +--- + +### T4 — Lint + +```bash +golangci-lint run ./pkg/ui/... +``` + +Expected: 0 issues. Fix any that arise (common: unused imports, shadow vars). + +--- + +### T5 — Run tests + +```bash +go test ./... +``` + +Expected: all pass. The only test touching `Hero` directly is `component/golden_test.go` — check if it needs a golden file update. + +--- + +### T6 — Update golden files (if needed) + +If `pkg/ui/component/golden_test.go` has a golden for `Hero`: + +```bash +go test ./pkg/ui/component/ -update +``` + +Or manually update the golden file to match the new Hero output (no profile name, +no tier badge, tagline present). + +--- + +### T7 — Visual smoke test + +```bash +go run ./cmd/arc +``` + +Verify in terminal: + +- [ ] Left: Hero block shows logo art + tagline (no profile name, no tier) +- [ ] Left: System card shows version badge, commit, build, platform, go, cores +- [ ] Center: Command list shows 5 rows, first row highlighted with `▸` +- [ ] Right: Detail pane shows name + description + subcommands + examples +- [ ] `↓` / `j` moves cursor down, right pane updates +- [ ] `↑` / `k` moves cursor up, wraps from top to bottom +- [ ] `enter` on `workspace` navigates to workspace view +- [ ] `enter` on `init` does nothing (no navTo) +- [ ] Columns separated by `│` spanning full content height +- [ ] No layout overflow at 80-col terminal +- [ ] No layout overflow at 200-col terminal + +--- + +### T8 — `make prepare` + +```bash +make prepare +``` + +Expected: format + lint + vet all clean. Commit. + +--- + +## Enhancements (added 2026-03-04) + +These four additions fit inside `home.go` only — no new components, no new files. +They stay within the 3-column layout plan: + +### E1 — Spring cursor bar (`harmonica`) + +The `▸` highlight in the CENTER column slides smoothly between rows instead of +jumping. Uses `harmonica.NewSpring(FPS(60), 0.85, 8.0)` with a `homeSpringTickMsg` +ticker that fires at 60 fps while the spring is unsettled. + +State additions: + +```go +spring harmonica.Spring +springPos float64 // visual (fractional) row position +springVel float64 +``` + +On `"up"/"k"` or `"down"/"j"`: update `cursor`, emit `homeSpringTickCmd()`. +On `homeSpringTickMsg`: call `spring.Update(&springPos, &springVel, float64(cursor))`. +Stop ticking when `|springPos−cursor| < 0.01 && |springVel| < 0.01`. +Render uses `int(math.Round(springPos))` as the visual highlighted row. + +### E2 — Async system info with spinner + +`CollectSystemInfo()` runs in a goroutine (`tea.Cmd`). While in-flight, the +System card body shows `spinner.View() + " loading…"`. When `homeSysInfoMsg` +arrives, card snaps to real data. Zero blocking on view enter. + +### E3 — Memory bar (`bubbles/progress`) + +In the System card, after the cores row (only when sysInfo.MemoryTotal > 0): + +``` +mem 1024/16384 MB +████████░░░░░░░░░░ +``` + +Uses `progress.New(WithWidth(leftW-6), WithGradient(secondary, primary))` stored +in `Home`. Rendered with `memBar.ViewAs(usedFraction)` (no animation cmd needed — +stateless view-time render). + +### E4 — Live clock tick + +`HomeClockTickMsg` fires every second via `tea.Tick(time.Second, ...)`. Updates +`v.now time.Time`. System card last row renders `"now " + now.Format("15:04:05")`. +Gives the view a visible heartbeat. + +--- + +## Implementation Order + +``` +T1 → T2a → T2b → T2c(+E2) → T2d(+E1,E4) → T2e(+E1,E2,E3,E4) → T2f → T3 → T4 → T5 → T6? → T7 → T8 +``` + +T1 must precede T2 because T2 calls `component.Hero(tc)` with new signature. +All T2 sub-tasks are sequential; implement top to bottom in the file. +Enhancements are woven into T2 sub-tasks (not separate tasks). diff --git a/specs/018-ui-design/plan.md b/specs/018-ui-design/plan.md new file mode 100644 index 0000000..87ef9c9 --- /dev/null +++ b/specs/018-ui-design/plan.md @@ -0,0 +1,864 @@ +# Implementation Plan: UI Design & Architecture Rebuild + +**Branch**: `018-ui-rewrite` | **Date**: 2026-03-03 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/018-ui-design/spec.md` + +**Note**: This plan is filled in by the `/speckit.plan` command. + +## Summary + +Complete rebuild of ARC CLI's UI architecture from scratch using React-inspired design patterns. Rebuilds the UI engine with a Shell + Router + Views pattern, consolidates 30+ components down to 17 focused components, implements a comprehensive error handling system for shell command execution, establishes a design system inspired by gh-dash with centralized theming (Design Tokens pattern), and creates a new Theme + Skin system that separates visual styling from layout rules. Uses git worktree for clean development, keeps all backend services unchanged, and delivers a production-ready UI that supports both dashboard mode (bare `arc`) and focused mode (`arc `) with JSON output for automation. + +## Technical Context + +**Language/Version**: Go 1.24.2 (existing in project) + +**Primary Dependencies** (from go.mod): + +- **TUI Framework**: + - `github.com/charmbracelet/bubbletea` v1.3.10 - Core TUI event loop + - `github.com/charmbracelet/bubbles` v1.0.0 - Component primitives (list, table, viewport, spinner, progress, textinput) + - `github.com/charmbracelet/huh` v0.8.0 - Form/wizard components +- **Styling & Rendering**: + - `github.com/charmbracelet/lipgloss` v1.1.1 - Layout and styling + - `github.com/charmbracelet/x/ansi` v0.11.6 - ANSI color support + - `github.com/charmbracelet/glamour` v0.10.0 - Markdown rendering + - `github.com/charmbracelet/colorprofile` v0.4.2 - Terminal color detection +- **Animation & Effects**: + - `github.com/charmbracelet/harmonica` v0.2.0 - Spring-based animations + - `github.com/charmbracelet/x/cellbuf` v0.0.15 - Buffer cell manipulation +- **Terminal Interaction**: + - `github.com/charmbracelet/x/term` v0.2.2 - Terminal detection & sizing + - `golang.org/x/term` v0.38.0 - Low-level terminal I/O + - `github.com/atotto/clipboard` v0.1.4 - Clipboard operations +- **Command Framework**: + - `github.com/spf13/cobra` v1.10.2 - CLI command structure +- **Data & Configuration**: + - `gopkg.in/yaml.v3` v3.0.1 - YAML parsing (themes, profiles, skins) + - `github.com/google/uuid` v1.6.0 - Unique identifiers + - `github.com/spf13/afero` v1.15.0 - Filesystem abstraction +- **Logging**: + - `github.com/charmbracelet/log` v0.4.2 - Structured logging + - `gopkg.in/natefinch/lumberjack.v2` v2.2.1 - Log rotation +- **Testing**: + - `github.com/stretchr/testify` v1.11.1 - Test assertions + - `github.com/google/go-cmp` v0.7.0 - Deep comparison for golden tests + +**Storage**: + +- `~/.arc/state.json` - Global preferences (profile_id, theme_id, skin_id) +- `~/.arc/themes/` - Embedded theme YAMLs (10 themes) +- `~/.arc/profiles/` - Embedded profile YAMLs (10 profiles) +- `~/.arc/skins/` - Embedded skin YAMLs (2 initial: gh-dash, minimal) + +**Testing**: + +- Go testing package with table-driven tests +- Golden file tests for component output (visual regression) +- Headless Bubble Tea tests for engine lifecycle (later phase) + +**Target Platform**: Cross-platform (Linux, macOS, Windows) via Go compilation + +**Performance Goals**: + +- <100ms startup time (cold start) +- <16ms tab/view navigation (60fps target) +- <20MB memory footprint (down from current) +- <50ms theme switch (live profile change) +- Instant JSON output (<10ms for --json mode) + +**Constraints**: + +- Must support --json output for data-retrieval commands (see JSON Mode Coverage Matrix below) +- Must work with all 10 existing profile themes + 2 skins (gh-dash, minimal) +- Must handle narrow terminals (minimum 80 columns) +- Backend services (catalog, workspace, store, scaffold, config, log) stay 100% unchanged + +**Scale/Scope**: + +- 17 components (down from 30+) +- 9 views (home, services_list, service_detail, workspace_info, workspace_history, workspace_run, config_overview, version, init_wizard) +- 10 theme YAMLs +- 10 profile YAMLs +- 2 skin YAMLs (gh-dash, minimal) +- 3 launch modes (dashboard, focused, JSON) +- 6 implementation phases over 7 weeks (~140 hours) +- 76 tasks total +- 60 golden file tests (10 themes × 2 skins + 4 components × 10 themes) + +## Constitution Check + +_GATE: Must pass before Phase 0 research. Re-check after Phase 1 design._ + +Verify compliance with A.R.C. CLI Constitution principles (v1.1.0): + +- [x] **Zero-Dependency**: ✅ No new runtime dependencies. All Charmbracelet libraries already in go.mod. Consolidates usage of existing libraries. Removes redundant implementations. +- [x] **Local-First**: ✅ UI redesign is purely visual layer. No network requirements. All themes/profiles/skins embedded. Works fully offline. +- [x] **Two-Brain Separation**: ✅ **ENHANCED**. Clean interface boundary: Engine provides ViewContext with backend services. Views are pure rendering. No business logic in UI code. +- [x] **Platform-in-a-Box**: ✅ Maintains seamless experience. New huh-based wizards for init and workspace creation. Improved error display with context and actionable suggestions. +- [x] **Intelligent Orchestration**: N/A - UI feature doesn't affect service orchestration logic +- [x] **Deep Observability**: ✅ **ENHANCED**. Error component shows shell command failures with context, stderr, exit codes. Status badges with themed icons. Dependency tree visualization. +- [x] **Resilience Testing**: N/A - UI feature doesn't affect chaos testing capabilities +- [x] **Interactive Experience**: ✅ **CORE FEATURE**. Complete TUI rebuild. Two modes (dashboard/focused). Keyboard-driven navigation. Live theme switching. Fuzzy search. Sortable tables. +- [x] **Declarative Reconciliation**: N/A - UI feature doesn't affect arc.yaml reconciliation +- [x] **Security by Default**: ✅ Shell executor timeouts (30s default), context cancellation. No credential display in error messages. Safe subprocess handling. +- [x] **Stateful Operations**: ✅ StateManager tracks profile/theme/skin. Router maintains navigation history. Views preserve state across OnExit/OnEnter. Workspace scope interface ready. +- [x] **High-Performance I/O**: ✅ Performance targets embedded (<100ms startup, <16ms navigation, <20MB memory). Style registry caches lipgloss.Style objects. Component reuse via factory. + +**Violations requiring justification**: None. Feature is fully compliant with constitution. + +| Principle Violated | Justification | Mitigation | +| ------------------ | ------------- | ---------- | +| None | N/A | N/A | + +## Architectural Patterns Compliance + +_GATE: Must pass for specs 006+. Specs 001-005 are grandfathered._ + +Verify compliance with Arc CLI Architectural Patterns (v1.0.0): +Reference: `.specify/memory/patterns.md` + +**Note**: Spec 018 MUST comply with all patterns. + +### 1. Factory Pattern (Dependency Injection) + +- [x] **No Global State**: Zero package-level vars for UI components. All components created via theme.Context passed as first parameter. +- [x] **Context Injection**: All views accept ViewContext in OnEnter(). ViewContext includes theme, profile, skin, backend services, dimensions. +- [x] **Explicit Dependencies**: StateManager initialized once in bootstrap.go. Engine receives state, builds ViewContext. No init() side effects. + +### 2. XDG Base Directory Specification + +- [x] **Config Location**: Respects existing `~/.arc/arc.yaml` (unchanged). +- [x] **Data Location**: Respects existing `~/.arc/services/` (unchanged). +- [x] **State Location**: Uses existing `~/.arc/state.json` with new fields (skin_id). +- [x] **XDG Functions**: Uses existing `internal/xdg/` package. No direct path construction. + +### 3. Repository Pattern (Domain-Driven Storage) + +- [x] **Interface Per Domain**: Views receive Catalog, Workspace, Store interfaces via ViewContext. No direct access. +- [x] **Interface Location**: Existing interfaces in `pkg/catalog/`, `pkg/workspace/`, `pkg/store/` (unchanged). +- [x] **Implementation Location**: Backend implementations unchanged. +- [x] **No Direct File Access**: Views never touch files. Shell executor is only subprocess interface. Theme loader uses afero.Fs abstraction. + +### 4. Middleware/UI Service Pattern + +- [x] **UI Service**: ✅ **CORE PATTERN**. Engine.Start() is central UI service. Checks --json flag, routes to TUI or JSON renderer. +- [x] **No Flag Checks**: Commands call engine.Start(mode, context). Engine checks flags internally. Views never see cobra.Command. +- [x] **Separation of Concerns**: Business logic in backend. Views render only. Shell executor wraps subprocess execution. + +### 5. Configuration Management (12-Factor App) + +- [x] **Environment Support**: + - `ARC_DASHBOARD_COLUMNS` - Column count override (optional) + - Respects existing env vars (ARC_CONFIG_DIR, ARC_LOG_LEVEL, etc.) +- [x] **Precedence Chain**: Flags → Environment → Config → Defaults (unchanged). +- [x] **Unified Config**: Uses existing `internal/config/` patterns. No manual env parsing. + +### 6. Testing Standards & Golden Test Matrix + +- [x] **Table-Driven Tests**: All component tests use table-driven pattern (multiple themes, profiles, skins, widths, error states). +- [x] **Parallel Execution**: `t.Parallel()` on all safe tests. Golden file tests sequential for consistency. +- [x] **Coverage Target**: + - Backend: 80% (unchanged requirement) + - Theme system: 75% (must test profile/theme/skin loading) + - Components: 60% (golden files for key components: table, card, hero, error) + - Engine: 70% (lifecycle, router, state manager) + - Views: 50% (later phase - headless Bubble Tea) +- [x] **Golden Test Strategy** (60 total tests): + - Theme tests: 10 themes × 2 skins = 20 tests (T045) + - Component tests: 4 components × 10 themes × 1 skin = 40 tests (T046) + - See `/specs/018-ui-design/data-model.md` section 4 for full matrix + +**Pattern Exceptions**: None + +### 7. Linting Strategy & Path Exclusions + +- [x] **Linter Configuration**: Keep all 30 linters from `.golangci.yml` +- [x] **Path-Based Exclusions** for UI layer (add in T070): + ```yaml + issues: + exclude-rules: + # UI components are render functions (cognitive complexity higher) + - path: pkg/ui/component/ + linters: [gocognit, cyclop, gocyclo] + # Views have complex Update() switch statements + - path: pkg/ui/view/ + linters: [gocognit, exhaustive] + # Theme loading uses reflection for YAML + - path: pkg/ui/theme/loader.go + linters: [exhaustive, exhaustruct] + ``` +- [x] **Zero //nolint Target**: No inline suppression in new code +- [x] **Validation**: Run `make lint` after each phase (T070, T071) + +| Pattern | Exception Reason | Mitigation | +| ------- | ---------------- | ---------- | +| None | N/A | N/A | + +### JSON Mode Coverage Matrix + +**100% coverage** of data-retrieval commands (5 commands with --json support): + +| Command | JSON Flag | Output | Implementation | Task | +| -------------------------- | --------- | -------------------------------- | ------------------------------ | ---- | +| `arc services list` | `--json` | Array of service definitions | Marshal catalog.List() | T053 | +| `arc services info ` | `--json` | Single service with dependencies | Marshal catalog.Get(name) | T053 | +| `arc workspace info` | `--json` | Workspace metadata | Marshal workspace.GetCurrent() | T061 | +| `arc workspace list` | `--json` | Array of workspaces | Marshal workspace.List() | T061 | +| `arc version` | `--json` | Version + system info | Marshal version.GetInfo() | T061 | + +**Commands WITHOUT --json** (interactive only): + +| Command | Reason | Alternative | +|---------|--------||-------------|| +| `arc` (dashboard) | Interactive TUI only | Use specific commands with --json | +| `arc init` | Wizard requires user input | Use `--non-interactive` with flags | +| `arc workspace run` | Progress display | Output logs to file | +| `arc config` | Interactive settings | Edit ~/.arc/arc.yaml directly | + +**Reference Implementations**: + +- Factory Pattern: `pkg/ui/theme/context.go` - theme.Context as dependency injected to all components +- Repository Pattern: ViewContext bridges backend interfaces to UI +- Middleware Pattern: `pkg/ui/engine/launch.go` - unified entry point +- Testing: Golden files follow Charm ecosystem patterns (lipgloss, bubbles, glamour) + +**Learn More**: `specs/005-animations-rich-ui/INDUSTRY_PATTERNS.md` + +## Project Structure + +### Documentation (this feature) + +```text +specs/018-ui-design/ +├── spec.md # Feature specification (UI Design & Implementation Plan) +├── plan.md # This file (Full implementation plan) +├── research.md # Phase 0 output (gh-dash analysis, design tokens research, error handling patterns) +├── data-model.md # Phase 1 output (Theme, Profile, Skin, View, ErrorMsg entities) +├── quickstart.md # Phase 1 output (Getting started with new UI architecture) +├── contracts/ # Phase 1 output (Interfaces and signatures) +│ ├── view_interface.go # View lifecycle (Init, Update, View, OnEnter, OnExit) +│ ├── theme_context.go # theme.Context structure +│ ├── state_manager.go # StateManager interface +│ ├── shell_executor.go # Shell command wrapper interface +│ └── error_component_api.go # ErrorDisplay, InlineError signatures +└── tasks.md # Phase 2 output (76 tasks with dependencies) +``` + +### Source Code (repository root) + +**NEW CODE** (all in git worktree `018-ui-rewrite`): + +```text +pkg/ui/ # REBUILT FROM SCRATCH +├── theme/ # NEW: Unified theme system +│ ├── context.go # theme.Context - single source of truth +│ ├── theme.go # Theme struct (ID, Name, Colors) +│ ├── profile.go # Profile struct (ID, Name, TierNames, Logo, ThemeID) +│ ├── skin.go # Skin struct (ID, Name, Layout, Navigation, Borders, Density) +│ ├── loader.go # YAML loader for themes/profiles/skins +│ ├── registry.go # Style cache (lipgloss.Style memoization) +│ └── embedded/ +│ ├── themes/ # 10 theme YAMLs (cyberstart.yaml, devops-pro.yaml, etc.) +│ ├── profiles/ # 10 profile YAMLs (cyberstart.yaml, devops-pro.yaml, etc.) +│ └── skins/ # 2+ skin YAMLs (gh-dash.yaml, minimal.yaml) +│ +├── component/ # NEW: 17 consolidated components +│ ├── header.go # Top bar (brand + workspace + profile) +│ ├── navigation.go # Tab bar or sidebar (skin-dependent) +│ ├── controlbar.go # Bottom bar (keybindings + context) +│ ├── table.go # Data table (wraps bubbles/table) +│ ├── card.go # Bordered card +│ ├── panel.go # Content panel +│ ├── hero.go # ASCII logo + profile branding +│ ├── badge.go # Status badges +│ ├── tree.go # Dependency tree +│ ├── error.go # NEW: Error display (ErrorDisplay, InlineError) +│ ├── list.go # Interactive list (wraps bubbles/list) +│ ├── search.go # Search input (wraps bubbles/textinput) +│ ├── spinner.go # Loading indicator (wraps bubbles/spinner) +│ ├── progress.go # Progress bar (wraps bubbles/progress) +│ ├── form.go # Wizard forms (wraps huh) +│ ├── viewport.go # Scrollable area (wraps bubbles/viewport) +│ └── markdown.go # Markdown rendering (wraps glamour) +│ +├── shell/ # NEW: Shell command execution +│ └── executor.go # Command wrapper (timeout, stderr capture, exit codes) +│ +├── engine/ # NEW: React-inspired engine +│ ├── shell.go # Shell model (root Bubble Tea model) +│ ├── router.go # Router (navigation + OnEnter guarantee) +│ ├── state.go # StateManager (preferences + theme resolution) +│ ├── view.go # View interface definition +│ ├── context.go # ViewContext (props for views) +│ ├── launch.go # Start() entry point (mode selection) +│ ├── messages.go # NEW: ErrorMsg, NavigateMsg, StateChangedMsg +│ └── keys.go # Global keybindings (q, tab, shift+tab) +│ +└── view/ # NEW: 9 view implementations + ├── home.go # Home dashboard (hero + quick actions + system info) + ├── services_list.go # Service catalog (table + search + error handling) + ├── service_detail.go # Service detail (card + dependency tree) + ├── workspace_info.go # Workspace info + ├── workspace_history.go # Workspace history + ├── workspace_run.go # Workspace runner (progress + error capture) + ├── config_overview.go # Config (profile/theme/skin pickers) + ├── version.go # Version info + └── init_wizard.go # Init wizard (huh form) +``` + +**UNCHANGED CODE** (backend stays intact): + +```text +pkg/ +├── catalog/ # UNCHANGED - Service catalog +├── workspace/ # UNCHANGED - Workspace management +├── store/ # UNCHANGED - Config store +├── scaffold/ # UNCHANGED - Templates +├── log/ # UNCHANGED - Logging +└── version/ # UNCHANGED - Version API + +internal/ +├── app/ +│ ├── context.go # SLIMMED - Config + State + Logger only +│ └── bootstrap.go # NEW - Replaces init() side effects +├── config/ # UNCHANGED +├── preferences/ # MINOR - Add skin_id field to state.json +├── terminal/ # UNCHANGED +├── xdg/ # UNCHANGED +└── version/ # UNCHANGED +``` + +**DELETED CODE** (Phase 6, Task T069): + +```text +pkg/ui/components/ # OLD - Delete entire tree +pkg/ui/views/ # OLD - Delete entire tree +pkg/ui/factory.go # OLD - 622-line factory (replaced by theme.Context) +pkg/ui/service.go # OLD - Old UI service +pkg/ui/styles/ # OLD - Global mutable colors +pkg/ui/animations/ # OLD - Animation framework +pkg/ui/layouts/ # OLD - Empty directory +pkg/ui/markdown/ # OLD - Folded into component/ +pkg/ui/profiles/ # OLD - Folded into theme/ +pkg/ui/themes/ # OLD - Folded into theme/ +pkg/cli/dashboard/ # OLD - Old dashboard +pkg/cli/middleware/ # OLD - ErrorBoundary +pkg/cli/errors/ # OLD - ArcError +pkg/cli/banner.go # OLD - Old banner +pkg/cli/init_profile_ui.go # OLD - Old init UI +internal/branding/ # OLD - Folded into theme/profile +``` + +## Pre-Implementation Prerequisites + +**Note**: These artifacts are created iteratively during implementation, not as blocking prerequisites. + +### Optional Phase 0: Research & Discovery + +**Goal**: Resolve all unknowns from Technical Context. Research best practices for Design Tokens, error handling patterns, and component composition. + +**Duration**: 1-2 days (optional - can proceed with implementation first) + +**Research Tasks**: + +1. **Design Tokens Research** + - Analyze gh-dash color system + - Research CSS-in-JS patterns (styled-components, emotion) + - Document Design Token structure for CLI + - Define default token set (colors, spacing, typography, icons) + +2. **Error Handling Patterns** + - Research Go subprocess error handling best practices + - Analyze context cancellation patterns + - Document shell executor design (timeouts, stderr capture) + - Define ErrorMsg structure for Bubble Tea + +3. **Component Composition** + - Analyze lipgloss composition patterns + - Research component reuse in bubbles/huh + - Document stateless vs. stateful component split + - Define component signature patterns (theme.Context first param) + +4. **Theme System Architecture** + - Research YAML-based theming in CLI tools + - Analyze profile + theme + skin separation + - Document loader architecture (embed.FS usage) + - Define cache strategy for lipgloss.Style objects + +5. **Router + View Lifecycle** + - Research Bubble Tea lifecycle (Init, Update, View) + - Analyze OnEnter/OnExit patterns + - Document navigation patterns (tabs, focus mode) + - Define ViewContext structure + +**Deliverable**: `research.md` with decisions, rationale, alternatives considered. ✅ **Created** + +**See**: `/specs/018-ui-design/research.md` + +**Success Criteria**: + +- All NEEDS CLARIFICATION items from Technical Context resolved +- Design Token structure defined with examples +- Shell executor interface documented +- Component composition patterns established +- Theme loading strategy documented + +## Phase 1: Design & Contracts + +**Goal**: Generate data model, API contracts, and quickstart guide based on research findings. + +**Duration**: 3-5 days + +**Tasks**: + +1. **Data Model** (`data-model.md`): + - Theme entity (ID, Name, ColorSet) + - Profile entity (ID, Name, TierNames, Logo, ThemeID) + - Skin entity (ID, Name, Layout, Navigation, Borders, Density) + - View lifecycle states (not-started, initializing, active, exited) + - ErrorMsg structure (Context, Err, Severity, Timestamp, Dismissible) + - ViewContext structure (Theme, Profile, Skin, Width, Height, Args, Services) + +2. **API Contracts** (`contracts/`): + - `view_interface.go` - View interface (Init, Update, View, OnEnter, OnExit, Name, Keybindings) + - `theme_context.go` - theme.Context structure + methods + - `state_manager.go` - StateManager interface (BuildViewContext, ChangeProfile, ChangeTheme, ChangeSkin) + - `shell_executor.go` - Executor interface (Run method, Result struct) + - `error_component_api.go` - ErrorDisplay and InlineError signatures + +3. **Quickstart Guide** (`quickstart.md`): + - How to create a new view + - How to add a new component + - How to define a new theme + - How to create a skin + - How to test with golden files + - How to handle shell command errors + +4. **Update Agent Context**: + - Run `.specify/scripts/bash/update-agent-context.sh copilot` + - Add charmbracelet/huh to technologies + - Add shell executor pattern to context + - Add error handling philosophy to context + +**Deliverable**: `data-model.md`, `contracts/` directory, `quickstart.md`, updated CLAUDE.md. ✅ **Created** + +**See**: `/specs/018-ui-design/data-model.md`, `/specs/018-ui-design/quickstart.md`, `/specs/018-ui-design/contracts/` + +**Success Criteria**: + +- All entities documented with fields and relationships +- All interfaces defined with Go signatures +- Quickstart guide covers all common development tasks +- Agent context updated with new patterns + +## Phase 2: Theme + Component Foundation (Week 1-2) + +**Goal**: Build theme system, shell executor, and first batch of components. + +**Duration**: 2 weeks (~59.5 hours) + +**Tasks**: T001-T040 (40 tasks, see Task Breakdown section below) + +**Key Milestones**: + +- Week 1: Theme system complete (context, theme, profile, skin, loader, registry) +- Week 1: First components (header, controlbar, navigation, table, card) +- Week 1: Shell executor + error component +- Week 2: Remaining components (hero, badge, tree, list, search, spinner, progress, form, viewport, markdown) +- Week 2: Engine scaffolding (view interface, router, state manager, shell model) +- Week 2: Bootstrap.go replaces init() side effects + +**Deliverable**: Empty shell renders with themed header, tabs, controlbar. `arc` command starts, `q` quits. + +**Success Criteria**: + +- Theme loads from YAML (10 themes, 10 profiles, 2 skins) +- Components render with correct colors/spacing from theme +- Shell executor captures command output and errors +- Error component displays themed error boxes +- Navigation switches based on skin (tab-bar vs sidebar) +- Bootstrap.go initializes all dependencies explicitly (no init()) + +## Phase 3: Theme Switching (Week 3) + +**Goal**: Wire profile/theme/skin switching live in the UI. + +**Duration**: 1 week (~18 hours) + +**Tasks**: T041-T047 (7 tasks, see Task Breakdown section below) + +**Key Milestones**: + +- StateManager.ChangeProfile() implemented +- StateChangedMsg handled in Shell +- Skin rendering in navigation (sidebar vs tab-bar switch) +- Placeholder Config view for testing + +**Deliverable**: Profile switch triggers cascading UI update (colors, borders, layout, logo). + +**Success Criteria**: + +- Change profile in Config view → entire UI updates live +- Skin layout changes (tabs → sidebar) +- Theme colors update all components +- Profile logo displays in hero component +- Golden tests pass for all themes/profiles + +## Phase 4: Dashboard Views (Week 4) + +**Goal**: Implement first two tabs with real data and error handling. + +**Duration**: 1 week (~19 hours) + +**Tasks**: T048-T054 (7 tasks, see Task Breakdown section below) + +**Key Milestones**: + +- Home view (hero + quick actions + system info) +- Services list view (catalog table + search + error display) +- Service detail view (card + dependency tree) +- Router tab switching +- Focused mode: `arc services list` +- JSON mode: `arc services list --json` + +**Deliverable**: 2-tab dashboard with real catalog data. Services searchable. Errors shown gracefully. + +**Success Criteria**: + +- `arc` opens dashboard, Home tab active +- Tab key switches between Home and Services +- Services list shows catalog entries +- Search filters services in real-time +- Service detail shows dependencies as tree +- Shell command errors displayed with context +- `arc services list` opens focused view (no tabs) +- `arc services list --json` outputs JSON, no TUI +- `q` quits back to terminal + +## Phase 5: Complete Dashboard (Week 5) + +**Goal**: Wire remaining tabs (Workspace, Config) and focused modes. + +**Duration**: 1 week (~18 hours) + +**Tasks**: T055-T062 (8 tasks, see Task Breakdown section below) + +**Key Milestones**: + +- Workspace info view +- Workspace history view +- Config overview complete (profile/theme/skin pickers) +- Version view +- All 4 tabs routed +- Focused mode for workspace info, version + +**Deliverable**: Full 4-tab dashboard with all tabs functional. + +**Success Criteria**: + +- 4 tabs: Home | Services | Workspace | Config +- Config view allows live profile/theme/skin switching +- Workspace views show real workspace data +- Version view shows system info +- All views support --json mode +- Navigation history preserved (up/down arrows) + +## Phase 6: Wizards & Commands (Week 6) + +**Goal**: Implement interactive flows with error handling. + +**Duration**: 1 week (~11.5 hours) + +**Tasks**: T063-T068 (6 tasks, see Task Breakdown section below) + +**Key Milestones**: + +- Init wizard (huh form) +- Workspace run view (progress + error capture) +- Wire `arc init` +- Wire `arc workspace init`, `arc workspace run` +- Keep `arc completion` (Cobra-generated, no TUI) + +**Deliverable**: All commands work. Shell command errors captured and displayed with context. + +**Success Criteria**: + +- `arc init` opens wizard (huh multi-step form) +- Wizard validates inputs, shows errors inline +- `arc workspace run` shows progress bar +- Command failures display in error component +- Stderr captured and shown in details +- Exit codes displayed +- Dismissible errors (press 'd') +- All commands support --json fallback + +## Phase 7: Cleanup & Ship (Week 7) + +**Goal**: Delete old code, final polish, documentation, merge. + +**Duration**: 1 week (~14 hours) + +**Tasks**: T069-T076 (8 tasks, see Task Breakdown section below) + +**Key Milestones**: + +- Delete all old UI code (30+ files) +- Update .golangci.yml exclusions +- Lint pass (zero //nolint target for new code) +- Update README, CLAUDE.md, agent docs +- Performance validation (<100ms startup, <16ms nav, <20MB memory) +- Merge worktree to develop + +**Deliverable**: Production-ready UI. Old code deleted. Documentation updated. Passing CI/CD. + +**Success Criteria**: + +- Old pkg/ui/\* deleted (see DELETED CODE list above) +- All 30 linters pass (with smart exclusions for UI) +- Zero //nolint in new code +- README shows new screenshots +- CLAUDE.md updated with new patterns +- Performance targets met: + - Cold start: <100ms + - Tab switch: <16ms + - Memory: <20MB + - Theme switch: <50ms +- CI/CD green (all tests pass) +- Develop branch merged + +## Task Breakdown + +### Phase 1: Foundation (40 tasks, ~59.5h) + +| # | Task | Depends | Est | +| ---- | --------------------------------------------- | --------------- | ---- | +| T001 | Create worktree, scaffold folders | — | 1h | +| T002 | Delete old pkg/ui/ contents | T001 | 30m | +| T003 | theme/theme.go — Theme + ColorSet | T001 | 2h | +| T004 | theme/profile.go — Profile struct | T003 | 1h | +| T005 | theme/skin.go — Skin + enums | T003 | 2h | +| T006 | theme/context.go — theme.Context | T003-T005 | 2h | +| T007 | theme/registry.go — Style cache | T006 | 2h | +| T008 | theme/loader.go — YAML loader | T006 | 3h | +| T009 | Port theme YAML files | T008 | 30m | +| T010 | Port profile YAML files | T008 | 30m | +| T011 | Create skin YAML files (gh-dash, minimal) | T005 | 1h | +| T012 | component/header.go | T006 | 2h | +| T013 | component/controlbar.go | T006 | 2h | +| T014 | component/navigation.go (skin-dependent) | T006, T005 | 4h | +| T015 | component/table.go | T006 | 3h | +| T016 | component/card.go | T006 | 1h | +| T017 | component/panel.go | T006 | 1h | +| T018 | component/hero.go | T006 | 2h | +| T019 | component/badge.go | T006 | 1h | +| T020 | component/tree.go | T006 | 2h | +| T021 | shell/executor.go — Shell command wrapper | — | 2h | +| T022 | component/error.go — Error display | T006, T021 | 2h | +| T023 | component/list.go (wraps bubbles) | T006 | 2h | +| T024 | component/search.go (wraps bubbles) | T006 | 1h | +| T025 | component/spinner.go (wraps bubbles) | T006 | 1h | +| T026 | component/progress.go (wraps bubbles) | T006 | 1h | +| T027 | component/form.go (wraps huh) | T006 | 2h | +| T028 | component/viewport.go (wraps bubbles) | T006 | 1h | +| T029 | component/markdown.go (wraps glamour) | T006 | 1h | +| T030 | engine/view.go — View interface | — | 1h | +| T031 | engine/context.go — ViewContext | T006 | 1h | +| T032 | engine/state.go — StateManager | T006, T008 | 3h | +| T033 | engine/messages.go — ErrorMsg + others | T022 | 1.5h | +| T034 | engine/router.go — Router + OnEnter guarantee | T030-T032 | 3h | +| T035 | engine/keys.go — Global keys | — | 30m | +| T036 | engine/shell.go — Shell model | T012-T014, T034 | 5h | +| T037 | engine/launch.go — Start() entry | T036 | 2h | +| T038 | internal/app/bootstrap.go | T032 | 2h | +| T039 | Update cmd/arc/main.go | T038 | 1h | +| T040 | MILESTONE: Empty shell renders | T039 | 1h | + +### Phase 2: Theme + Skin (7 tasks, ~18h) + +| # | Task | Depends | Est | +| ---- | ------------------------------------ | ---------- | --- | +| T041 | StateManager.ChangeProfile() | T032 | 3h | +| T042 | StateChangedMsg in Shell | T036, T041 | 2h | +| T043 | Skin rendering in navigation | T014, T005 | 3h | +| T044 | Placeholder Config view | T041 | 3h | +| T045 | Theme system golden tests | T008 | 3h | +| T046 | Component golden tests | T015-T018 | 3h | +| T047 | MILESTONE: Profile switch works live | T044 | 1h | + +### Phase 3: Home + Services (7 tasks, ~19h) + +| # | Task | Depends | Est | +| ---- | ------------------------------------------- | ---------------------- | --- | +| T048 | view/home.go | T018, T016 | 4h | +| T049 | view/services_list.go (with error handling) | T015, T023, T024, T022 | 5h | +| T050 | view/service_detail.go | T016, T020 | 4h | +| T051 | Router: Home <-> Services tab switching | T034, T048, T049 | 2h | +| T052 | Focused mode: arc services list | T037, T049 | 2h | +| T053 | JSON mode: arc services list --json | T037 | 1h | +| T054 | MILESTONE: 2-tab dashboard with real data | T051 | 1h | + +### Phase 4: Workspace + Config (8 tasks, ~18h) + +| # | Task | Depends | Est | +| ---- | ------------------------------------- | ---------- | --- | +| T055 | view/workspace_info.go | T016, T015 | 3h | +| T056 | view/workspace_history.go | T015 | 3h | +| T057 | Complete view/config_overview.go | T044, T023 | 4h | +| T058 | view/version.go | T016 | 2h | +| T059 | Router: all 4 tabs wired | T048-T057 | 2h | +| T060 | Focused mode: workspace info, version | T037 | 2h | +| T061 | JSON mode: workspace info, version | T037 | 1h | +| T062 | MILESTONE: Full 4-tab dashboard | T059 | 1h | + +### Phase 5: Wizards + Commands (6 tasks, ~11.5h) + +| # | Task | Depends | Est | +| ---- | ------------------------------------------- | ---------------- | --- | +| T063 | view/init_wizard.go | T027 | 4h | +| T064 | view/workspace_run.go (with error handling) | T025, T026, T022 | 3h | +| T065 | Wire arc init | T063 | 1h | +| T066 | Wire arc workspace init/run | T063, T064 | 2h | +| T067 | arc completion (keep current) | — | 30m | +| T068 | MILESTONE: All commands work | T065-T067 | 1h | + +### Phase 6: Cleanup (8 tasks, ~14h) + +| # | Task | Depends | Est | +| ---- | -------------------------------- | ------- | --- | +| T069 | Delete all old UI code | T068 | 2h | +| T070 | Update .golangci.yml | T069 | 1h | +| T071 | Lint pass — zero //nolint target | T070 | 3h | +| T072 | Update README.md | T068 | 2h | +| T073 | Update agent doc | T068 | 2h | +| T074 | Update CLAUDE.md | T068 | 1h | +| T075 | Performance validation | T068 | 2h | +| T076 | MILESTONE: Merge to develop | T075 | 1h | + +**Total**: 76 tasks, ~140 hours (~7 weeks @ 20h/week) + +## Success Criteria + +### Functional Requirements + +- [x] Dashboard mode: `arc` opens 4-tab TUI (Home, Services, Workspace, Config) +- [x] Focused mode: `arc services list` opens single-view TUI (no tabs) +- [x] JSON mode: `arc services list --json` outputs JSON, no TUI +- [x] Tab navigation: Tab key cycles tabs, Shift+Tab reverse +- [x] Global quit: `q` or Ctrl+C quits back to terminal +- [x] Theme switching: Live profile/theme/skin change in Config view +- [x] Error display: Shell command failures shown with context, stderr, exit codes +- [x] Interactive components: Search, filter, sort, scrollable lists +- [x] Wizards: Init and workspace creation use huh forms + +### Non-Functional Requirements + +- [x] Performance: <100ms startup, <16ms navigation, <20MB memory +- [x] Code quality: Zero //nolint in new code, all 30 linters pass +- [x] Testing: 75% theme, 60% components, 70% engine, 50% views +- [x] Documentation: README updated, quickstart guide, agent context updated +- [x] Clean separation: Backend services 100% unchanged + +### Quality Gates + +**Phase 0 Gate**: Research complete, all unknowns resolved +**Phase 1 Gate**: Data model + contracts + quickstart generated +**Phase 2 Gate**: Empty shell renders, theme loads, components render +**Phase 3 Gate**: Profile switch works live, all themes tested +**Phase 4 Gate**: 2-tab dashboard with real data, errors displayed +**Phase 5 Gate**: Full 4-tab dashboard, all tabs functional +**Phase 6 Gate**: All commands work, wizards functional +**Phase 7 Gate**: Old code deleted, CI/CD green, performance targets met + +## Risk Assessment + +| Risk | Probability | Impact | Mitigation | +| --------------------------------------------------- | ----------- | ------ | ----------------------------------------------------------------------- | +| Bubble Tea lifecycle breaks existing views | Medium | High | Thorough OnEnter guarantee testing. Phased rollout with milestones. | +| Theme YAML parsing fails for existing profiles | Low | Medium | Reuse existing YAML loader. Test all 10 profiles. Fallback to defaults. | +| Shell executor timeouts break long-running commands | Medium | Medium | Configurable timeout. Context cancellation. Progress display. | +| Golden file tests flaky across terminals | High | Low | Normalize whitespace. Test on CI. Document environment requirements. | +| Performance regression on slow terminals | Medium | Medium | Profile on target hardware. Optimize render cycles. Cache styles. | +| Component composition too complex | Low | Medium | Keep components simple. Prefer wrapping libraries. Document patterns. | +| Worktree merge conflicts | Medium | Low | Frequent rebases. Small commits. Clear structure. | + +## Timeline + +**Total Duration**: 7 weeks (~140 hours @ 20h/week) + +| Week | Phase | Key Deliverable | Hours | +| -------- | --------------------------- | ---------------------------------------- | ----- | +| Week 0 | Phase 0: Research | research.md with decisions | 4h | +| Week 0 | Phase 1: Design | data-model.md, contracts/, quickstart.md | 12h | +| Week 1-2 | Phase 2: Foundation | Empty shell renders, components working | 59.5h | +| Week 3 | Phase 3: Theme Switching | Live profile change | 18h | +| Week 4 | Phase 4: Dashboard Views | 2-tab dashboard with real data | 19h | +| Week 5 | Phase 5: Complete Dashboard | Full 4-tab dashboard | 18h | +| Week 6 | Phase 6: Wizards & Commands | All commands functional | 11.5h | +| Week 7 | Phase 7: Cleanup & Ship | Merge to develop | 14h | + +**Critical Path**: T001 → T006 → T008 → T032 → T034 → T036 → T037 → T041 → T048 → T051 → T059 → T068 → T076 + +**Parallel Work Opportunities**: + +- Theme YAMLs can be ported while loader is in development (T009-T011 parallel to T008) +- Components can be built in parallel once theme.Context is stable (T012-T029 after T006) +- Views can be built in parallel once engine interfaces are stable (T048-T058 after T034) +- Golden tests can be written in parallel with component development (T045-T046) + +## Dependencies + +**Internal**: + +- Existing backend services must remain stable (no breaking changes) +- Existing preferences structure (state.json format) +- Existing XDG directory layout +- Existing profile themes (10 themes, 10 profiles) + +**External**: + +- Charmbracelet ecosystem (bubbletea, bubbles, lipgloss, huh, glamour, harmonica) +- Go 1.24.2 compiler +- Terminal with ANSI color support (256-color minimum, truecolor preferred) + +**Blockers**: + +- None identified. All dependencies already in go.mod. + +## Appendix + +### Glossary + +- **Shell**: Root Bubble Tea model. Persistent frame (header + navigation + controlbar). +- **View**: Pluggable content panel. Receives ViewContext, renders content area. +- **Router**: Navigation manager. Calls OnExit/OnEnter, ensures lifecycle. +- **ViewContext**: Props passed to views (theme, dimensions, backend services). +- **StateManager**: Preferences loader, theme resolver, ViewContext builder. +- **Theme**: Color palette (ColorSet). +- **Profile**: Branding (logo, tier names, theme reference). +- **Skin**: Layout rules (sidebar vs tabs, border style, density). +- **Design Tokens**: CSS-like variables (colors, spacing, typography, icons). +- **Shell Executor**: Command wrapper (timeout, stderr capture, exit codes). +- **ErrorMsg**: Bubble Tea message for async errors. +- **Launch Mode**: Dashboard (all tabs) vs Focused (single view). + +### References + +- **Spec**: [spec.md](./spec.md) - Full UI Design & Implementation Plan +- **Constitution**: `.specify/memory/constitution.md` - A.R.C. CLI principles +- **Patterns**: `.specify/memory/patterns.md` - Architectural patterns +- **Charmbracelet Docs**: https://github.com/charmbracelet +- **gh-dash**: https://github.com/dlvhdr/gh-dash - Design inspiration +- **Bubble Tea Tutorial**: https://github.com/charmbracelet/bubbletea/tree/master/tutorials + +### Related Specs + +- **017-ui-engine**: Previous UI redesign (horizontal tabs → sidebar) +- **015-ui-refactor**: Earlier UI refactor (Bubble Tea v1.3.4 upgrade) +- **012-ui-error-component**: Error component prototype +- **005-animations-rich-ui**: Animation framework and design patterns + +--- + +_"The CLI should feel like it was built from the heart."_ +_— A.R.C. CLI v2 UI Design & Implementation Plan, March 2026_ diff --git a/specs/018-ui-design/quickstart.md b/specs/018-ui-design/quickstart.md new file mode 100644 index 0000000..45fa2ca --- /dev/null +++ b/specs/018-ui-design/quickstart.md @@ -0,0 +1,701 @@ +# Quickstart Guide: UI Design & Architecture Rebuild + +**Feature**: 018-ui-design +**Audience**: Developers implementing tasks T001-T076 +**Date**: 2026-03-03 + +## Table of Contents + +1. [Getting Started](#getting-started) +2. [Creating a New View](#creating-a-new-view) +3. [Adding a New Component](#adding-a-new-component) +4. [Defining a New Theme](#defining-a-new-theme) +5. [Creating a Skin](#creating-a-skin) +6. [Testing with Golden Files](#testing-with-golden-files) +7. [Handling Shell Command Errors](#handling-shell-command-errors) +8. [Debugging Tips](#debugging-tips) + +--- + +## Getting Started + +### Prerequisites + +- Go 1.24.2+ +- Git worktree understanding +- Charmbracelet ecosystem familiarity (bubbles, lipgloss, huh) + +### Setup Worktree + +```bash +# From repo root +cd /path/to/arc-cli +git worktree add ../arc-cli-v2 -b 018-ui-rewrite + +# Navigate to worktree +cd ../arc-cli-v2 + +# Verify isolation +git branch # Should show: * 018-ui-rewrite +``` + +### Project Structure + +``` +pkg/ui/ +├── theme/ # Theme system +│ ├── context.go # theme.Context (dependency injection) +│ ├── theme.go # Theme entity +│ ├── profile.go # Profile entity +│ ├── skin.go # Skin entity +│ ├── loader.go # YAML loader +│ └── registry.go # Style cache +│ +├── component/ # 17 components +│ ├── header.go +│ ├── card.go +│ └── ... +│ +├── engine/ # Engine core +│ ├── shell.go # Root model +│ ├── router.go # Navigation +│ ├── state.go # StateManager +│ └── view.go # View interface +│ +├── view/ # 9 view implementations +│ ├── home.go +│ ├── services_list.go +│ └── ... +│ +└── shell/ # Shell command execution + └── executor.go +``` + +--- + +## Creating a New View + +### Step 1: Define the View Struct + +```go +// pkg/ui/view/home.go +package view + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/arc/pkg/ui/component" + "github.com/arc/pkg/ui/engine" + "github.com/arc/pkg/ui/theme" +) + +type Home struct { + ctx *engine.ViewContext + loaded bool +} + +func NewHome() *Home { + return &Home{} +} +``` + +### Step 2: Implement View Interface + +```go +// Lifecycle methods +func (h *Home) Init() tea.Cmd { + return nil // No initialization needed +} + +func (h *Home) Update(msg tea.Msg) (engine.View, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "enter": + // Navigate to another view + return h, func() tea.Msg { + return engine.NavigateMsg{Target: "services-list"} + } + } + } + return h, nil +} + +func (h *Home) View() string { + if !h.loaded { + return "Loading..." + } + + // Render using components + hero := component.Hero(h.ctx.Theme, h.ctx.Profile) + return hero +} + +func (h *Home) OnEnter(ctx *engine.ViewContext) tea.Cmd { + h.ctx = ctx + h.loaded = true + return nil +} + +func (h *Home) OnExit() tea.Cmd { + return nil +} + +// Metadata methods +func (h *Home) Name() string { + return "home" +} + +func (h *Home) Keybindings() []engine.KeyBinding { + return []engine.KeyBinding{ + {Key: "enter", Description: "View services"}, + {Key: "q", Description: "Quit"}, + } +} +``` + +### Step 3: Register with Router + +```go +// internal/app/bootstrap.go +func InitializeUI(state *engine.StateManager) *engine.Router { + router := engine.NewRouter(state) + + router.Register("home", view.NewHome()) + router.Register("services-list", view.NewServicesList()) + // ... more views + + return router +} +``` + +### Step 4: Wire to Command + +```go +// cmd/arc/root.go +var rootCmd = &cobra.Command{ + Use: "arc", + Short: "ARC CLI", + RunE: func(cmd *cobra.Command, args []string) error { + return engine.Start(engine.DashboardMode, "home", nil) + }, +} +``` + +--- + +## Adding a New Component + +### Stateless Component (Render Function) + +```go +// pkg/ui/component/card.go +package component + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/arc/pkg/ui/theme" +) + +// Card renders a bordered card with title and content. +func Card(ctx *theme.Context, title, content string) string { + borderStyle := ctx.Registry.GetStyle("card-border", func() lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(ctx.Theme.Colors.Border)). + Padding(1, 2) + }) + + titleStyle := ctx.Registry.GetStyle("card-title", func() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(ctx.Theme.Colors.Primary)). + Bold(true) + }) + + renderedTitle := titleStyle.Render(title) + return borderStyle.Render(renderedTitle + "\n\n" + content) +} +``` + +### Stateful Component (Wraps bubbles) + +```go +// pkg/ui/component/spinner.go +package component + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/lipgloss" + "github.com/arc/pkg/ui/theme" +) + +type Spinner struct { + spinner spinner.Model + ctx *theme.Context +} + +func NewSpinner(ctx *theme.Context) Spinner { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle(). + Foreground(lipgloss.Color(ctx.Theme.Colors.Primary)) + + return Spinner{ + spinner: s, + ctx: ctx, + } +} + +func (s Spinner) Init() tea.Cmd { + return s.spinner.Tick +} + +func (s Spinner) Update(msg tea.Msg) (Spinner, tea.Cmd) { + var cmd tea.Cmd + s.spinner, cmd = s.spinner.Update(msg) + return s, cmd +} + +func (s Spinner) View() string { + return s.spinner.View() +} +``` + +### Component Testing + +```go +// pkg/ui/component/card_test.go +package component + +import ( + "testing" + "github.com/arc/pkg/ui/theme" +) + +func TestCard(t *testing.T) { + tests := []struct { + name string + title string + content string + }{ + {"simple", "Title", "Content"}, + {"multiline", "Title", "Line 1\nLine 2"}, + } + + ctx := &theme.Context{ + Theme: &theme.Theme{ + Colors: theme.ColorSet{ + Primary: "#00B4D8", + Border: "#0077B6", + }, + }, + Registry: theme.NewRegistry(), + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Card(ctx, tt.title, tt.content) + if result == "" { + t.Error("Card returned empty string") + } + }) + } +} +``` + +--- + +## Defining a New Theme + +### Step 1: Create YAML File + +```yaml +# pkg/ui/theme/embedded/themes/my-theme.yaml +id: my-theme +name: My Custom Theme +colors: + primary: "#FF6B6B" + secondary: "#4ECDC4" + accent: "#FFE66D" + background: "#1A1A2E" + foreground: "#EAEAEA" + success: "#95E1D3" + warning: "#F38181" + error: "#AA4465" + muted: "#5C5C6D" + border: "#4ECDC4" +``` + +### Step 2: Validate Color Codes + +```bash +# Use online tool or script +echo "#FF6B6B" | grep -E '^#[0-9A-Fa-f]{6}$' +``` + +### Step 3: Test Theme Loading + +```go +// theme/loader_test.go +func TestLoadMyTheme(t *testing.T) { + themes, err := LoadThemes() + if err != nil { + t.Fatal(err) + } + + found := false + for _, theme := range themes { + if theme.ID == "my-theme" { + found = true + if theme.Colors.Primary != "#FF6B6B" { + t.Errorf("Wrong primary color: %s", theme.Colors.Primary) + } + } + } + + if !found { + t.Error("my-theme not found") + } +} +``` + +### Step 4: Create Matching Profile + +```yaml +# pkg/ui/theme/embedded/profiles/my-profile.yaml +id: my-profile +name: My Custom Profile +tier_names: ["Novice", "Expert", "Master"] +logo: "default.txt" +theme_id: my-theme +``` + +--- + +## Creating a Skin + +### Step 1: Define Skin YAML + +```yaml +# pkg/ui/theme/embedded/skins/compact.yaml +id: compact +name: Compact Layout +navigation: + style: sidebar + position: left +borders: + style: square + width: thin +density: compact +``` + +### Step 2: Implement Skin-Dependent Rendering + +```go +// component/navigation.go +func Navigation(ctx *theme.Context, tabs []string, active int) string { + if ctx.Skin.Navigation.Style == "sidebar" { + return renderSidebar(ctx, tabs, active) + } + return renderTabBar(ctx, tabs, active) +} + +func renderSidebar(ctx *theme.Context, tabs []string, active int) string { + // Vertical layout (20 cols wide) + // ... +} + +func renderTabBar(ctx *theme.Context, tabs []string, active int) string { + // Horizontal layout (full width) + // ... +} +``` + +### Step 3: Test Skin Switching + +```go +func TestNavigationSkins(t *testing.T) { + ctx := &theme.Context{ + Skin: &theme.Skin{ + Navigation: theme.NavigationStyle{Style: "tab-bar"}, + }, + } + + result := Navigation(ctx, []string{"Home", "Services"}, 0) + // Verify horizontal layout + + ctx.Skin.Navigation.Style = "sidebar" + result = Navigation(ctx, []string{"Home", "Services"}, 0) + // Verify vertical layout +} +``` + +--- + +## Testing with Golden Files + +### Step 1: Create Golden Test + +```go +// theme/loader_test.go +func TestThemeGolden(t *testing.T) { + themes, _ := LoadThemes() + + for _, theme := range themes { + t.Run(theme.ID, func(t *testing.T) { + ctx := &theme.Context{ + Theme: &theme, + Registry: NewRegistry(), + } + + // Render something with this theme + output := component.Header(ctx, "Test", "Subtitle") + + // Compare to golden file + goldenFile := filepath.Join("testdata", "golden", theme.ID+".golden") + if *updateGolden { + os.WriteFile(goldenFile, []byte(output), 0644) + } + + expected, _ := os.ReadFile(goldenFile) + if string(expected) != output { + t.Errorf("Output doesn't match golden file") + } + }) + } +} +``` + +### Step 2: Generate Golden Files + +```bash +# First run generates golden files +go test ./pkg/ui/theme/... -update-golden + +# Subsequent runs compare +go test ./pkg/ui/theme/... +``` + +### Step 3: Review Golden Files + +```bash +# View generated output +cat testdata/golden/cyberstart.golden + +# Golden files should be committed to git +git add testdata/golden/*.golden +git commit -m "Add golden files for theme tests" +``` + +--- + +## Handling Shell Command Errors + +### Step 1: Use Shell Executor + +```go +// view/services_list.go +import "github.com/arc/pkg/ui/shell" + +func (v *ServicesList) startService(name string) tea.Cmd { + return func() tea.Msg { + executor := shell.NewExecutor() + result := executor.Run(context.Background(), "docker", "start", name) + + if !result.Success() { + return engine.ErrorMsg{ + Context: fmt.Sprintf("Starting service '%s'", name), + Err: fmt.Errorf("exit code %d", result.ExitCode), + Severity: engine.ErrorSeverityError, + Timestamp: time.Now(), + Dismissible: true, + Details: result.Stderr, + } + } + + return ServiceStartedMsg{Name: name} + } +} +``` + +### Step 2: Handle Error in Shell + +```go +// engine/shell.go +func (s Shell) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case engine.ErrorMsg: + s.error = &msg + return s, nil + } + + // ... delegate to view +} + +func (s Shell) View() string { + // ... render header, navigation, content + + if s.error != nil { + errorBox := component.ErrorDisplay(s.ctx, s.error) + return lipgloss.JoinVertical(lipgloss.Left, content, errorBox) + } + + return content +} +``` + +### Step 3: Dismiss Error + +```go +func (s Shell) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "d" && s.error != nil && s.error.Dismissible { + s.error = nil + return s, nil + } + } + // ... +} +``` + +--- + +## Debugging Tips + +### Enable Debug Logging + +```go +// Set environment variable +export ARC_LOG_LEVEL=debug + +// Use log package +import "github.com/arc/pkg/log" + +log.Debug("View OnEnter", "name", v.Name(), "ctx", v.ctx) +``` + +### Inspect Bubble Tea Messages + +```go +func (v *MyView) Update(msg tea.Msg) (engine.View, tea.Cmd) { + log.Debug("Message received", "type", fmt.Sprintf("%T", msg), "msg", msg) + // ... +} +``` + +### Test Views in Isolation + +```go +// Create minimal test harness +func TestViewRendering(t *testing.T) { + v := NewHome() + + ctx := &engine.ViewContext{ + Theme: &theme.Theme{/* ... */}, + Width: 80, + Height: 24, + } + + v.OnEnter(ctx) + output := v.View() + + if output == "" { + t.Error("View returned empty string") + } +} +``` + +### Validate YAML Files + +```bash +# Install yamllint +brew install yamllint # macOS + +# Validate all theme files +yamllint pkg/ui/theme/embedded/**/*.yaml +``` + +### Check Style Cache + +```go +// Add logging to Registry +func (r *Registry) GetStyle(key string, builder func() lipgloss.Style) lipgloss.Style { + log.Debug("Style cache", "key", key, "hit", r.cache[key] != nil) + // ... +} +``` + +--- + +## Common Patterns + +### Pattern: Component with Theme Context + +```go +func MyComponent(ctx *theme.Context, data string) string { + style := ctx.Registry.GetStyle("my-component", func() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(ctx.Theme.Colors.Primary)) + }) + return style.Render(data) +} +``` + +### Pattern: View with Backend Service + +```go +func (v *ServicesList) OnEnter(ctx *engine.ViewContext) tea.Cmd { + v.ctx = ctx + + // Fetch data from backend + services, err := ctx.Catalog.List() + if err != nil { + return func() tea.Msg { + return engine.ErrorMsg{/* ... */} + } + } + + v.services = services + return nil +} +``` + +### Pattern: Navigation Between Views + +```go +case tea.KeyMsg: + if msg.String() == "enter" { + return v, func() tea.Msg { + return engine.NavigateMsg{ + Target: "service-detail", + Args: map[string]any{ + "service_name": v.selectedService, + }, + } + } + } +``` + +--- + +## Next Steps + +1. **Start with T001**: Create worktree, scaffold folders +2. **Read contracts/**: Review interface definitions +3. **Follow phases**: Don't skip ahead (dependencies matter) +4. **Test incrementally**: Write tests as you implement +5. **Ask questions**: Reference research.md and data-model.md + +--- + +## References + +- [Bubble Tea Tutorial](https://github.com/charmbracelet/bubbletea/tree/master/tutorials) +- [Lipgloss Documentation](https://github.com/charmbracelet/lipgloss) +- [Bubbles Components](https://github.com/charmbracelet/bubbles) +- [Huh Forms](https://github.com/charmbracelet/huh) + +--- + +_Happy coding! Remember: Wrap libraries, don't reimplement. 🚀_ diff --git a/specs/018-ui-design/research-t069-migration.md b/specs/018-ui-design/research-t069-migration.md new file mode 100644 index 0000000..2ee5837 --- /dev/null +++ b/specs/018-ui-design/research-t069-migration.md @@ -0,0 +1,320 @@ +# T069 Migration Research: Removing Legacy UI + +**Date**: 2026-03-03 +**Scope**: Phase 6 — delete `pkg/ui.legacy/` and all consuming code in `pkg/cli/`, `internal/app/`, `pkg/workspace/` +**Status**: ✅ APPROVED — proceeding with implementation + +### Approved Decisions + +| # | Decision | Resolution | +| --- | ------------------ | ------------------------------------------------------------------------------------------------------------------------- | +| 1 | Animation handling | Remove ALL legacy animation code. Charmbracelet library handles animation. No `animations.ShouldAnimate()` checks needed. | +| 2 | Logo/branding | New `component.Logo(mode)` with 4 modes: `None`, `Compact`, `Short`, `Long` — see Section 9. | +| 3 | Old UI support | **Zero**. No `ARC_USE_LEGACY_UI` env var. No `legacyRun*` paths. Single code path: new engine. | +| 4 | Migration approach | Rewrite where moving/adjusting is harder. Don't lift-and-shift broken patterns. | +| 5 | `pkg/ui.legacy/` | Delete entirely after all consumers are removed. | + +--- + +## 1. Current State + +### 1.1 What Was Already Done + +The `pkg/ui/` directory was already rebuilt clean (T001–T062). The old code +was moved to `pkg/ui.legacy/` to allow a side-by-side migration. Most commands +have an `ARC_USE_LEGACY_UI=""` fast path that calls the new engine. + +### 1.2 Files Still Importing `pkg/ui.legacy/` + +**22 non-test files outside `pkg/ui.legacy/`** still import legacy packages: + +| File | Legacy Imports | +| -------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `internal/app/context.go` | `ui`, `ui/components`, `ui/profiles` | +| `internal/app/factory.go` | `ui`, `ui/themes` | +| `pkg/cli/banner.go` | `ui/animations`, `ui/components`, `ui/profiles`, `ui/styles`, `ui/themes` | +| `pkg/cli/completion.go` | `ui/styles` | +| `pkg/cli/help.go` | `ui/animations`, `ui/styles` | +| `pkg/cli/info.go` | `ui`, `ui/animations`, `ui/components`, `ui/engine`, `ui/profiles`, `ui/themes`, `ui/views` | +| `pkg/cli/root.go` | `ui`, `ui/animations`, `ui/components`, `ui/profiles`, `ui/styles`, `ui/themes` | +| `pkg/cli/theme.go` | `ui`, `ui/animations`, `ui/components`, `ui/engine`, `ui/profiles`, `ui/styles`, `ui/themes`, `ui/views` | +| `pkg/cli/init.go` | `ui/components`, `ui/profiles` | +| `pkg/cli/init_profile_ui.go` | (uses lipgloss only — no legacy imports, but is dead code) | +| `pkg/cli/config/profile.go` | `ui`, `ui/engine`, `ui/profiles`, `ui/styles`, `ui/views` | +| `pkg/cli/dashboard/app.go` | `ui`, `ui/components`, `ui/engine`, `ui/views` | +| `pkg/cli/dashboard/config_view.go` | `ui` | +| `pkg/cli/dashboard/dashboard_view.go` | `ui`, `ui/components` | +| `pkg/cli/dashboard/services_view.go` | `ui`, `ui/components` | +| `pkg/cli/dashboard/workspace_view.go` | `ui`, `ui/components` | +| `pkg/cli/middleware/error_boundary.go` | `ui`, `ui/components`, `ui/themes` | +| `pkg/cli/middleware/profile.go` | `ui`, `ui/components`, `ui/profiles` | +| `pkg/cli/services/deps.go` | `ui`, `ui/engine`, `ui/styles`, `ui/views` | +| `pkg/cli/services/info.go` | `ui/styles` | +| `pkg/cli/services/list.go` | `ui/styles` | +| `pkg/cli/services/ports.go` | `ui`, `ui/engine`, `ui/styles`, `ui/views` | +| `pkg/cli/workspace/history.go` | `ui`, `ui/components`, `ui/engine`, `ui/profiles`, `ui/views` | +| `pkg/workspace/formatter.go` | `ui/profiles` | + +--- + +## 2. Gap Analysis: "New UI Paths" That Still Call Legacy Code + +This is the critical finding. Several commands have an `ARC_USE_LEGACY_UI=""` guard +that was supposed to use the new engine — but the "new UI path" function itself +still instantiates a **legacy view** from `pkg/ui.legacy/views/`. These are +**not actually migrated yet**: + +### Gap 1 — `pkg/cli/info.go`: `renderInfoWithNewUI()` → legacy `views.NewInfoView` + +```go +// This is NOT actually using the new engine. It uses: +// - legacy profiles.ProfileContext +// - legacy components.SafeBorder +// - legacy ui.NewComponentFactory +// - legacy views.NewInfoView +// - legacy engine.NewViewContext / engine.Render +func renderInfoWithNewUI(ctx *app.Context, info *branding.SystemInfo) error { ... } +``` + +**Resolution**: Create `pkg/ui/view/info.go` using the new `engine.ViewContext` and +`component.*` package, then wire `arc info` to launch via `engine.Start`. +**Alternatively** (simpler): simplify `arc info` to themed plain-text output using +`uithemeldr.NewLoader()` + lipgloss directly — no full TUI needed for a utility +info dump. + +### Gap 2 — `pkg/cli/theme.go`: `renderThemeListWithNewUI()` → legacy `views.NewThemeListView` + +```go +func renderThemeListWithNewUI() error { + factory := ... // legacy ui.NewComponentFactory + view := views.NewThemeListView(factory) // legacy view + ... +} +``` + +**Resolution**: Simplify `arc theme list` to plain text output using +`uithemeldr.NewLoader().ListThemes()`. The Config view in the new dashboard +already handles theme switching interactively — these solo commands become +simple non-TUI utility prints. + +### Gap 3 — `pkg/cli/config/profile.go`: profile commands → legacy `views.NewConfigGetView`, `views.NewProfileListView` + +```go +func renderGetProfileUI() error { + view := views.NewConfigGetView(factory) // legacy view +} +func renderListProfilesUI() error { + view := views.NewProfileListView(factory) // legacy view +} +``` + +**Resolution**: Same strategy as theme — simplify to plain text using +`uithemeldr.NewLoader().ListProfiles()` / preferences. The new `ConfigOverview` +TUI view handles all of this in the dashboard. + +### Gap 4 — `pkg/cli/workspace/history.go`: `renderHistoryWithNewUI()` → legacy `views.NewWorkspaceHistoryView` + +```go +func renderHistoryWithNewUI(flags *historyFlags) error { + factory := ... // legacy factory + view := views.NewWorkspaceHistoryView(factory) // legacy view +} +``` + +**Resolution**: Re-wire to use the **already-built** `uiview.NewWorkspaceHistory()` +via `engine.Start` — this is an easy fix, the new view already exists. + +--- + +## 3. Full Migration Strategy + +### Category A — DELETE ENTIRE (no replacement needed) + +These files/dirs are fully superseded. After consuming code is migrated they +can be deleted outright. + +| Target | Reason | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `pkg/cli/dashboard/` (5 files) | Replaced by `engine.Start` in `root.go`. The `dashboard.ShouldLaunchDashboard` check and `dashboard.Launch` fallback will both be removed. | +| `pkg/cli/middleware/` (2 files) | `integrateProfileMiddleware` is explicitly `//nolint:unused` and DISABLED. No command calls it. The new engine handles its own setup. | +| `pkg/cli/errors/` (2 files) | `ArcError` and `HintRegistry` are only used inside the middleware — deleting middleware removes this dependency. | +| `pkg/cli/init_profile_ui.go` | Dead code — the hardcoded-color profile step wizard, never called in the new path. | + +### Category B — RE-WIRE TO NEW ENGINE + +These files have the right intent but call the wrong view implementation. + +| Target | Action | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `pkg/cli/workspace/history.go` | Replace `renderHistoryWithNewUI` body: drop legacy factory/view, call `engine.Start` with `uiview.NewWorkspaceHistory()`. **Easy** — new view already built. | +| `pkg/cli/info.go` | **Option A**: Create `pkg/ui/view/info.go` (new view) + wire via `engine.Start`. **Option B** (recommended): Remove TUI path, render themed plain text using `uithemeldr.NewLoader()` + lipgloss panels. `arc info` is a utility dump — no TUI needed. | + +### Category C — SIMPLIFY (keep new path, delete legacy path) + +These files have correct dual-path structure. Work is: delete the `legacyRun*` +functions and the legacy imports, keep only the `ARC_USE_LEGACY_UI=""` fast path +(or remove the guard entirely since legacy is gone). + +| Target | Size Before | What Gets Removed | +| --------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `pkg/cli/init.go` | 1146 lines | ~1000 lines of old Bubble Tea wizard state machine (`initModel`, `wizardStep`, `ProfileSelection`, `StackSelection`, `PathSelection`, `Installation`, `Completion` and all their Update/View functions). Shrinks to ~80 lines. | +| `pkg/cli/theme.go` | 428 lines | Legacy theme list/set/show/preview rendering. The 4 legacy cmd bodies + `ThemePreviewState` struct (~300 lines). Shrinks to ~100 lines. | +| `pkg/cli/config/profile.go` | 442 lines | `legacyRunSetProfile`, `legacyRunGetProfile`, `legacyRunListProfiles` + legacy factory setup (~250 lines). Shrinks to ~150 lines. | +| `pkg/cli/services/deps.go` | — | Legacy `runDepsLegacy` path, replace `renderDepsWithNewUI` to call new engine instead of legacy engine. | +| `pkg/cli/services/ports.go` | — | Same as deps. | +| `pkg/cli/completion.go` | 265 lines | Remove `styles.Error(…)` → replace with `fmt.Fprintf(os.Stderr, …)`. 1 import removed. | +| `pkg/cli/help.go` | 179 lines | Remove `animations.ShouldAnimate()` guard → plain output only. `styles.NoColor` check → use `os.Getenv("NO_COLOR") != ""`. | +| `pkg/cli/services/info.go` | — | Remove `styles.` calls → `fmt.Fprintf`. | +| `pkg/cli/services/list.go` | — | Remove `styles.` calls → `fmt.Fprintf`. | + +### Category D — INFRASTRUCTURE SIMPLIFICATION + +| Target | Action | +| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `internal/app/context.go` | Remove fields: `UI *ui.Service`, `SafeBorder *components.SafeBorder`, `Factory ui.ComponentFactory`, `profileContext *profiles.ProfileContext`. Remove `GetProfileContext()`, `InvalidateProfileContext()` methods. Keep: `Config`, `Logger`, `Store`, `Prefs`, `Catalog`, `BaseDir`, `NoColor`, `NoAnimation`. | +| `internal/app/factory.go` | Remove legacy theme loading + `ui.NewService(theme)` call. Keep: logger, store, prefs, catalog setup. | +| `pkg/workspace/formatter.go` | Replace `profiles.NewRepository() + NewResolver()` call for tier names with a simple static map: `tierID → displayName`. The display names are fixed constants (Super Saiyan, Super Saiyan Blue, Ultra Instinct) — no profile context needed here. | + +### Category E — SPECIAL: `pkg/cli/banner.go` + `pkg/cli/root.go` + +`banner.go` (660 lines) renders the ASCII arc logo with animations and profile +theming. It is only used in `root.go` as a **final fallback** when the new engine +TUI cannot launch (e.g. in a pipe, CI, `--no-color`). + +**Recommended approach**: + +1. **Delete `pkg/cli/banner.go`** in its entirety. +2. In `root.go`, replace the banner fallback with a 5–10 line inline plain-text + "header" using lipgloss directly (no legacy deps needed): + +```go +// Simple banner for non-TTY / fallback — no animation, no profile theming. +banner := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00ADD8")). + Render("A.R.C. CLI") +fmt.Printf("%s %s\n\n", banner, branding.Tagline) +``` + +This removes the 660-line file and all 5 legacy imports in one stroke, while +keeping a minimal non-interactive fallback. + +--- + +## 4. New Code Required + +| New File | Purpose | Size Estimate | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------- | +| `pkg/ui/view/info.go` _(optional)_ | New `arc info` view using new engine — shows system panels with `component.Card`. Only needed if we want a full TUI for `arc info`. If we go plain-text, this is NOT required. | ~150 lines | + +**Recommendation**: Skip the new Info TUI view. Plain-text with lipgloss panels +is sufficient for a debug/utility command. Saves ~150 lines of new code and +keeps the new view directory focused on important dashboard views. + +--- + +## 5. Dependency Order for Deletion + +To avoid breaking the build mid-migration, changes must happen in this order: + +``` +Step 1 Fix Gap 4: re-wire history.go → uiview.NewWorkspaceHistory() (easy, unblocks history) +Step 2 Fix Gap 2+3: simplify theme.go + config/profile.go to plain text (unblocks styles/engine/views removal) +Step 3 Fix Gap 1: simplify info.go to plain text (unblocks ui/engine imports in info.go) +Step 4 Fix root.go: remove banner + legacy dashboard paths, inline plain banner +Step 5 Delete pkg/cli/banner.go +Step 6 Delete pkg/cli/dashboard/ +Step 7 Delete pkg/cli/middleware/ + pkg/cli/errors/ +Step 8 Delete pkg/cli/init_profile_ui.go +Step 9 Simplify internal/app/context.go + factory.go +Step 10 Simplify pkg/workspace/formatter.go +Step 11 Simplify pkg/cli/init.go (remove ~1000 line legacy wizard) +Step 12 Simplify services/deps.go, services/ports.go +Step 13 Simplify completion.go, help.go, services/info.go, services/list.go +Step 14 run `go build ./...` — all green +Step 15 Verify ARC_USE_LEGACY_UI guard is fully gone from all files +Step 16 Delete pkg/ui.legacy/ entirely +``` + +--- + +## 6. Risk & Rollback + +| Risk | Mitigation | +| --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `arc init` legacy wizard is removed, new TUI has edge case bugs | Keep `ARC_USE_LEGACY_UI` env var as documented escape hatch → but the code for legacy wizard is gone. Rollback via git. | +| `arc info` loses interactive TUI | Accepted tradeoff — plain text is fine for a utility/debug command. | +| `arc theme list/show` loses animations | Accepted — the new Config view in the dashboard has live theme switching. | +| Tests that test the legacy views break | All `pkg/ui.legacy/**_test.go` files are deleted with the directory. Tests for the new views are in `pkg/ui/`. | + +--- + +## 7. Summary Counts + +| Category | Files/Dirs | Lines Removed (est.) | +| ------------------------- | -------------------------------- | ------------------------- | +| A — Delete entire | 4 dirs / 9 files | ~2,800 | +| B — Re-wire | 2 files | ~100 lines changed | +| C — Simplify | 9 files | ~1,600 removed | +| D — Infrastructure | 3 files | ~120 removed | +| E — Banner | 1 file deleted + root.go trimmed | ~680 removed | +| **pkg/ui.legacy/ itself** | **~90 .go files** | **~15,000** | +| **Total** | | **~20,000 lines removed** | + +--- + +## 8. Decision Points — ✅ All Resolved + +| # | Decision | Resolution | +| --- | --------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| D1 | `arc info` TUI vs plain text | **Plain text** using lipgloss + new `uithemeldr.NewLoader()`. No new TUI view needed for a debug utility. | +| D2 | `arc init` legacy wizard | **Delete entirely** — `InitWizard` view in `pkg/ui/view/init_wizard.go` is already wired. | +| D3 | `arc theme list/set/show` | **Keep as plain-text commands** using new `uithemeldr.NewLoader()`. No legacy engine. | +| D4 | `pkg/workspace/formatter.go` tier names | **Static map** — tier names are constants, no profile resolver needed. | +| D5 | `internal/branding/` | **Keep** — provides `CollectSystemInfo()` + `Tagline`/`Name` with no UI deps. | +| D6 | `ARC_USE_LEGACY_UI` env var | **Remove entirely** — no escape hatch. Single code path: new engine. | +| D7 | Animations | **Remove all legacy animation code**. Charmbracelet library handles animations natively. | + +--- + +## 9. Logo Modes Design + +### Rationale + +The ASCII logo from `docs/branding/ascii-banners.txt` exists in multiple sizes. +Instead of picking one, we expose a `LogoMode` enum that views choose based on context. + +### `pkg/ui/component/logo.go` — New Component + +```go +type LogoMode int + +const ( + LogoNone LogoMode = iota // No logo — most views + LogoCompact // MINI (~40 chars wide) — shell header + LogoShort // COMPACT (~60 chars) + tagline — Home view + LogoLong // MEDIUM (~80 chars) + tagline + version — Version view / fallback +) + +func Logo(tc *theme.Context, mode LogoMode) string { ... } +``` + +### Mode → Context Mapping + +| Mode | ASCII Variant | Description Line | Used In | +| --------- | ------------------------------------------------ | ----------------------------------- | --------------------------------------------------------- | +| `None` | — | — | Services list, workspace views, forms, most focused views | +| `Compact` | MINI (40-char geometric + `╔═╗ ╦═╗ ╔═╗` letters) | none | Shell header strip | +| `Short` | COMPACT (60-char) | `"Agentic Reasoning Core"` | Home view hero, Init wizard | +| `Long` | MEDIUM (80-char) | `"Agentic Reasoning Core • v{ver}"` | Version view, non-TTY fallback | + +### Impact on Existing Components + +- `component/hero.go` — Update to use `Logo(ctx, LogoShort)` instead of `p.Logo` raw string +- `engine/shell.go` — Header area can optionally include `Logo(ctx, LogoCompact)` +- `view/version.go` — Prepend `Logo(ctx, LogoLong)` above the info card +- `view/home.go` — Replace current `Hero(ctx, tier)` logo section with `Logo(ctx, LogoShort)` +- `view/init_wizard.go` — Header with `Logo(ctx, LogoShort)` +- Non-TTY fallback in `root.go` — Call `Logo(nil, LogoLong)` with plain lipgloss styling + +--- + +_All decisions resolved. Implementation proceeds._ diff --git a/specs/018-ui-design/research.md b/specs/018-ui-design/research.md new file mode 100644 index 0000000..5d7d501 --- /dev/null +++ b/specs/018-ui-design/research.md @@ -0,0 +1,518 @@ +# Research & Discovery: UI Design & Architecture Rebuild + +**Feature**: 018-ui-design +**Date**: 2026-03-03 +**Status**: Living Document (updated during implementation) + +## Executive Summary + +Complete research findings for UI rebuild using React-inspired patterns, Design Tokens system, and comprehensive error handling. Key decisions: wrap Charmbracelet libraries (don't reimplement), use theme.Context dependency injection, implement 3-layer error handling, and optimize for <100ms startup time. + +--- + +## 1. Design Tokens Research + +### Concept + +Design Tokens are named entities that store visual design attributes (colors, spacing, typography, icons). They provide a single source of truth and enable theme switching without code changes. + +**Origin**: Popularized by Salesforce Lightning Design System, adopted by styled-components, Material-UI, Tailwind CSS. + +### CLI Application to ARC + +**Token Categories**: + +```yaml +colors: + primary: "#00B4D8" + secondary: "#0077B6" + accent: "#90E0EF" + background: "#03045E" + foreground: "#CAF0F8" + success: "#06FFA5" + warning: "#FFD60A" + error: "#FF006E" + muted: "#8D99AE" + +spacing: + xs: 1 # 1 cell + sm: 2 # 2 cells + md: 4 # 4 cells + lg: 8 # 8 cells + xl: 16 # 16 cells + +typography: + font_family: "monospace" + header_weight: "bold" + body_weight: "normal" + +borders: + style: "rounded" # rounded | square | thick | double + width: "normal" # thin | normal | thick + +icons: + check: "✓" + cross: "✗" + arrow_right: "→" + spinner: "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" +``` + +### Implementation in ARC + +**Approach**: YAML-based with embedded defaults + +```go +// pkg/ui/theme/theme.go +type Theme struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Colors ColorSet `yaml:"colors"` +} + +type ColorSet struct { + Primary string `yaml:"primary"` + Secondary string `yaml:"secondary"` + Accent string `yaml:"accent"` + Background string `yaml:"background"` + // ... more colors +} +``` + +**Advantages**: + +- ✅ Easy to add new themes (just YAML file) +- ✅ No code changes for color updates +- ✅ IDE-agnostic (any editor can edit YAML) +- ✅ Version controllable +- ✅ Shareable across teams + +**Alternatives Considered**: + +- JSON: More verbose, no comments +- TOML: Less common, harder to nest +- Go structs: Requires recompile for changes + +**Decision**: Use YAML for themes/profiles/skins with `embed.FS` for defaults. + +--- + +## 2. Error Handling Patterns + +### Problem Statement + +Shell command execution in TUI requires: + +1. Non-blocking execution (don't freeze UI) +2. Stderr capture (shell errors are on stderr) +3. Exit code handling (distinguish success vs failure) +4. Timeout protection (don't wait forever) +5. Context cancellation (respect user quit) +6. Graceful display (show errors without breaking layout) + +### Research: Go Best Practices + +**Pattern 1: exec.CommandContext with timeout** + +```go +ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) +defer cancel() +cmd := exec.CommandContext(ctx, "docker", "ps") +``` + +✅ Respects context cancellation +✅ Built-in timeout +❌ No stderr separation by default + +**Pattern 2: Separate stdout/stderr buffers** + +```go +var stdout, stderr bytes.Buffer +cmd.Stdout = &stdout +cmd.Stderr = &stderr +err := cmd.Run() +``` + +✅ Clear separation +✅ Full output capture +❌ No streaming (waits for completion) + +**Pattern 3: Bubble Tea Cmd pattern** + +```go +func runCommand(name string, args ...string) tea.Cmd { + return func() tea.Msg { + result := executor.Run(context.Background(), name, args...) + return CommandResultMsg{Result: result} + } +} +``` + +✅ Async execution +✅ UI remains responsive +✅ Type-safe messages + +### ARC Implementation: 3-Layer System + +**Layer 1: Shell Executor** (`pkg/ui/shell/executor.go`) + +- Wraps exec.CommandContext +- Captures stdout, stderr separately +- Records exit code, duration +- Returns structured Result + +**Layer 2: Error Component** (`pkg/ui/component/error.go`) + +- `ErrorDisplay(ctx *theme.Context, err error, details string) string` +- `InlineError(ctx *theme.Context, msg string) string` +- Themed rendering (colored borders, icons) + +**Layer 3: Bubble Tea Messages** (`pkg/ui/engine/messages.go`) + +- `type ErrorMsg struct { Context, Err, Severity, Timestamp, Dismissible }` +- Shell handles ErrorMsg in Update() +- Views send ErrorMsg via tea.Cmd + +**Example Flow**: + +1. View calls `shell.Executor.Run("docker", "ps")` +2. Executor captures `stderr: "Cannot connect to Docker daemon"` +3. View returns `tea.Cmd` with `ErrorMsg` +4. Shell receives ErrorMsg, renders with `component.ErrorDisplay` +5. User sees themed error box, presses 'd' to dismiss + +### Alternatives Considered + +- **Global error handler**: Rejected (hidden side effects) +- **Panic/recover**: Rejected (TUI shouldn't panic) +- **Error channel**: Rejected (complex lifecycle) + +**Decision**: 3-layer system with Shell Executor + Error Component + ErrorMsg. + +--- + +## 3. Component Composition Patterns + +### Research: Charmbracelet Ecosystem + +**bubbles**: Reusable TUI components + +- `list`: Filterable list with delegates +- `table`: Data table with sorting +- `spinner`: Animated loading indicators +- `progress`: Progress bars +- `textinput`: Text input fields +- `viewport`: Scrollable content areas + +**lipgloss**: Layout and styling + +- `Style.Render()`: Apply colors, borders, padding +- `JoinHorizontal()`: Side-by-side layout +- `JoinVertical()`: Stacked layout +- `Place()`: Absolute positioning + +**huh**: Form library + +- Multi-step wizards +- Validation +- Themed inputs + +### Pattern: Stateless vs Stateful + +**Stateless** (render functions): + +```go +func Header(ctx *theme.Context, title, subtitle string) string { + style := lipgloss.NewStyle(). + Foreground(lipgloss.Color(ctx.Theme.Colors.Primary)). + Bold(true) + return style.Render(title) +} +``` + +✅ Pure function (testable) +✅ No lifecycle management +✅ Fast + +**Stateful** (Bubble Tea models): + +```go +type Spinner struct { + spinner bubbles.Spinner + ctx *theme.Context +} + +func (s Spinner) Update(msg tea.Msg) (Spinner, tea.Cmd) { + var cmd tea.Cmd + s.spinner, cmd = s.spinner.Update(msg) + return s, cmd +} +``` + +✅ Manages internal state +✅ Handles animations +❌ Requires Init/Update/View + +### ARC Strategy + +**Stateless** (10 components): + +- header, navigation, controlbar, table, card, panel, hero, badge, tree, error, markdown + +**Stateful** (7 components): + +- list, search, spinner, progress, form, viewport (all wrap bubbles/huh) + +**Wrapping Pattern**: + +```go +// pkg/ui/component/list.go +type List struct { + list bubbles.List + ctx *theme.Context +} + +func NewList(ctx *theme.Context, items []list.Item) List { + delegate := list.NewDefaultDelegate() + // Apply theme to delegate + delegate.Styles.SelectedTitle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(ctx.Theme.Colors.Primary)) + + l := list.New(items, delegate, 0, 0) + return List{list: l, ctx: ctx} +} + +func (l List) Update(msg tea.Msg) (List, tea.Cmd) { + var cmd tea.Cmd + l.list, cmd = l.list.Update(msg) + return l, cmd +} + +func (l List) View() string { + return l.list.View() +} +``` + +**Decision**: Wrap bubbles/huh components with thin themed adapters (~10-20 lines each). Reimplement only ARC-specific components (hero, navigation). + +--- + +## 4. Theme System Architecture + +### Research: YAML-based Theming + +**Examples in the wild**: + +- **gh-dash**: Uses YAML for themes, profiles separate +- **lazygit**: Single YAML with nested themes +- **k9s**: Skins directory with YAML per theme + +### ARC Design: Profile + Theme + Skin Separation + +**Profile** (branding): + +```yaml +id: cyberstart +name: CyberStart +tier_names: ["Trainee", "Associate", "Lead"] +logo: "cyberstart_ascii.txt" +theme_id: cyberstart +``` + +**Theme** (colors): + +```yaml +id: cyberstart +name: CyberStart Dark +colors: + primary: "#00B4D8" + secondary: "#0077B6" + # ... 8 more colors +``` + +**Skin** (layout): + +```yaml +id: gh-dash +name: GitHub Dashboard Style +navigation: + style: tab-bar # tab-bar | sidebar + position: top # top | left +borders: + style: rounded + width: normal +density: comfortable # compact | comfortable | spacious +``` + +### Loading Strategy + +**Phase 1**: Load from embedded FS + +```go +//go:embed embedded/themes/*.yaml +var themesFS embed.FS + +func LoadThemes() ([]Theme, error) { + files, _ := themesFS.ReadDir("embedded/themes") + for _, file := range files { + data, _ := themesFS.ReadFile("embedded/themes/" + file.Name()) + var theme Theme + yaml.Unmarshal(data, &theme) + themes = append(themes, theme) + } + return themes, nil +} +``` + +**Phase 2** (future): Override from user directory + +```go +// Check ~/.arc/themes/ for user themes +userThemes := xdg.ConfigHome() + "/arc/themes/" +``` + +### Style Caching + +**Problem**: Creating lipgloss.Style objects is expensive (allocations) + +**Solution**: Registry with memoization + +```go +// pkg/ui/theme/registry.go +type Registry struct { + cache map[string]lipgloss.Style + mu sync.RWMutex +} + +func (r *Registry) GetStyle(key string, builder func() lipgloss.Style) lipgloss.Style { + r.mu.RLock() + if style, ok := r.cache[key]; ok { + r.mu.RUnlock() + return style + } + r.mu.RUnlock() + + r.mu.Lock() + defer r.mu.Unlock() + style := builder() + r.cache[key] = style + return style +} +``` + +**Decision**: Use embed.FS for defaults, YAML for themes/profiles/skins, Registry for style caching. + +--- + +## 5. Router + View Lifecycle + +### Research: React-inspired Patterns + +**React lifecycle**: + +```javascript +componentDidMount(); // component enters DOM +componentWillUnmount(); // component leaves DOM +``` + +**ARC equivalent**: + +```go +OnEnter(ctx *ViewContext) tea.Cmd // view becomes active +OnExit() tea.Cmd // view deactivates +``` + +### The Critical Guarantee + +**Problem in old engine**: Views launched without OnEnter, components nil, blank screen. + +**Solution**: Router ALWAYS calls OnEnter before any View() render: + +```go +func (r *Router) Navigate(name string, args map[string]any) (View, tea.Cmd) { + // Exit current view + var exitCmd tea.Cmd + if r.current != nil { + exitCmd = r.current.OnExit() + } + + // Look up new view + view := r.views[name] + + // Build context + ctx := r.state.BuildViewContext(args) + + // GUARANTEE: OnEnter called before first render + enterCmd := view.OnEnter(ctx) + + r.current = view + return view, tea.Batch(exitCmd, enterCmd) +} +``` + +### Navigation Patterns + +**Tab-based** (dashboard mode): + +```go +case key.Matches(msg, keys.Tab): + return m, m.router.Navigate("services-list", nil) +``` + +**Focus-based** (single command): + +```go +// arc services list +func Execute(cmd *cobra.Command) error { + return engine.Start(engine.FocusedMode, "services-list", nil) +} +``` + +**JSON-based** (automation): + +```go +// arc services list --json +if jsonFlag { + data := catalog.ListServices() + json.NewEncoder(os.Stdout).Encode(data) + return nil +} +``` + +**Decision**: Router guarantees OnEnter before render. Support 3 launch modes: Dashboard, Focused, JSON. + +--- + +## Decisions Summary + +| Area | Decision | Rationale | +| ------------------ | ------------------------------------------- | ------------------------------------------- | +| Design Tokens | YAML-based themes/profiles/skins | Easy to edit, version control, no recompile | +| Error Handling | 3-layer system (Executor + Component + Msg) | Non-blocking, structured, themed display | +| Component Strategy | Wrap libraries, ~10-20 lines per wrapper | Leverage ecosystem, avoid reinventing | +| Theme Loading | embed.FS with YAML parsing | Fast startup, user overrides possible | +| Style Caching | Registry with sync.RWMutex | Avoid allocations, thread-safe | +| View Lifecycle | Router guarantees OnEnter before render | Prevents blank screen bug | +| Launch Modes | Dashboard, Focused, JSON | Supports interactive + automation | +| Performance | <100ms startup, <16ms nav, <20MB memory | Production-grade responsiveness | + +--- + +## Open Questions + +1. ~~Should we support 3 skins initially?~~ → **Resolved**: Start with 2 (gh-dash, minimal) +2. ~~Do we need golden tests for all 200 combinations?~~ → **See data-model.md for test matrix** +3. ~~Which commands require --json mode?~~ → **See plan.md JSON Mode Coverage Matrix** + +--- + +## References + +- [Salesforce Design Tokens](https://www.lightningdesignsystem.com/design-tokens/) +- [Charmbracelet Bubbles](https://github.com/charmbracelet/bubbles) +- [Go exec package](https://pkg.go.dev/os/exec) +- [gh-dash source](https://github.com/dlvhdr/gh-dash) +- [Bubble Tea Tutorial](https://github.com/charmbracelet/bubbletea/tree/master/tutorials) + +--- + +_This document is a living artifact. Update during implementation as new patterns emerge._ diff --git a/specs/018-ui-design/spec.md b/specs/018-ui-design/spec.md new file mode 100644 index 0000000..5d9acf0 --- /dev/null +++ b/specs/018-ui-design/spec.md @@ -0,0 +1,1499 @@ +# A.R.C. CLI v2 — UI Design & Implementation Plan + +> **Date**: March 3, 2026 +> **Branch**: `018-ui-rewrite` +> **Status**: FINAL — Aligned and ready for implementation +> **Approach**: Same repo, git worktree, rebuild UI from scratch, keep backend + +--- + +## Table of Contents + +1. [Decisions Summary](#1-decisions-summary) +2. [Architecture Overview](#2-architecture-overview) +3. [Engine Design — The Shell](#3-engine-design--the-shell) +4. [Component System](#4-component-system) +5. [Design System — React for CLI](#5-design-system--react-for-cli) +6. [Theme and Skin System](#6-theme-and-skin-system) +7. [State Management](#7-state-management) +8. [Command Strategy](#8-command-strategy) +9. [Folder Structure](#9-folder-structure) +10. [Linting Constitution](#10-linting-constitution) +11. [Testing Strategy](#11-testing-strategy) +12. [Phase-by-Phase Implementation](#12-phase-by-phase-implementation) +13. [Task Breakdown](#13-task-breakdown) +14. [Appendix A: Discussion & Alignment Notes](#appendix-a-discussion--alignment-notes) + +--- + +## 1. Decisions Summary + +These are locked in. No more discussion on these — we build. + +| Decision | Choice | Notes | +| ------------------ | ------------------------------- | ------------------------------------------------------------ | +| Language | **Go** | Keep existing backend, Charmbracelet ecosystem | +| Repo strategy | **Same repo, worktree** | `git worktree add ../arc-cli-v2 -b 018-ui-rewrite` | +| Engine | **Fix and improve**, not delete | React-inspired Shell + Router + Views | +| `arc` (bare) | **Opens dashboard** | Full interactive TUI, Home tab active | +| `arc ` | **Focused TUI view** | Rich interactive view for that command | +| `arc --json` | **JSON output** | Scripting/piping mode, no TUI | +| Skins | **Level 2+** | Theme + Layout Variant, interface designed for Level 3 later | +| Workspace prefs | **Later** | Global first, interface ready for workspace scope | +| Components | **Consolidate** | One implementation per concept, factory pattern kept | +| Linting | **Keep 30, smart exclusions** | Path-based exclusions for UI layer | +| Testing | **Interfaces first** | Backend contracts + golden files for key components | +| Priority | **C then D then A then B** | Components, Theme, Dashboard, All commands | +| Control bar | **Unified, 3 bars** | Header + Navigation + Control bar, shared across all views | +| Commands | **Most become views** | Reduce standalone commands, show as dashboard tabs/views | + +--- + +## 2. Architecture Overview + +### The Big Picture + +``` ++------------------------------------------------------------------+ +| arc binary | ++------------------------------------------------------------------+ +| | +| +---------------+ +--------------------------------------+ | +| | Cobra CLI |--->| UI Engine (Shell) | | +| | (minimal) | | | | +| | | | +----------+ +------------------+ | | +| | arc | | | Header | | State Manager | | | +| | arc services | | | TabBar | | (profile, theme, | | | +| | arc --json | | | ViewArea | | skin, workspace)| | | +| | | | | Controls | | | | | +| +-------+-------+ | +----------+ +------------------+ | | +| | | | | +| | | +----------------------------------+| | +| | | | Router + View Registry || | +| | | | home | services | workspace | || | +| | | | config | info | init | theme || | +| | | +----------------------------------+| | +| | +--------------------------------------+ | +| | | | +| | +-------------v--------------+ | +| | | Component Library | | +| | | table | card | hero | tree | | +| | | badge | panel | search | | +| | | spinner | progress | form | | +| | +-------------+--------------+ | +| | | | +| | +-------------v--------------+ | +| | | Theme + Skin System | | +| | | colors | styles | layout | | +| | | profiles | skins (YAML) | | +| | +----------------------------+ | +| | | +| +-------v----------------------------------------------------+ | +| | Backend Services | | +| | catalog | workspace | store | scaffold | config | log | | +| | (UNCHANGED) | | +| +-------------------------------------------------------------+ | ++-------------------------------------------------------------------+ +``` + +### Data Flow + +``` +User types "arc" or "arc services list" + | + v +Cobra parses command + flags + | + +-- --json flag? --> Backend fetch -> JSON stdout -> exit + | + +-- TUI mode --> Engine.Start(mode) + | + +-- mode = "dashboard" (bare "arc") + | -> Shell with all tabs, Home active + | + +-- mode = "focused:services-list" ("arc services list") + -> Shell with single view, no tab bar + -> q to quit back to terminal +``` + +### Two Launch Modes + +The engine supports two modes — this is how we satisfy Option C: + +| Mode | Triggered by | Tab bar visible | Back to terminal on q | +| ------------- | ------------------- | ---------------- | --------------------- | +| **Dashboard** | `arc` (bare) | Yes — all tabs | Yes | +| **Focused** | `arc services list` | No — single view | Yes | + +Both modes share the same Shell (header + control bar). The only difference is whether the tab bar renders and whether the router allows navigation. This means: + +- Components are identical in both modes +- Theme/skin applies to both +- The engine is ONE Bubble Tea program, not two separate systems + +--- + +## 3. Engine Design — The Shell + +### Mental Model: React for the Terminal + +Think of the Shell as a React App component: + +``` + <- The persistent frame (Bubble Tea model) +
<- Brand, workspace, profile — always visible + <- Tab bar OR sidebar (depends on skin) + <- The area that changes + <- Whatever view the router says is current + + <- Keybindings from active view + profile info + +``` + +### Shell Struct (the one and only Bubble Tea model) + +```go +// Shell is the root Bubble Tea model. There is exactly ONE Shell +// per arc invocation. It owns the persistent frame and delegates +// content rendering to the active View. +type Shell struct { + // Engine internals + router *Router + state *StateManager + mode LaunchMode // Dashboard or Focused + + // Persistent frame components + header *component.Header + navigation *component.Navigation + controlBar *component.ControlBar + + // Dimensions + width int + height int + + // The active view (set by router) + activeView View +} +``` + +### Shell Lifecycle + +``` +Shell.Init() + +-- Load state (profile, theme, skin, workspace) + +-- Initialize persistent components (header, nav, controlbar) + +-- Router.Navigate(initialRoute) <- "home" for dashboard, target for focused + +-- Return tea.WindowSize command + +Shell.Update(msg) + +-- tea.WindowSizeMsg -> update dimensions, propagate to activeView + +-- tea.KeyMsg + | +-- Global keys (q/ctrl+c = quit, tab = next tab, shift+tab = prev) + | +-- Everything else -> delegate to activeView.Update(msg) + +-- NavigateMsg -> Router.Navigate(target), call OnExit/OnEnter + +-- StateChangedMsg -> reload theme/skin, re-render persistent frame + +Shell.View() + +-- header.Render(width, state) + +-- navigation.Render(width, activeTab) <- only in Dashboard mode + +-- activeView.View() <- the content area + +-- controlBar.Render(width, activeView.Keybindings()) +``` + +### View Interface (simplified from current) + +```go +// View is a pluggable content panel. It receives context via OnEnter, +// handles its own key events, and returns a rendered string from View(). +type View interface { + // Init is called once when the view is first created. + Init() tea.Cmd + + // Update handles key events and messages delegated from the Shell. + Update(msg tea.Msg) (View, tea.Cmd) + + // View renders the content area. Pure string composition, no I/O. + View() string + + // OnEnter is called by the Router when this view becomes active. + // The Shell GUARANTEES this is called before the first View(). + OnEnter(ctx *ViewContext) tea.Cmd + + // OnExit is called when navigating away. Cleanup resources. + OnExit() tea.Cmd + + // Name returns the unique identifier (e.g., "services-list", "home"). + Name() string + + // Keybindings returns keyboard shortcuts for the ControlBar to display. + Keybindings() []KeyBinding +} +``` + +### The Critical Fix: Shell calls OnEnter() automatically + +```go +// Router.Navigate — called by Shell, ALWAYS calls OnEnter +func (r *Router) Navigate(name string, args map[string]any) (View, tea.Cmd) { + // Exit current view + var exitCmd tea.Cmd + if r.current != nil { + exitCmd = r.current.OnExit() + } + + // Look up target view + view, ok := r.views[name] + if !ok { + return r.current, nil + } + + // Build context from state manager + ctx := r.state.BuildViewContext(args) + + // THIS IS THE FIX — OnEnter always called before any render + enterCmd := view.OnEnter(ctx) + + r.current = view + return view, tea.Batch(exitCmd, enterCmd) +} +``` + +This is the single most important fix. The current engine launches views without calling OnEnter, so components are nil, View() returns empty string, terminal goes black. + +In the new engine, Navigate() always calls OnEnter() before any render happens. It is impossible to see a blank view. + +### ViewContext (props passed to views) + +```go +// ViewContext is the props object passed to every view on OnEnter. +type ViewContext struct { + // Theming + Theme *theme.Theme + Profile *theme.Profile + Skin *theme.Skin + + // Dimensions (content area only — Shell already subtracted frame) + Width int + Height int + + // Route parameters (e.g., {"serviceName": "postgres"}) + Args map[string]any + + // Backend services (so views can fetch data) + Catalog catalog.Catalog + Workspace *workspace.Manager + Store *store.Store + + // State reference (for views that need to trigger state changes) + State *StateManager +} +``` + +ViewContext includes backend services directly. Views don't need to go through app.Context. The engine bridges that gap. This is the clean interface boundary between backend and UI. + +--- + +## 4. Component System + +### Philosophy + +1. **One component, one file, one package** — `pkg/ui/component/` +2. **Stateless by default** — render functions that take theme + data, return string +3. **Stateful when needed** — spinners, progress, search are structs with Update() +4. **Theme-aware always** — every component receives `*theme.Context` +5. **Use libraries** — wrap bubbles/huh/lipgloss/glamour, don't rewrite them + +### Component Inventory (17 total, down from 30+) + +| Component | Type | Library Used | Purpose | +| --------------- | --------- | ------------------------ | ------------------------------------ | +| `header.go` | Render fn | lipgloss | Top bar: brand + workspace + profile | +| `navigation.go` | Render fn | lipgloss | Tab bar or sidebar (skin-dependent) | +| `controlbar.go` | Render fn | lipgloss | Bottom bar: keybindings + context | +| `table.go` | Render fn | lipgloss + bubbles/table | Themed data tables | +| `card.go` | Render fn | lipgloss | Bordered card with title + body | +| `panel.go` | Render fn | lipgloss | Bordered content panel | +| `hero.go` | Render fn | lipgloss | ASCII logo + profile branding | +| `badge.go` | Render fn | lipgloss | Colored status badges | +| `tree.go` | Render fn | lipgloss | Dependency tree visualization | +| `error.go` | Render fn | lipgloss | Themed error boxes & inline errors | +| `list.go` | Struct | bubbles/list | Interactive filterable list | +| `search.go` | Struct | bubbles/textinput | Search input with fuzzy matching | +| `spinner.go` | Struct | bubbles/spinner | Animated loading indicator | +| `progress.go` | Struct | bubbles/progress | Progress bar | +| `form.go` | Struct | huh | Multi-step form wizard | +| `viewport.go` | Struct | bubbles/viewport | Scrollable content area | +| `markdown.go` | Render fn | glamour | Render markdown in terminal | + +### Key Principle: Wrap Libraries, Don't Rewrite + +The old codebase has 338-line tab_bar.go, 266-line progress.go, 406-line status_rail.go all reimplemented from scratch. In the new system: + +- `List` wraps `bubbles/list` (10-20 lines of glue code) +- `Spinner` wraps `bubbles/spinner` (10 lines) +- `Progress` wraps `bubbles/progress` (10 lines) +- `Form` wraps `huh` (thin adapter) +- `Viewport` wraps `bubbles/viewport` (thin adapter) + +Our custom code focuses only on ARC-unique components: header, navigation, controlbar, hero, badge, tree. + +### Error Handling & Shell Command Execution + +**Problem**: When views execute shell commands or backend operations, errors need to be captured, wrapped, and displayed gracefully in the TUI. We need a unified strategy to handle errors across all components and views. + +**Solution**: A three-layer error handling system: + +1. **Shell Command Wrapper** — `pkg/ui/shell/executor.go` +2. **Error Component** — `pkg/ui/component/error.go` +3. **Error Messages** — Bubble Tea messages for async errors + +#### Layer 1: Shell Command Executor (Wrapper) + +All shell command execution goes through a single wrapper that captures stdout, stderr, and exit codes: + +```go +// pkg/ui/shell/executor.go +package shell + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "time" +) + +// Result wraps the output of a shell command execution. +type Result struct { + Command string + Stdout string + Stderr string + ExitCode int + Duration time.Duration + Err error +} + +// Executor provides a safe wrapper for executing shell commands +// with timeout, cancellation, and error capture. +type Executor struct { + timeout time.Duration +} + +// NewExecutor creates a new shell command executor with default 30s timeout. +func NewExecutor() *Executor { + return &Executor{timeout: 30 * time.Second} +} + +// Run executes a shell command and captures all output. +// Returns a Result with stdout, stderr, exit code, and any errors. +func (e *Executor) Run(ctx context.Context, name string, args ...string) Result { + start := time.Now() + + // Create command with timeout context + timeoutCtx, cancel := context.WithTimeout(ctx, e.timeout) + defer cancel() + + cmd := exec.CommandContext(timeoutCtx, name, args...) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + result := Result{ + Command: fmt.Sprintf("%s %v", name, args), + } + + // Execute + err := cmd.Run() + result.Stdout = stdout.String() + result.Stderr = stderr.String() + result.Duration = time.Since(start) + + // Capture exit code + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + result.ExitCode = exitErr.ExitCode() + } else { + // Command failed to start or was killed + result.Err = err + result.ExitCode = -1 + } + } + + return result +} + +// IsSuccess returns true if the command executed successfully (exit code 0). +func (r Result) IsSuccess() bool { + return r.ExitCode == 0 && r.Err == nil +} + +// ErrorMessage returns a formatted error message for display in UI. +func (r Result) ErrorMessage() string { + if r.IsSuccess() { + return "" + } + + if r.Err != nil { + return fmt.Sprintf("Command failed: %s\n%v", r.Command, r.Err) + } + + return fmt.Sprintf( + "Command exited with code %d: %s\nStderr: %s", + r.ExitCode, + r.Command, + r.Stderr, + ) +} +``` + +#### Layer 2: Error Display Component + +A themed error component that shows errors with context, severity, and optional actions: + +```go +// pkg/ui/component/error.go +package component + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/yourorg/arc/pkg/ui/theme" +) + +// ErrorLevel defines the severity of an error. +type ErrorLevel int + +const ( + ErrorLevelInfo ErrorLevel = iota + ErrorLevelWarning + ErrorLevelError + ErrorLevelCritical +) + +// ErrorDisplay renders a themed error message box. +func ErrorDisplay(tc *theme.Context, level ErrorLevel, title, message string, details []string) string { + colors := tc.Theme().Colors + + // Select icon and color based on level + var icon string + var borderColor lipgloss.Color + + switch level { + case ErrorLevelInfo: + icon = tc.Tokens().Icons.Info + borderColor = colors.Info + case ErrorLevelWarning: + icon = tc.Tokens().Icons.Warning + borderColor = colors.Warning + case ErrorLevelError: + icon = tc.Tokens().Icons.Error + borderColor = colors.Error + case ErrorLevelCritical: + icon = tc.Tokens().Icons.Cross + borderColor = colors.Error + } + + // Build header + header := lipgloss.NewStyle(). + Foreground(borderColor). + Bold(true). + Render(fmt.Sprintf("%s %s", icon, title)) + + // Build message body + body := lipgloss.NewStyle(). + Foreground(colors.Foreground). + Render(message) + + // Build details section if provided + var detailsSection string + if len(details) > 0 { + detailLines := make([]string, len(details)) + for i, detail := range details { + detailLines[i] = lipgloss.NewStyle(). + Foreground(colors.Muted). + Render(fmt.Sprintf(" • %s", detail)) + } + detailsSection = "\n\n" + strings.Join(detailLines, "\n") + } + + // Compose the error box + content := lipgloss.JoinVertical( + lipgloss.Left, + header, + "", + body, + detailsSection, + ) + + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Padding(1, 2). + Width(60). + Render(content) +} + +// InlineError renders a compact single-line error for tables/lists. +func InlineError(tc *theme.Context, message string) string { + colors := tc.Theme().Colors + icon := tc.Tokens().Icons.Error + + return lipgloss.NewStyle(). + Foreground(colors.Error). + Render(fmt.Sprintf("%s %s", icon, message)) +} +``` + +#### Layer 3: Async Error Messages + +For operations that run asynchronously (like workspace initialization, service health checks), use Bubble Tea messages: + +```go +// pkg/ui/engine/messages.go +package engine + +// ErrorMsg carries error information from async operations to views. +type ErrorMsg struct { + Context string // What was being done (e.g., "Starting PostgreSQL") + Err error // The actual error + Severity ErrorLevel + Timestamp time.Time + Dismissible bool // Can user dismiss this error? +} + +// View handles ErrorMsg in Update(): +func (v *ServicesView) Update(msg tea.Msg) (View, tea.Cmd) { + switch msg := msg.(type) { + case ErrorMsg: + // Store error in view state + v.lastError = msg + return v, nil + case tea.KeyMsg: + if msg.String() == "d" && v.lastError != nil && v.lastError.Dismissible { + // Dismiss error + v.lastError = nil + } + } + // ... rest of update +} + +// In View(), render the error if present: +func (v *ServicesView) View() string { + var sections []string + + // Show error banner if present + if v.lastError != nil { + errorBox := component.ErrorDisplay( + v.themeCtx, + v.lastError.Severity, + v.lastError.Context, + v.lastError.Err.Error(), + []string{ + v.lastError.Timestamp.Format("15:04:05"), + "Press 'd' to dismiss", + }, + ) + sections = append(sections, errorBox) + } + + // Rest of view content + sections = append(sections, v.renderContent()) + + return lipgloss.JoinVertical(lipgloss.Left, sections...) +} +``` + +#### Usage Example: Workspace Init View + +```go +// pkg/ui/view/workspace_init.go +func (v *WorkspaceInitView) runInitCommand(path string) tea.Cmd { + return func() tea.Msg { + executor := shell.NewExecutor() + result := executor.Run( + context.Background(), + "arc-workspace-init", + "--path", path, + ) + + if !result.IsSuccess() { + return ErrorMsg{ + Context: "Initializing workspace", + Err: fmt.Errorf(result.ErrorMessage()), + Severity: ErrorLevelError, + Timestamp: time.Now(), + Dismissible: true, + } + } + + return WorkspaceInitSuccessMsg{Path: path} + } +} +``` + +#### Error Component in Inventory + +Add to the component inventory table: + +| Component | Type | Library Used | Purpose | +| ---------- | --------- | ------------ | ---------------------------------- | +| `error.go` | Render fn | lipgloss | Themed error boxes & inline errors | + +#### Integration Points + +1. **Views**: All views that execute commands use `shell.Executor` +2. **State Manager**: Backend errors (catalog load, config parse) converted to `ErrorMsg` +3. **Shell**: Command execution errors automatically wrapped +4. **Components**: Error component used by all views for consistent error display + +#### Error Handling Philosophy + +- **Never panic** — all errors bubble up as messages +- **Context first** — always show _what_ was being done when error occurred +- **Actionable** — suggest next steps or dismiss option +- **Themed** — errors match the current theme/profile +- **Non-blocking** — errors don't freeze the UI, views remain interactive + +--- + +## 5. Design System — React for CLI + +> Think React components for the terminal. Every component has padding, margin, color, font, typography, emoji — all controlled by a centralized design system. +> Inspired by **gh-dash** — delightful, keyboard-driven, composable boxes. + +### Design Tokens (The CSS Variables) + +Everything flows from one struct — the single source of truth: + +```go +type DesignTokens struct { + // Colors + Primary, Secondary, Success, Error, Warning, Info lipgloss.Color + Text, TextMuted, TextInverse lipgloss.Color + BgBase, BgSurface, BgHighlight lipgloss.Color + Border, BorderFocus lipgloss.Color + + // Spacing + PaddingX, PaddingY, MarginX, MarginY, Gap int + + // Typography + Bold, Dim, Italic func(s string) string + + // Icons + Icons IconSet +} + +type IconSet struct { + Success, Error, Warning, Info string + Arrow, Bullet, Star string + Folder, File, Check, Cross string +} +``` + +### Default Theme (Dark — gh-dash Inspired) + +```go +var DefaultTokens = DesignTokens{ + Primary: "#7C3AED", + Secondary: "#A78BFA", + Success: "#34D399", + Error: "#F87171", + Warning: "#FBBF24", + Info: "#60A5FA", + Text: "#E4E4E7", + TextMuted: "#71717A", + TextInverse: "#18181B", + BgBase: "#09090B", + BgSurface: "#18181B", + BgHighlight: "#27272A", + Border: "#3F3F46", + BorderFocus: "#7C3AED", + + PaddingX: 2, Gap: 1, + Icons: IconSet{ + Success: "✓", Error: "✗", Warning: "⚠", + Info: "●", Arrow: "→", Bullet: "•", Star: "★", + Folder: "📁", File: "📄", Check: "✔", Cross: "✘", + }, +} +``` + +### Style Factory (Like React `styled-components`) + +```go +type Styles struct { + tokens DesignTokens + Page, Section, SectionHeader, SectionBody lipgloss.Style + Title, Subtitle, Body, Muted, Label, Value lipgloss.Style + StatusSuccess, StatusError, StatusWarning, StatusInfo lipgloss.Style + TableHeader, TableRow, TableRowAlt, TableCell lipgloss.Style + Prompt, Input, Selected, HelpKey, HelpValue lipgloss.Style + Card, Banner, Divider lipgloss.Style +} + +func NewStyles(t DesignTokens) Styles { + return Styles{ + tokens: t, + Page: lipgloss.NewStyle(). + Padding(t.PaddingY, t.PaddingX). + Margin(t.MarginY, t.MarginX), + Section: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.Border). + Padding(t.PaddingY, t.PaddingX). + MarginBottom(t.Gap), + // ... more styles + } +} +``` + +### Component Implementations + +Every component is a **pure function** — takes data + styles, returns a string. + +```go +func Banner(s Styles, emoji string, title string, subtitle string) string { + t := s.tokens + titleLine := fmt.Sprintf("%s %s", emoji, s.Title.Render(title)) + subtitleLine := s.Muted.Render(subtitle) + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.Primary). + Padding(1, 2). + MarginBottom(t.Gap). + Render(lipgloss.JoinVertical(lipgloss.Left, titleLine, subtitleLine)) +} + +func StatusBadge(s Styles, status string) string { + t := s.tokens + switch status { + case "success", "active", "running": + return s.StatusSuccess.Render(t.Icons.Success + " " + status) + case "error", "failed", "terminated": + return s.StatusError.Render(t.Icons.Error + " " + status) + default: + return s.StatusInfo.Render(t.Icons.Info + " " + status) + } +} + +func Section(s Styles, title string, content string) string { + header := s.SectionHeader.Render(title) + body := s.SectionBody.Render(content) + return s.Section.Render(lipgloss.JoinVertical(lipgloss.Left, header, body)) +} + +func HelpBar(s Styles, bindings [][]string) string { + parts := make([]string, len(bindings)) + for i, b := range bindings { + parts[i] = s.HelpKey.Render(b[0]) + " " + s.HelpValue.Render(b[1]) + } + return lipgloss.JoinHorizontal(lipgloss.Top, parts...) +} +``` + +### How It All Connects + +``` +arc.yaml → profile.Theme = "default" + → tokens := themes["default"] // DesignTokens struct + → styles := NewStyles(tokens) // Styles struct + → components.Banner(styles, ...) + → components.Table(styles, ...) +``` + +**One theme → one tokens struct → one styles struct → passed to every component.** +No globals. Like React context. + +### Design System Component Inventory + +| Component | Type | Description | Stateless? | +| ----------- | --------- | ------------------------ | ---------- | +| Banner | Container | App header | ✅ | +| Section | Container | Bordered box | ✅ | +| StatusBadge | Inline | Colored status with icon | ✅ | +| HelpBar | Control | Footer with keybindings | ✅ | +| Table | Data | Column-aligned data | ✅ | +| Card | Container | Bordered card | ✅ | +| ProgressBar | Feedback | Animated progress | ❌ | +| List | Input | Selection list | ❌ | + +--- + +## 6. Theme and Skin System + +### New Design: One Package `pkg/ui/theme/` + +Replaces the current 4 packages (themes, profiles, styles, factory). + +```go +package theme + +// Context is the single source of truth for all visual styling. +// Every component receives this. +type Context struct { + profile *Profile + theme *Theme + skin *Skin + registry *Registry // Cached lipgloss.Style objects +} + +// Profile defines branding: logo, tier names, identity. +type Profile struct { + ID string + Name string + Description string + TierNames [3]string + ThemeID string + Logo string +} + +// Theme defines colors. +type Theme struct { + ID string + Name string + Colors ColorSet +} + +// ColorSet is the full color palette. +type ColorSet struct { + Primary lipgloss.Color + Secondary lipgloss.Color + Accent lipgloss.Color + Success lipgloss.Color + Warning lipgloss.Color + Error lipgloss.Color + Info lipgloss.Color + Muted lipgloss.Color + Background lipgloss.Color + Foreground lipgloss.Color + Border lipgloss.Color + HeaderBg lipgloss.Color + HeaderFg lipgloss.Color +} + +// Skin defines layout rules (Level 2). +type Skin struct { + ID string + Name string + Layout LayoutType // sidebar-left | tabs-top | minimal + Navigation NavType // sidebar | tab-bar | breadcrumb + Borders BorderType // rounded | sharp | none | half-block + Density Density // compact | comfortable | spacious +} +``` + +### Skin YAML Examples + +```yaml +# gh-dash.yaml +id: gh-dash +name: "GitHub Dashboard" +layout: sidebar-left +navigation: sidebar +borders: rounded +density: comfortable + +# minimal.yaml +id: minimal +name: "Minimal" +layout: full-width +navigation: tab-bar +borders: none +density: compact +``` + +### How Skin Affects Rendering + +Components don't know about skins — they read values from theme.Context: + +```go +// component/navigation.go — the ONE place skin layout matters +func Navigation(tc *theme.Context, tabs []Tab, activeIdx int, width int) string { + switch tc.Skin().Navigation { + case NavSidebar: + return renderSidebar(tc, tabs, activeIdx, width) + case NavTabBar: + return renderTabBar(tc, tabs, activeIdx, width) + } +} +``` + +All other components are skin-agnostic. They just use colors and borders from Context. + +### Level 3 Ready: The Interface + +```go +// Renderer interface for future Level 3 skins. +// Right now, only DefaultRenderer exists. +type Renderer interface { + RenderTable(tc *Context, headers []string, rows [][]string) string + RenderCard(tc *Context, title, body string) string + RenderHero(tc *Context, width int) string +} +``` + +We don't build this now. But components go through theme.Context, so swapping to a Renderer interface later is a one-line change, not a rewrite. + +--- + +## 7. State Management + +### StateManager + +```go +// StateManager is the brain. It loads preferences, resolves the active +// profile/theme/skin, and builds ViewContext for views. +// Initialized ONCE during bootstrap (not in init()). +type StateManager struct { + prefs *preferences.Preferences + profile *theme.Profile + themeCtx *theme.Context + catalog catalog.Catalog + workspace *workspace.Manager + store *store.Store +} + +func (sm *StateManager) BuildViewContext(args map[string]any) *ViewContext +func (sm *StateManager) ChangeProfile(profileID string) tea.Cmd +func (sm *StateManager) ChangeTheme(themeID string) tea.Cmd +func (sm *StateManager) ChangeSkin(skinID string) tea.Cmd +``` + +### Bootstrap (replaces init() side effects) + +```go +// internal/app/bootstrap.go — called ONCE from main.go +func Bootstrap() (*app.Context, error) { + cfg, _ := config.Load() + prefs, _ := preferences.Load() + tc, _ := theme.LoadFromPreferences(prefs) + cat := catalog.NewEmbedded() + store := store.New(xdg.DataDir()) + state := engine.NewStateManager(prefs, tc, cat, store) + return &app.Context{Config: cfg, State: state, Logger: log.New(cfg.LogLevel)}, nil +} +``` + +No global variables. No init(). No lazy loading. Just a function. + +### Workspace-Scoped Preferences (interface ready, built later) + +```go +// PreferenceProvider interface — global for now, workspace-scoped later +type PreferenceProvider interface { + ProfileID() string + ThemeID() string + SkinID() string +} + +// GlobalPreferences implements PreferenceProvider (current) +// WorkspacePreferences implements PreferenceProvider (future) +``` + +--- + +## 8. Command Strategy + +### New Command Tree + +``` +arc -> Dashboard (Home tab) +arc dashboard -> Dashboard (explicit alias) +arc init -> Init wizard (huh form, focused mode) +arc workspace init [path] -> Workspace wizard (huh form, focused mode) +arc workspace run -> Workspace runner (progress view, focused) +arc workspace info -> Workspace info (focused TUI or --json) +arc services list -> Services list (focused TUI or --json) +arc version -> Version info (focused TUI or --json) +arc completion -> Shell completion (stdout, no TUI) +arc help -> Help text (stdout, no TUI) +``` + +### What Moved Into Dashboard Tabs + +| Old Command | New Location | +| -------------------------------------- | -------------------------- | +| `arc info` | Dashboard -> Home tab | +| `arc services list/info/deps/ports` | Dashboard -> Services tab | +| `arc theme list/set/preview` | Dashboard -> Config tab | +| `arc config list-profiles/set-profile` | Dashboard -> Config tab | +| `arc workspace info/history` | Dashboard -> Workspace tab | + +--- + +## 9. Folder Structure + +``` +arc-cli/ +|-- cmd/arc/ +| +-- main.go # Entry: bootstrap -> root -> execute +| +|-- internal/ # Private packages (KEPT, minimal changes) +| |-- app/ +| | |-- context.go # Slimmed: Config + State + Logger +| | +-- bootstrap.go # NEW: explicit init, replaces init() +| |-- config/ # KEPT +| |-- preferences/ # KEPT (add skin_id field) +| |-- terminal/ # KEPT +| |-- xdg/ # KEPT +| +-- version/ # KEPT +| +|-- pkg/ +| |-- catalog/ # KEPT ENTIRELY +| |-- workspace/ # KEPT ENTIRELY +| |-- store/ # KEPT ENTIRELY +| |-- scaffold/ # KEPT ENTIRELY +| |-- log/ # KEPT ENTIRELY +| |-- version/ # KEPT ENTIRELY +| | +| |-- ui/ # REBUILT +| | |-- theme/ # Theme + Profile + Skin (merged) +| | | |-- context.go +| | | |-- theme.go +| | | |-- profile.go +| | | |-- skin.go +| | | |-- loader.go +| | | |-- registry.go +| | | +-- embedded/ +| | | |-- themes/ # 10 theme YAMLs +| | | |-- profiles/ # 10 profile YAMLs +| | | +-- skins/ # gh-dash.yaml, minimal.yaml +| | | +| | |-- component/ # ALL components (ONE location) +| | | |-- header.go +| | | |-- navigation.go +| | | |-- controlbar.go +| | | |-- table.go +| | | |-- card.go +| | | |-- panel.go +| | | |-- hero.go +| | | |-- badge.go +| | | |-- tree.go +| | | |-- error.go # NEW: Error display component +| | | |-- list.go +| | | |-- search.go +| | | |-- spinner.go +| | | |-- progress.go +| | | |-- form.go +| | | |-- viewport.go +| | | +-- markdown.go +| | | +| | |-- shell/ # NEW: Shell command execution +| | | +-- executor.go # Command wrapper with error capture +| | | +| | |-- engine/ # Shell + Router + State +| | | |-- shell.go +| | | |-- router.go +| | | |-- state.go +| | | |-- view.go +| | | |-- context.go +| | | |-- launch.go +| | | |-- messages.go # NEW: ErrorMsg and other messages +| | | +-- keys.go +| | | +| | +-- view/ # View implementations +| | |-- home.go +| | |-- services_list.go +| | |-- service_detail.go +| | |-- workspace_info.go +| | |-- workspace_history.go +| | |-- workspace_run.go +| | |-- config_overview.go +| | |-- version.go +| | +-- init_wizard.go +| | +| +-- cli/ # Cobra commands (THIN layer) +| |-- root.go +| |-- services.go +| |-- workspace.go +| |-- version.go +| |-- init.go +| +-- completion.go +| +|-- testdata/golden/ +|-- tests/ +| |-- integration/ +| +-- component/ +| +|-- .golangci.yml +|-- Makefile +|-- go.mod ++-- go.sum +``` + +**Dependency direction (one-way, no cycles):** + +``` +cli -> engine -> view -> component -> theme + | | | + | | +-> shell (executor) + | | + | +-> shell (executor) + | + (bubbles, huh, lipgloss, glamour) +``` + +**Notes:** + +- Views and Engine can use shell.Executor directly +- Components are pure rendering functions (no shell access) +- All error handling flows through ErrorMsg to views + +--- + +## 10. Linting Constitution + +Keep all 30 linters. Add path-based exclusions for UI: + +```yaml +issues: + exclude-rules: + # Existing test exclusions (KEEP all) + - path: _test\.go + linters: + [ + gosec, + errcheck, + dupl, + funlen, + gocyclo, + cyclop, + nestif, + goconst, + gocritic, + revive, + unparam, + nakedret, + prealloc, + ] + + # NEW: UI views — Bubble Tea boilerplate is structurally identical + - path: pkg/ui/view/ + linters: [dupl] + + # NEW: Engine — complex Update() switch statements are inherent + - path: pkg/ui/engine/ + linters: [gocyclo, cyclop] + + # NEW: Components — lipgloss API passes types by value + - path: pkg/ui/component/ + linters: [gocritic] + text: "hugeParam" +``` + +Backend stays enterprise-strict. Zero `//nolint` target for all new code. + +--- + +## 11. Testing Strategy + +| Layer | What to Test | How | When | +| --------------- | -------------------------- | --------------------- | ----------- | +| **Backend** | catalog, workspace, store | Unit tests + mocks | Must have | +| **Theme** | Profile/theme/skin loading | Unit tests | Must have | +| **Components** | Table, card, hero output | Golden file snapshots | Should have | +| **Engine** | Shell lifecycle, router | Headless Bubble Tea | Later | +| **Views** | Full view rendering | Golden files | Later | +| **Integration** | `arc services list --json` | CLI execution tests | Later | + +--- + +## 12. Phase-by-Phase Implementation + +### Phase 1: Foundation (Week 1-2) + +Build theme system + component library + engine shell + error handling. + +- Week 1: `pkg/ui/theme/` (context, theme, profile, skin, loader, registry) +- Week 1: `pkg/ui/component/` first batch (header, controlbar, navigation, table, card) +- Week 1: `pkg/ui/shell/` (executor for shell command wrapping) +- Week 2: `pkg/ui/component/` remaining (hero, badge, tree, error, list, spinner, viewport, etc.) +- Week 2: `pkg/ui/engine/` (shell, router, state, view interface, launch, messages) +- Week 2: `internal/app/bootstrap.go` — kill init() side effects + +**Milestone**: `arc` opens empty shell with themed header + tabs + controlbar. `q` quits. + +### Phase 2: Theme + Skin System (Week 3) + +Wire profile/theme/skin switching live. + +- StateManager.ChangeProfile() — atomic switch +- StateChangedMsg handling in Shell — re-render frame +- Skin rendering in navigation (sidebar vs tab-bar) +- Placeholder Config view for profile switching + +**Milestone**: Change profile -> entire UI updates live (colors, borders, logo, layout). + +### Phase 3: Dashboard — Home + Services (Week 4) + +First two real tabs with error handling integrated. + +- view/home.go — Hero + quick actions + system info +- view/services_list.go — Catalog table with search/filter + error display +- view/service_detail.go — Service info card + deps tree +- Router wiring for tab switching +- Focused mode: `arc services list` +- JSON mode: `arc services list --json` + +**Milestone**: 2-tab dashboard with real data. Services searchable from catalog. Errors shown gracefully. + +### Phase 4: Workspace + Config Tabs (Week 5) + +Complete the 4-tab dashboard. + +- view/workspace_info.go + workspace_history.go +- view/config_overview.go (profile, theme, skin pickers) +- view/version.go +- Focused mode for remaining commands + +**Milestone**: Full 4-tab dashboard, all tabs with real data. + +### Phase 5: Wizards + Remaining Commands (Week 6) + +Interactive flows with full error handling. + +- view/init_wizard.go (huh form) +- view/workspace_run.go (progress + error capture) +- Wire all remaining Cobra commands + +**Milestone**: All commands work. Shell command errors captured and displayed. + +### Phase 6: Cleanup + Polish (Week 7) + +Ship it. + +- Delete all old UI code +- Final lint pass (zero //nolint target) +- Update README, agent doc, CLAUDE.md +- Performance validation (<100ms startup, <16ms tab switch, <20MB memory) +- Merge to develop + +--- + +## 13. Task Breakdown + +### Phase 1: Foundation (40 tasks, ~59.5h) + +| # | Task | Depends | Est | +| ---- | --------------------------------------------- | --------------- | ---- | +| T001 | Create worktree, scaffold folders | — | 1h | +| T002 | Delete old pkg/ui/ contents | T001 | 30m | +| T003 | theme/theme.go — Theme + ColorSet | T001 | 2h | +| T004 | theme/profile.go — Profile struct | T003 | 1h | +| T005 | theme/skin.go — Skin + enums | T003 | 2h | +| T006 | theme/context.go — theme.Context | T003-T005 | 2h | +| T007 | theme/registry.go — Style cache | T006 | 2h | +| T008 | theme/loader.go — YAML loader | T006 | 3h | +| T009 | Port theme YAML files | T008 | 30m | +| T010 | Port profile YAML files | T008 | 30m | +| T011 | Create skin YAML files (gh-dash, minimal) | T005 | 1h | +| T012 | component/header.go | T006 | 2h | +| T013 | component/controlbar.go | T006 | 2h | +| T014 | component/navigation.go (skin-dependent) | T006, T005 | 4h | +| T015 | component/table.go | T006 | 3h | +| T016 | component/card.go | T006 | 1h | +| T017 | component/panel.go | T006 | 1h | +| T018 | component/hero.go | T006 | 2h | +| T019 | component/badge.go | T006 | 1h | +| T020 | component/tree.go | T006 | 2h | +| T021 | shell/executor.go — Shell command wrapper | — | 2h | +| T022 | component/error.go — Error display | T006, T021 | 2h | +| T023 | component/list.go (wraps bubbles) | T006 | 2h | +| T024 | component/search.go (wraps bubbles) | T006 | 1h | +| T025 | component/spinner.go (wraps bubbles) | T006 | 1h | +| T026 | component/progress.go (wraps bubbles) | T006 | 1h | +| T027 | component/form.go (wraps huh) | T006 | 2h | +| T028 | component/viewport.go (wraps bubbles) | T006 | 1h | +| T029 | component/markdown.go (wraps glamour) | T006 | 1h | +| T030 | engine/view.go — View interface | — | 1h | +| T031 | engine/context.go — ViewContext | T006 | 1h | +| T032 | engine/state.go — StateManager | T006, T008 | 3h | +| T033 | engine/messages.go — ErrorMsg + others | T022 | 1.5h | +| T034 | engine/router.go — Router + OnEnter guarantee | T030-T032 | 3h | +| T035 | engine/keys.go — Global keys | — | 30m | +| T036 | engine/shell.go — Shell model | T012-T014, T034 | 5h | +| T037 | engine/launch.go — Start() entry | T036 | 2h | +| T038 | internal/app/bootstrap.go | T032 | 2h | +| T039 | Update cmd/arc/main.go | T038 | 1h | +| T040 | MILESTONE: Empty shell renders | T039 | 1h | + +### Phase 2: Theme + Skin (7 tasks, ~18h) + +| # | Task | Depends | Est | +| ---- | ------------------------------------ | ---------- | --- | +| T041 | StateManager.ChangeProfile() | T032 | 3h | +| T042 | StateChangedMsg in Shell | T036, T041 | 2h | +| T043 | Skin rendering in navigation | T014, T005 | 3h | +| T044 | Placeholder Config view | T041 | 3h | +| T045 | Theme system golden tests | T008 | 3h | +| T046 | Component golden tests | T015-T018 | 3h | +| T047 | MILESTONE: Profile switch works live | T044 | 1h | + +### Phase 3: Home + Services (7 tasks, ~19h) + +| # | Task | Depends | Est | +| ---- | ------------------------------------------- | ---------------------- | --- | +| T048 | view/home.go | T018, T016 | 4h | +| T049 | view/services_list.go (with error handling) | T015, T023, T024, T022 | 5h | +| T050 | view/service_detail.go | T016, T020 | 4h | +| T051 | Router: Home <-> Services tab switching | T034, T048, T049 | 2h | +| T052 | Focused mode: arc services list | T037, T049 | 2h | +| T053 | JSON mode: arc services list --json | T037 | 1h | +| T054 | MILESTONE: 2-tab dashboard with real data | T051 | 1h | + +### Phase 4: Workspace + Config (8 tasks, ~18h) + +| # | Task | Depends | Est | +| ---- | ------------------------------------- | ---------- | --- | +| T055 | view/workspace_info.go | T016, T015 | 3h | +| T056 | view/workspace_history.go | T015 | 3h | +| T057 | Complete view/config_overview.go | T044, T023 | 4h | +| T058 | view/version.go | T016 | 2h | +| T059 | Router: all 4 tabs wired | T048-T057 | 2h | +| T060 | Focused mode: workspace info, version | T037 | 2h | +| T061 | JSON mode: workspace info, version | T037 | 1h | +| T062 | MILESTONE: Full 4-tab dashboard | T059 | 1h | + +### Phase 5: Wizards + Commands (6 tasks, ~11.5h) + +| # | Task | Depends | Est | +| ---- | ------------------------------------------- | ---------------- | --- | +| T063 | view/init_wizard.go | T027 | 4h | +| T064 | view/workspace_run.go (with error handling) | T025, T026, T022 | 3h | +| T065 | Wire arc init | T063 | 1h | +| T066 | Wire arc workspace init/run | T063, T064 | 2h | +| T067 | arc completion (keep current) | — | 30m | +| T068 | MILESTONE: All commands work | T065-T067 | 1h | + +### Phase 6: Cleanup (8 tasks, ~14h) + +| # | Task | Depends | Est | +| ---- | -------------------------------- | ------- | --- | +| T069 | Delete all old UI code | T068 | 2h | +| T070 | Update .golangci.yml | T069 | 1h | +| T071 | Lint pass — zero //nolint target | T070 | 3h | +| T072 | Update README.md | T068 | 2h | +| T073 | Update agent doc | T068 | 2h | +| T074 | Update CLAUDE.md | T068 | 1h | +| T075 | Performance validation | T068 | 2h | +| T076 | MILESTONE: Merge to develop | T075 | 1h | + +### Totals + +| Phase | Tasks | Hours | +| --------------------- | ------ | ------------------------------- | +| 1. Foundation | 40 | ~59.5h | +| 2. Theme + Skin | 7 | ~18h | +| 3. Home + Services | 7 | ~19h | +| 4. Workspace + Config | 8 | ~18h | +| 5. Wizards + Commands | 6 | ~11.5h | +| 6. Cleanup | 8 | ~14h | +| **Total** | **76** | **~140h (~7 weeks @ 20h/week)** | + +--- + +## Appendix A: Discussion & Alignment Notes + +> These are the 10 design principles discussed and aligned before finalizing this plan. + +### Point 1: Abstraction is NOT Bad + +Keep one clean interface boundary between backend and UI. Backend stays stable; UI is swappable. + +### Point 2: Engine Was a Good Idea — Fix It + +Fix the lifecycle bug (OnEnter guarantee), consolidate components, keep the engine concept. + +### Point 3: gh-dash + Versionable UI + Worktree + +- Git worktree for clean rewrite +- gh-dash as V1 design reference +- UI skin system for swappable looks +- Tab-based navigation + +### Point 4: Simpler Folder Structure + +Max 2 directory hops. Flat: `pkg/ui/` has `theme/`, `component/`, `engine/`, `view/`. + +### Point 5: Linting — Smart Exclusions + +Keep all 30 linters. Path exclusions for UI layers. Backend stays enterprise-strict. + +### Point 6: Testing — Interfaces First + +- Must test: Backend interfaces, theme loading, profile switching +- Golden files: Table, card, hero output +- Skip: View rendering, animation timing +- Later: Headless Bubble Tea integration + +### Point 7: Don't Discard Components + +Consolidate duplicates to one implementation. Keep factory pattern. + +### Point 8: Proper Engineering Plan, Go + Libraries + +Stick with Go + Charmbracelet: lipgloss, bubbles, huh, glamour, harmonica. + +### Point 9: Swappable UI, State-Driven Theming + +- Core untouched, UI replaceable +- Profile change = cascading update +- State is the brain +- Workspace scope: ready later, global first + +### Point 10: React-Inspired Engine Design + +``` +Engine: Shell + Router + ViewRegistry + StateManager +View: OnEnter (props) → Update → View (pure string) +``` + +Props = ViewContext, State = internal state, Component tree = Shell → TabBar → View → Components. + +--- + +## What Gets Deleted (Phase 6, T066) + +``` +pkg/ui/components/ # Old component tree +pkg/ui/views/ # Old view files +pkg/ui/engine/ # Old engine (replaced by new) +pkg/ui/factory.go # 622-line factory +pkg/ui/service.go # Old UI service +pkg/ui/styles/ # Global mutable colors +pkg/ui/animations/ # Animation framework +pkg/ui/layouts/ # Empty directory +pkg/ui/markdown/ # Folded into component/ +pkg/ui/profiles/ # Folded into theme/ +pkg/ui/themes/ # Folded into theme/ +pkg/cli/dashboard/ # Old dashboard +pkg/cli/middleware/ # ErrorBoundary +pkg/cli/errors/ # ArcError +pkg/cli/banner.go # Old banner +pkg/cli/init_profile_ui.go # Old init UI +internal/branding/ # Folded into theme/profile +``` + +## What Stays Untouched + +``` +pkg/catalog/ # Service catalog +pkg/workspace/ # Workspace management +pkg/store/ # Config store +pkg/scaffold/ # Templates +pkg/log/ # Logging +pkg/version/ # Version API +internal/config/ # arc.yaml parsing +internal/preferences/ # state.json +internal/terminal/ # TTY detection +internal/xdg/ # XDG directories +specs/ # All 17 spec histories +``` + +--- + +_"The CLI should feel like it was built from the heart."_ +_— A.R.C. CLI v2 UI Design & Implementation Plan, March 2026_ diff --git a/specs/018-ui-design/tasks.md b/specs/018-ui-design/tasks.md new file mode 100644 index 0000000..b75ad8a --- /dev/null +++ b/specs/018-ui-design/tasks.md @@ -0,0 +1,382 @@ +# Tasks: UI Design & Architecture Rebuild + +**Input**: Design documents from `/specs/018-ui-design/` +**Prerequisites**: plan.md ✅, spec.md ✅ + +**Feature Branch**: `018-ui-rewrite` +**Total Tasks**: 76 +**Parallel Opportunities**: ~45 tasks +**Estimated Effort**: 140 hours (~7 weeks @ 20h/week) + +**Tests**: Golden file tests for themes/components + lifecycle tests included per spec requirements. + +**Organization**: Tasks are grouped by implementation phase and user story to enable progressive delivery. + +--- + +## Test Coverage Requirements + +**Targets for this Feature**: + +- **Theme system** (loader, theme, profile, skin, context): **75%+ coverage** +- **UI components** (17 components): **60%+ coverage** +- **Engine** (router, state, shell, view interface): **70%+ coverage** +- **Views** (9 view implementations): **50%+ coverage** + +**Test Strategy**: + +- ✅ Golden files for themes (10 themes × 2 skins = 20 tests) +- ✅ Golden files for components (4 components × 10 themes = 40 tests) +- ✅ Lifecycle tests for engine (view OnEnter/OnExit guarantees) +- ✅ Headless Bubble Tea tests for navigation flows +- ✅ Error handling tests (shell executor with timeout, stderr capture) +- **Total**: 60 golden file tests (see data-model.md section 4) + +--- + +## Code Quality & Linting Requirements + +**Pre-Implementation** (Phase 1): + +- Review `.golangci.yml` configuration +- Run `make lint` to establish baseline +- Path-based exclusions documented in plan.md (applied in T070) + +**During Implementation**: + +- Run `make lint` after each phase +- Fix all errors before moving to next phase +- Target: Zero `//nolint` in new code (path exclusions preferred) + +**Pre-Merge Quality Gate** (Final Phase): + +- `make quality` must pass (fmt + vet + lint) +- `make test` with race detector must pass +- All coverage targets met +- Performance targets validated (<100ms startup, <16ms nav, <20MB memory) + +--- + +## Implementation Strategy + +### MVP Scope (First Deliverable) + +**Phase 1 + Phase 2**: Theme System + Empty Shell Rendering + +- Complete theme/profile/skin YAML loading +- All 17 components rendering with themed styles +- Empty shell with header, navigation (tab-bar), controlbar +- Live theme switching in placeholder Config view +- **Deliverable**: `arc` opens themed empty shell, profile switch updates all components + +### Incremental Delivery + +1. **MVP**: Theme System + Shell (Phases 1-2) → Validate with stakeholders +2. **Iteration 2**: Dashboard with Services (US2) → Deploy for testing +3. **Iteration 3**: Complete Dashboard (US3) → Full 4-tab experience +4. **Iteration 4**: Wizards (US4) + Polish → Production ready + +### Parallel Opportunities + +Tasks marked with `[P]` can be executed in parallel: + +- All component implementations (T012-T029) after theme system is ready +- View implementations within each user story +- Documentation updates in final phase + +### Independent Story Validation + +Each user story has clear success criteria: + +- **US1**: Profile switch triggers cascading UI update +- **US2**: Services searchable, detail view shows dependencies, errors displayed gracefully +- **US3**: All 4 tabs routed, workspace data displayed +- **US4**: Init wizard works end-to-end + +--- + +## Phase 1: Setup & Foundation + +**Duration**: Week 1-2 (~59.5 hours) +**Goal**: Complete theme system, all components, error handling, engine scaffolding +**Status**: Not started + +### Project Setup + +- [x] T001 Create git worktree for 018-ui-rewrite branch, scaffold pkg/ui/ folder structure +- [x] T002 Delete old pkg/ui/ contents from worktree completely (fresh start) + +### Theme System Core + +- [x] T003 Implement theme/theme.go with Theme struct and ColorSet definitions +- [x] T004 Implement theme/profile.go with Profile struct (logo, tier names, theme reference) +- [x] T005 Implement theme/skin.go with Skin struct and enums (NavigationStyle, BorderStyle, Density) +- [x] T006 Implement theme/context.go with theme.Context that combines Theme + Profile + Skin +- [x] T007 Implement theme/registry.go with thread-safe style cache for lipgloss styles +- [x] T008 Implement theme/loader.go with YAML loader for themes/profiles/skins +- [x] T009 [P] Port 10 theme YAML files to pkg/ui/theme/embedded/themes/ directory +- [x] T010 [P] Port 10 profile YAML files to pkg/ui/theme/embedded/profiles/ directory +- [x] T011 [P] Create 2 skin YAML files (gh-dash, minimal) in pkg/ui/theme/embedded/skins/ + +### Component Library (17 Components) + +- [x] T012 [P] Implement component/header.go with title, subtitle rendering from theme.Context +- [x] T013 [P] Implement component/controlbar.go with keybindings display from theme.Context +- [x] T014 [P] Implement component/navigation.go with skin-dependent rendering (tab-bar vs sidebar) +- [x] T015 [P] Implement component/table.go wrapping bubbles.table with theme.Context styling +- [x] T016 [P] Implement component/card.go with border, title, content from theme.Context +- [x] T017 [P] Implement component/panel.go with flexible layout container +- [x] T018 [P] Implement component/hero.go with profile logo, tagline, tier badge +- [x] T019 [P] Implement component/badge.go with tier/status rendering +- [x] T020 [P] Implement component/tree.go with dependency tree rendering +- [x] T021 [P] Implement shell/executor.go with command wrapper (timeout, context, stderr capture) +- [x] T022 [P] Implement component/error.go with ErrorDisplay and InlineError components +- [x] T023 [P] Implement component/list.go wrapping bubbles.list with theme.Context +- [x] T024 [P] Implement component/search.go wrapping bubbles.textinput for filtering +- [x] T025 [P] Implement component/spinner.go wrapping bubbles.spinner with theme colors +- [x] T026 [P] Implement component/progress.go wrapping bubbles.progress with theme colors +- [x] T027 [P] Implement component/form.go wrapping huh forms with theme.Context +- [x] T028 [P] Implement component/viewport.go wrapping bubbles.viewport for scrollable content +- [x] T029 [P] Implement component/markdown.go wrapping glamour with theme.Context + +### Engine Infrastructure + +- [x] T030 [P] Define View interface in engine/view.go (Init, Update, View, OnEnter, OnExit, Name, Keybindings) +- [x] T031 Implement engine/context.go with ViewContext struct (Theme, Profile, Skin, Width, Height, Args, backend services) +- [x] T032 Implement engine/state.go with StateManager (profile/theme/skin resolver, ViewContext builder, preferences loader) +- [x] T033 Implement engine/messages.go with ErrorMsg, StateChangedMsg, and other Bubble Tea messages +- [x] T034 Implement engine/router.go with Navigate (OnExit/OnEnter lifecycle guarantee), Back, Current methods +- [x] T035 [P] Implement engine/keys.go with global key bindings (q=quit, Tab=next, Shift+Tab=prev) +- [x] T036 Implement engine/shell.go with Shell model (header, navigation, controlbar, router, view area) +- [x] T037 Implement engine/launch.go with Start() entry point (dashboard vs focused mode detection) +- [x] T038 Implement internal/app/bootstrap.go replacing init() side effects with explicit dependency injection +- [x] T039 Update cmd/arc/main.go to use bootstrap.go and engine/launch.go +- [x] T040 MILESTONE: Test empty shell rendering with themed header, navigation, controlbar (press 'q' to quit) + +--- + +## Phase 2: Foundational - Theme Switching + +**Duration**: Week 3 (~18 hours) +**Goal**: Wire live profile/theme/skin switching - BLOCKING for user stories +**Status**: Not started + +### Theme Switching Infrastructure + +- [x] T041 [US1] Implement StateManager.ChangeProfile() with cascading theme/skin updates in engine/state.go +- [x] T042 [US1] Implement StateChangedMsg handling in Shell model (engine/shell.go) to trigger full re-render +- [x] T043 [US1] Implement skin-based navigation rendering in component/navigation.go (tab-bar ↔ sidebar switch) +- [x] T044 [US1] Create placeholder Config view with profile/theme/skin pickers for testing in view/config_overview.go + +### Testing & Validation + +- [x] T045 [P] [US1] Write golden file tests for theme system (10 themes × 2 skins = 20 tests) in theme/loader_test.go +- [x] T046 [P] [US1] Write golden file tests for key components (header, hero, table, card) × 10 themes +- [x] T047 [US1] MILESTONE: Test profile switch live in Config view (colors, borders, layout, logo update) + +--- + +## Phase 3: User Story 2 - Dashboard Mode with Services (P1) + +**Goal**: Implement dashboard mode (`arc`) with Home and Services views, error handling + +**Independent Test**: Launch `arc`, navigate to Services tab, search for service, view detail, see errors displayed gracefully + +**Duration**: Week 4 (~19 hours) + +### US2: Dashboard Views + +- [x] T048 [P] [US2] Implement view/home.go with hero, quick actions, system info (uses component/hero.go, component/card.go) +- [x] T049 [P] [US2] Implement view/services_list.go with catalog table, search, error display (uses component/table.go, component/search.go, component/error.go) +- [x] T050 [P] [US2] Implement view/service_detail.go with card and dependency tree (uses component/card.go, component/tree.go) +- [x] T051 [US2] Wire Router for Home ↔ Services tab switching in engine/router.go +- [x] T052 [US2] Implement focused mode for `arc services list` in engine/launch.go (single-view, no tabs) +- [x] T053 [US2] Implement JSON mode for `arc services list --json` in engine/launch.go (no TUI) +- [x] T054 [US2] MILESTONE: Test 2-tab dashboard with real catalog data, search filtering, service detail navigation + +--- + +## Phase 4: User Story 3 - Complete Dashboard (P1) + +**Goal**: Implement remaining dashboard tabs (Workspace, Config, Version) + +**Independent Test**: Launch `arc`, navigate through all 4 tabs, view workspace data, switch profile in Config + +**Duration**: Week 5 (~18 hours) + +### US3: Additional Dashboard Views + +- [x] T055 [P] [US3] Implement view/workspace_info.go with workspace details (uses component/card.go, component/table.go) +- [x] T056 [P] [US3] Implement view/workspace_history.go with history table (uses component/table.go) +- [x] T057 [US3] Complete view/config_overview.go with profile/theme/skin pickers (uses component/list.go) +- [x] T058 [P] [US3] Implement view/version.go with system info (uses component/card.go) +- [x] T059 [US3] Wire Router for all 4 tabs (Home, Services, Workspace, Config) in engine/router.go +- [x] T060 [US3] Implement focused mode for `arc workspace info`, `arc version` in engine/launch.go +- [x] T061 [US3] Implement JSON mode for `arc workspace info --json`, `arc version --json` +- [x] T062 [US3] MILESTONE: Test full 4-tab dashboard, navigation history, workspace data display + +--- + +## Phase 5: User Story 4 - Interactive Wizards & Commands (P2) + +**Goal**: Implement interactive wizards for init and workspace management + +**Independent Test**: Run `arc init`, complete wizard, verify workspace created + +**Duration**: Week 6 (~11.5 hours) + +### US4: Wizards & Commands + +- [x] T063 [P] [US4] Implement view/init_wizard.go with huh forms for project initialization (uses component/form.go) +- [x] T064 [P] [US4] Implement view/workspace_run.go with spinner, progress, error display (uses component/spinner.go, component/progress.go, component/error.go) +- [x] T065 [US4] Wire `arc init` command to init_wizard view in cmd/arc/init.go +- [x] T066 [US4] Wire `arc workspace init`, `arc workspace run` commands in cmd/arc/workspace.go +- [x] T067 [US4] Verify `arc completion` command still works (no changes needed) +- [x] T068 [US4] MILESTONE: Test all commands work end-to-end with new UI + +--- + +## Phase 6: Polish & Production Readiness + +**Duration**: Week 7 (~14 hours) +**Goal**: Delete old code, update docs, validate performance, merge to develop +**Status**: Not started + +### Cleanup & Documentation + +- [ ] T069 Delete all old UI code (30+ files) from pkg/ui/ after validation +- [ ] T070 Update .golangci.yml with path-based exclusions for new UI layer +- [ ] T071 Run full lint pass targeting zero //nolint in new code (fix all issues) +- [ ] T072 [P] Update README.md with new UI screenshots and architecture diagrams +- [ ] T073 [P] Update docs/developer/ agent documentation with new patterns (Shell + Router + Views) +- [ ] T074 [P] Update CLAUDE.md with new UI architecture and component references + +### Validation & Merge + +- [ ] T075 Run performance validation (<100ms startup, <16ms nav, <20MB memory, <50ms theme switch) +- [ ] T076 MILESTONE: Merge 018-ui-rewrite worktree to develop branch after CI/CD passes + +--- + +## Success Criteria & Validation + +### Functional Requirements (All User Stories) + +**Dashboard Mode**: + +- [ ] `arc` opens 4-tab TUI (Home, Services, Workspace, Config) +- [ ] Tab key cycles through tabs, Shift+Tab goes backward +- [ ] Services tab: Search filters list, Enter opens detail, Backspace returns +- [ ] Config tab: Profile picker works, live theme switch updates entire UI + +**Focused Mode**: + +- [ ] `arc services list` opens single-view TUI (no tabs) +- [ ] `arc workspace info` opens single-view TUI +- [ ] All views support `q` to quit + +**JSON Mode**: + +- [ ] `arc services list --json` outputs JSON, no TUI +- [ ] `arc workspace info --json` outputs JSON +- [ ] `arc version --json` outputs JSON + +**Error Handling**: + +- [ ] Shell command failures captured with stderr, exit codes +- [ ] ErrorDisplay component shows themed error boxes +- [ ] ErrorMsg handled asynchronously in Shell model + +**Theme Switching**: + +- [ ] Profile switch in Config view updates: colors, borders, layout, logo +- [ ] Skin switch changes navigation (tab-bar ↔ sidebar) +- [ ] All 10 themes tested, golden files pass + +**Wizards**: + +- [ ] `arc init` launches huh form wizard +- [ ] `arc workspace init` launches workspace wizard +- [ ] Form validation works, errors displayed + +### Non-Functional Requirements + +**Performance** (validated in T075): + +- [ ] Startup time: <100ms (cold start) +- [ ] Tab navigation: <16ms (60fps target) +- [ ] Memory footprint: <20MB +- [ ] Theme switch: <50ms (live profile change) +- [ ] JSON output: <10ms (instant) + +**Code Quality** (validated in T071): + +- [ ] All 30 linters pass (with smart path exclusions) +- [ ] Zero //nolint in new code +- [ ] 75% theme coverage, 60% component coverage, 70% engine coverage, 50% view coverage + +**Production Readiness**: + +- [ ] All command flags work correctly (--json, --no-animation, etc.) +- [ ] JSON mode: 5 data-retrieval commands support --json (see plan.md JSON Mode Coverage Matrix) +- [ ] Backend services unchanged (catalog, workspace, store, scaffold) +- [ ] Clean separation between UI and business logic maintained +- [ ] Golden tests: 60 tests pass (20 theme tests + 40 component tests) + +--- + +## Dependencies & Critical Path + +**Critical Path** (blocking tasks that must complete in sequence): + +``` +T001 → T006 → T008 → T032 → T034 → T036 → T037 → T041 → T048 → T051 → T059 → T068 → T076 +``` + +**Parallel Opportunities per Phase**: + +- Phase 1: T009-T011 (themes/profiles/skins), T012-T029 (all components after T006) +- Phase 2: T045-T046 (golden tests) +- Phase 3: T048-T050 (all views in parallel after router ready) +- Phase 4: T055-T056, T058 (views in parallel) +- Phase 5: T063-T064 (wizards in parallel) +- Phase 6: T072-T074 (documentation in parallel) + +**User Story Dependencies**: + +- US2 depends on: Phases 1-2 (foundation + theme switching) +- US3 depends on: US2 (router must support tab switching) +- US4 depends on: US2 (focused mode pattern established) + +--- + +## Notes + +### Component Consolidation Strategy + +- Old: 30+ components with inconsistent patterns +- New: 17 components, clear separation (stateless render functions vs stateful Bubble Tea structs) +- Wrapping vs Reimplementing: Wrap bubbles/huh components (list, table, form), reimplement custom components (hero, card, tree) + +### Error Handling Philosophy + +Three layers: + +1. **Shell Executor** (shell/executor.go): Captures stderr, exit codes, timeout handling +2. **Error Display** (component/error.go): Themed error boxes with context +3. **Async Messages** (engine/messages.go): ErrorMsg for non-blocking errors + +### Theme System Architecture + +- **Design Tokens Pattern**: Centralized color/spacing/typography definitions +- **Profile + Theme + Skin Separation**: Branding (profile) + colors (theme) + layout (skin) +- **YAML-based**: Themes, profiles, skins all defined in human-readable YAML + +### View Lifecycle Guarantee + +The router ALWAYS calls OnEnter() before any render. This prevents the blank screen bug from old engine. + +--- + +_"The CLI should feel like it was built from the heart."_ +_— A.R.C. CLI v2 UI Design & Implementation Plan, March 2026_ diff --git a/specs/001-initial-setup/plan.md b/specs/archive/001-initial-setup/plan.md similarity index 100% rename from specs/001-initial-setup/plan.md rename to specs/archive/001-initial-setup/plan.md diff --git a/specs/001-initial-setup/spec.md b/specs/archive/001-initial-setup/spec.md similarity index 100% rename from specs/001-initial-setup/spec.md rename to specs/archive/001-initial-setup/spec.md diff --git a/specs/001-initial-setup/tasks.md b/specs/archive/001-initial-setup/tasks.md similarity index 100% rename from specs/001-initial-setup/tasks.md rename to specs/archive/001-initial-setup/tasks.md diff --git a/specs/002-state-management/plan.md b/specs/archive/002-state-management/plan.md similarity index 100% rename from specs/002-state-management/plan.md rename to specs/archive/002-state-management/plan.md diff --git a/specs/002-state-management/spec.md b/specs/archive/002-state-management/spec.md similarity index 100% rename from specs/002-state-management/spec.md rename to specs/archive/002-state-management/spec.md diff --git a/specs/002-state-management/tasks.md b/specs/archive/002-state-management/tasks.md similarity index 100% rename from specs/002-state-management/tasks.md rename to specs/archive/002-state-management/tasks.md diff --git a/specs/003-test-infrastructure/plan.md b/specs/archive/003-test-infrastructure/plan.md similarity index 100% rename from specs/003-test-infrastructure/plan.md rename to specs/archive/003-test-infrastructure/plan.md diff --git a/specs/003-test-infrastructure/research.md b/specs/archive/003-test-infrastructure/research.md similarity index 100% rename from specs/003-test-infrastructure/research.md rename to specs/archive/003-test-infrastructure/research.md diff --git a/specs/003-test-infrastructure/spec.md b/specs/archive/003-test-infrastructure/spec.md similarity index 100% rename from specs/003-test-infrastructure/spec.md rename to specs/archive/003-test-infrastructure/spec.md diff --git a/specs/003-test-infrastructure/tasks.md b/specs/archive/003-test-infrastructure/tasks.md similarity index 100% rename from specs/003-test-infrastructure/tasks.md rename to specs/archive/003-test-infrastructure/tasks.md diff --git a/specs/004-interactive-ui-enhancements/contracts/components.md b/specs/archive/004-interactive-ui-enhancements/contracts/components.md similarity index 100% rename from specs/004-interactive-ui-enhancements/contracts/components.md rename to specs/archive/004-interactive-ui-enhancements/contracts/components.md diff --git a/specs/004-interactive-ui-enhancements/data-model.md b/specs/archive/004-interactive-ui-enhancements/data-model.md similarity index 100% rename from specs/004-interactive-ui-enhancements/data-model.md rename to specs/archive/004-interactive-ui-enhancements/data-model.md diff --git a/specs/004-interactive-ui-enhancements/plan.md b/specs/archive/004-interactive-ui-enhancements/plan.md similarity index 100% rename from specs/004-interactive-ui-enhancements/plan.md rename to specs/archive/004-interactive-ui-enhancements/plan.md diff --git a/specs/004-interactive-ui-enhancements/quickstart.md b/specs/archive/004-interactive-ui-enhancements/quickstart.md similarity index 100% rename from specs/004-interactive-ui-enhancements/quickstart.md rename to specs/archive/004-interactive-ui-enhancements/quickstart.md diff --git a/specs/004-interactive-ui-enhancements/research.md b/specs/archive/004-interactive-ui-enhancements/research.md similarity index 100% rename from specs/004-interactive-ui-enhancements/research.md rename to specs/archive/004-interactive-ui-enhancements/research.md diff --git a/specs/004-interactive-ui-enhancements/spec.md b/specs/archive/004-interactive-ui-enhancements/spec.md similarity index 100% rename from specs/004-interactive-ui-enhancements/spec.md rename to specs/archive/004-interactive-ui-enhancements/spec.md diff --git a/specs/004-interactive-ui-enhancements/tasks.md b/specs/archive/004-interactive-ui-enhancements/tasks.md similarity index 100% rename from specs/004-interactive-ui-enhancements/tasks.md rename to specs/archive/004-interactive-ui-enhancements/tasks.md diff --git a/specs/005-animations-rich-ui/INDEX.md b/specs/archive/005-animations-rich-ui/INDEX.md similarity index 100% rename from specs/005-animations-rich-ui/INDEX.md rename to specs/archive/005-animations-rich-ui/INDEX.md diff --git a/specs/005-animations-rich-ui/INDUSTRY_PATTERNS.md b/specs/archive/005-animations-rich-ui/INDUSTRY_PATTERNS.md similarity index 100% rename from specs/005-animations-rich-ui/INDUSTRY_PATTERNS.md rename to specs/archive/005-animations-rich-ui/INDUSTRY_PATTERNS.md diff --git a/specs/005-animations-rich-ui/INDUSTRY_STANDARDS.md b/specs/archive/005-animations-rich-ui/INDUSTRY_STANDARDS.md similarity index 100% rename from specs/005-animations-rich-ui/INDUSTRY_STANDARDS.md rename to specs/archive/005-animations-rich-ui/INDUSTRY_STANDARDS.md diff --git a/specs/005-animations-rich-ui/QUICK_REFERENCE.md b/specs/archive/005-animations-rich-ui/QUICK_REFERENCE.md similarity index 100% rename from specs/005-animations-rich-ui/QUICK_REFERENCE.md rename to specs/archive/005-animations-rich-ui/QUICK_REFERENCE.md diff --git a/specs/005-animations-rich-ui/README.md b/specs/archive/005-animations-rich-ui/README.md similarity index 100% rename from specs/005-animations-rich-ui/README.md rename to specs/archive/005-animations-rich-ui/README.md diff --git a/specs/005-animations-rich-ui/REFACTORING_ROADMAP.md b/specs/archive/005-animations-rich-ui/REFACTORING_ROADMAP.md similarity index 100% rename from specs/005-animations-rich-ui/REFACTORING_ROADMAP.md rename to specs/archive/005-animations-rich-ui/REFACTORING_ROADMAP.md diff --git a/specs/005-animations-rich-ui/REFACTORING_SUMMARY.md b/specs/archive/005-animations-rich-ui/REFACTORING_SUMMARY.md similarity index 100% rename from specs/005-animations-rich-ui/REFACTORING_SUMMARY.md rename to specs/archive/005-animations-rich-ui/REFACTORING_SUMMARY.md diff --git a/specs/005-animations-rich-ui/archive/contracts/animation-api.md b/specs/archive/005-animations-rich-ui/archive/contracts/animation-api.md similarity index 100% rename from specs/005-animations-rich-ui/archive/contracts/animation-api.md rename to specs/archive/005-animations-rich-ui/archive/contracts/animation-api.md diff --git a/specs/005-animations-rich-ui/archive/data-model.md b/specs/archive/005-animations-rich-ui/archive/data-model.md similarity index 100% rename from specs/005-animations-rich-ui/archive/data-model.md rename to specs/archive/005-animations-rich-ui/archive/data-model.md diff --git a/specs/005-animations-rich-ui/archive/plan.md b/specs/archive/005-animations-rich-ui/archive/plan.md similarity index 100% rename from specs/005-animations-rich-ui/archive/plan.md rename to specs/archive/005-animations-rich-ui/archive/plan.md diff --git a/specs/005-animations-rich-ui/archive/quickstart.md b/specs/archive/005-animations-rich-ui/archive/quickstart.md similarity index 100% rename from specs/005-animations-rich-ui/archive/quickstart.md rename to specs/archive/005-animations-rich-ui/archive/quickstart.md diff --git a/specs/005-animations-rich-ui/archive/research.md b/specs/archive/005-animations-rich-ui/archive/research.md similarity index 100% rename from specs/005-animations-rich-ui/archive/research.md rename to specs/archive/005-animations-rich-ui/archive/research.md diff --git a/specs/005-animations-rich-ui/archive/spec.md b/specs/archive/005-animations-rich-ui/archive/spec.md similarity index 100% rename from specs/005-animations-rich-ui/archive/spec.md rename to specs/archive/005-animations-rich-ui/archive/spec.md diff --git a/specs/005-animations-rich-ui/archive/tasks.md b/specs/archive/005-animations-rich-ui/archive/tasks.md similarity index 100% rename from specs/005-animations-rich-ui/archive/tasks.md rename to specs/archive/005-animations-rich-ui/archive/tasks.md diff --git a/specs/006-stabilize-base/ANALYSIS_SUMMARY.md b/specs/archive/006-stabilize-base/ANALYSIS_SUMMARY.md similarity index 100% rename from specs/006-stabilize-base/ANALYSIS_SUMMARY.md rename to specs/archive/006-stabilize-base/ANALYSIS_SUMMARY.md diff --git a/specs/006-stabilize-base/README.md b/specs/archive/006-stabilize-base/README.md similarity index 100% rename from specs/006-stabilize-base/README.md rename to specs/archive/006-stabilize-base/README.md diff --git a/specs/006-stabilize-base/contracts/contracts.go b/specs/archive/006-stabilize-base/contracts/contracts.go similarity index 100% rename from specs/006-stabilize-base/contracts/contracts.go rename to specs/archive/006-stabilize-base/contracts/contracts.go diff --git a/specs/006-stabilize-base/data-model.md b/specs/archive/006-stabilize-base/data-model.md similarity index 100% rename from specs/006-stabilize-base/data-model.md rename to specs/archive/006-stabilize-base/data-model.md diff --git a/specs/006-stabilize-base/metrics/baseline-summary.md b/specs/archive/006-stabilize-base/metrics/baseline-summary.md similarity index 100% rename from specs/006-stabilize-base/metrics/baseline-summary.md rename to specs/archive/006-stabilize-base/metrics/baseline-summary.md diff --git a/specs/006-stabilize-base/plan.md b/specs/archive/006-stabilize-base/plan.md similarity index 100% rename from specs/006-stabilize-base/plan.md rename to specs/archive/006-stabilize-base/plan.md diff --git a/specs/006-stabilize-base/quickstart.md b/specs/archive/006-stabilize-base/quickstart.md similarity index 100% rename from specs/006-stabilize-base/quickstart.md rename to specs/archive/006-stabilize-base/quickstart.md diff --git a/specs/006-stabilize-base/spec.md b/specs/archive/006-stabilize-base/spec.md similarity index 100% rename from specs/006-stabilize-base/spec.md rename to specs/archive/006-stabilize-base/spec.md diff --git a/specs/006-stabilize-base/tasks.md b/specs/archive/006-stabilize-base/tasks.md similarity index 100% rename from specs/006-stabilize-base/tasks.md rename to specs/archive/006-stabilize-base/tasks.md diff --git a/specs/007-init-wizard/CODE_QUALITY_REVIEW.md b/specs/archive/007-init-wizard/CODE_QUALITY_REVIEW.md similarity index 100% rename from specs/007-init-wizard/CODE_QUALITY_REVIEW.md rename to specs/archive/007-init-wizard/CODE_QUALITY_REVIEW.md diff --git a/specs/007-init-wizard/checklists/requirements.md b/specs/archive/007-init-wizard/checklists/requirements.md similarity index 100% rename from specs/007-init-wizard/checklists/requirements.md rename to specs/archive/007-init-wizard/checklists/requirements.md diff --git a/specs/007-init-wizard/contracts/init-operation.go b/specs/archive/007-init-wizard/contracts/init-operation.go similarity index 100% rename from specs/007-init-wizard/contracts/init-operation.go rename to specs/archive/007-init-wizard/contracts/init-operation.go diff --git a/specs/007-init-wizard/data-model.md b/specs/archive/007-init-wizard/data-model.md similarity index 100% rename from specs/007-init-wizard/data-model.md rename to specs/archive/007-init-wizard/data-model.md diff --git a/specs/007-init-wizard/plan.md b/specs/archive/007-init-wizard/plan.md similarity index 100% rename from specs/007-init-wizard/plan.md rename to specs/archive/007-init-wizard/plan.md diff --git a/specs/007-init-wizard/quickstart.md b/specs/archive/007-init-wizard/quickstart.md similarity index 100% rename from specs/007-init-wizard/quickstart.md rename to specs/archive/007-init-wizard/quickstart.md diff --git a/specs/007-init-wizard/research.md b/specs/archive/007-init-wizard/research.md similarity index 100% rename from specs/007-init-wizard/research.md rename to specs/archive/007-init-wizard/research.md diff --git a/specs/007-init-wizard/spec.md b/specs/archive/007-init-wizard/spec.md similarity index 100% rename from specs/007-init-wizard/spec.md rename to specs/archive/007-init-wizard/spec.md diff --git a/specs/007-init-wizard/tasks.md b/specs/archive/007-init-wizard/tasks.md similarity index 100% rename from specs/007-init-wizard/tasks.md rename to specs/archive/007-init-wizard/tasks.md diff --git a/specs/008-workspace-config/checklists/requirements.md b/specs/archive/008-workspace-config/checklists/requirements.md similarity index 100% rename from specs/008-workspace-config/checklists/requirements.md rename to specs/archive/008-workspace-config/checklists/requirements.md diff --git a/specs/008-workspace-config/plan.md b/specs/archive/008-workspace-config/plan.md similarity index 100% rename from specs/008-workspace-config/plan.md rename to specs/archive/008-workspace-config/plan.md diff --git a/specs/008-workspace-config/spec.md b/specs/archive/008-workspace-config/spec.md similarity index 100% rename from specs/008-workspace-config/spec.md rename to specs/archive/008-workspace-config/spec.md diff --git a/specs/008-workspace-config/tasks.md b/specs/archive/008-workspace-config/tasks.md similarity index 100% rename from specs/008-workspace-config/tasks.md rename to specs/archive/008-workspace-config/tasks.md diff --git a/specs/009-init-wizard-fix/plan.md b/specs/archive/009-init-wizard-fix/plan.md similarity index 100% rename from specs/009-init-wizard-fix/plan.md rename to specs/archive/009-init-wizard-fix/plan.md diff --git a/specs/009-init-wizard-fix/spec.md b/specs/archive/009-init-wizard-fix/spec.md similarity index 100% rename from specs/009-init-wizard-fix/spec.md rename to specs/archive/009-init-wizard-fix/spec.md diff --git a/specs/009-init-wizard-fix/tasks.md b/specs/archive/009-init-wizard-fix/tasks.md similarity index 100% rename from specs/009-init-wizard-fix/tasks.md rename to specs/archive/009-init-wizard-fix/tasks.md diff --git a/specs/009-service-catalog/plan.md b/specs/archive/009-service-catalog/plan.md similarity index 100% rename from specs/009-service-catalog/plan.md rename to specs/archive/009-service-catalog/plan.md diff --git a/specs/009-service-catalog/spec.md b/specs/archive/009-service-catalog/spec.md similarity index 100% rename from specs/009-service-catalog/spec.md rename to specs/archive/009-service-catalog/spec.md diff --git a/specs/009-service-catalog/tasks.md b/specs/archive/009-service-catalog/tasks.md similarity index 100% rename from specs/009-service-catalog/tasks.md rename to specs/archive/009-service-catalog/tasks.md diff --git a/specs/010-codebase-cleanup-and/ARCHITECTURE.md b/specs/archive/010-codebase-cleanup-and/ARCHITECTURE.md similarity index 100% rename from specs/010-codebase-cleanup-and/ARCHITECTURE.md rename to specs/archive/010-codebase-cleanup-and/ARCHITECTURE.md diff --git a/specs/010-codebase-cleanup-and/plan.md b/specs/archive/010-codebase-cleanup-and/plan.md similarity index 100% rename from specs/010-codebase-cleanup-and/plan.md rename to specs/archive/010-codebase-cleanup-and/plan.md diff --git a/specs/010-codebase-cleanup-and/spec.md b/specs/archive/010-codebase-cleanup-and/spec.md similarity index 100% rename from specs/010-codebase-cleanup-and/spec.md rename to specs/archive/010-codebase-cleanup-and/spec.md diff --git a/specs/010-codebase-cleanup-and/tasks.md b/specs/archive/010-codebase-cleanup-and/tasks.md similarity index 100% rename from specs/010-codebase-cleanup-and/tasks.md rename to specs/archive/010-codebase-cleanup-and/tasks.md diff --git a/specs/011-workspace-orchestration-deep/plan.md b/specs/archive/011-workspace-orchestration-deep/plan.md similarity index 100% rename from specs/011-workspace-orchestration-deep/plan.md rename to specs/archive/011-workspace-orchestration-deep/plan.md diff --git a/specs/011-workspace-orchestration-deep/quickstart.md b/specs/archive/011-workspace-orchestration-deep/quickstart.md similarity index 100% rename from specs/011-workspace-orchestration-deep/quickstart.md rename to specs/archive/011-workspace-orchestration-deep/quickstart.md diff --git a/specs/011-workspace-orchestration-deep/spec.md b/specs/archive/011-workspace-orchestration-deep/spec.md similarity index 100% rename from specs/011-workspace-orchestration-deep/spec.md rename to specs/archive/011-workspace-orchestration-deep/spec.md diff --git a/specs/012-ui-error-component/contracts/error-box-api.md b/specs/archive/012-ui-error-component/contracts/error-box-api.md similarity index 100% rename from specs/012-ui-error-component/contracts/error-box-api.md rename to specs/archive/012-ui-error-component/contracts/error-box-api.md diff --git a/specs/012-ui-error-component/data-model.md b/specs/archive/012-ui-error-component/data-model.md similarity index 100% rename from specs/012-ui-error-component/data-model.md rename to specs/archive/012-ui-error-component/data-model.md diff --git a/specs/012-ui-error-component/plan.md b/specs/archive/012-ui-error-component/plan.md similarity index 100% rename from specs/012-ui-error-component/plan.md rename to specs/archive/012-ui-error-component/plan.md diff --git a/specs/012-ui-error-component/spec.md b/specs/archive/012-ui-error-component/spec.md similarity index 100% rename from specs/012-ui-error-component/spec.md rename to specs/archive/012-ui-error-component/spec.md diff --git a/specs/012-ui-error-component/tasks.md b/specs/archive/012-ui-error-component/tasks.md similarity index 100% rename from specs/012-ui-error-component/tasks.md rename to specs/archive/012-ui-error-component/tasks.md diff --git a/specs/013-profile-tiers/INTEGRATION_GAPS.md b/specs/archive/013-profile-tiers/INTEGRATION_GAPS.md similarity index 100% rename from specs/013-profile-tiers/INTEGRATION_GAPS.md rename to specs/archive/013-profile-tiers/INTEGRATION_GAPS.md diff --git a/specs/013-profile-tiers/plan.md b/specs/archive/013-profile-tiers/plan.md similarity index 100% rename from specs/013-profile-tiers/plan.md rename to specs/archive/013-profile-tiers/plan.md diff --git a/specs/013-profile-tiers/spec.md b/specs/archive/013-profile-tiers/spec.md similarity index 100% rename from specs/013-profile-tiers/spec.md rename to specs/archive/013-profile-tiers/spec.md diff --git a/specs/013-profile-tiers/tasks.md b/specs/archive/013-profile-tiers/tasks.md similarity index 100% rename from specs/013-profile-tiers/tasks.md rename to specs/archive/013-profile-tiers/tasks.md diff --git a/specs/014-profile-init-wizard/checklists/requirements.md b/specs/archive/014-profile-init-wizard/checklists/requirements.md similarity index 100% rename from specs/014-profile-init-wizard/checklists/requirements.md rename to specs/archive/014-profile-init-wizard/checklists/requirements.md diff --git a/specs/014-profile-init-wizard/data-model.md b/specs/archive/014-profile-init-wizard/data-model.md similarity index 100% rename from specs/014-profile-init-wizard/data-model.md rename to specs/archive/014-profile-init-wizard/data-model.md diff --git a/specs/014-profile-init-wizard/plan.md b/specs/archive/014-profile-init-wizard/plan.md similarity index 100% rename from specs/014-profile-init-wizard/plan.md rename to specs/archive/014-profile-init-wizard/plan.md diff --git a/specs/014-profile-init-wizard/quickstart.md b/specs/archive/014-profile-init-wizard/quickstart.md similarity index 100% rename from specs/014-profile-init-wizard/quickstart.md rename to specs/archive/014-profile-init-wizard/quickstart.md diff --git a/specs/014-profile-init-wizard/research.md b/specs/archive/014-profile-init-wizard/research.md similarity index 100% rename from specs/014-profile-init-wizard/research.md rename to specs/archive/014-profile-init-wizard/research.md diff --git a/specs/014-profile-init-wizard/spec.md b/specs/archive/014-profile-init-wizard/spec.md similarity index 100% rename from specs/014-profile-init-wizard/spec.md rename to specs/archive/014-profile-init-wizard/spec.md diff --git a/specs/014-profile-init-wizard/tasks.md b/specs/archive/014-profile-init-wizard/tasks.md similarity index 100% rename from specs/014-profile-init-wizard/tasks.md rename to specs/archive/014-profile-init-wizard/tasks.md diff --git a/specs/015-ui-refactor/data-model.md b/specs/archive/015-ui-refactor/data-model.md similarity index 100% rename from specs/015-ui-refactor/data-model.md rename to specs/archive/015-ui-refactor/data-model.md diff --git a/specs/015-ui-refactor/plan.md b/specs/archive/015-ui-refactor/plan.md similarity index 100% rename from specs/015-ui-refactor/plan.md rename to specs/archive/015-ui-refactor/plan.md diff --git a/specs/015-ui-refactor/quickstart.md b/specs/archive/015-ui-refactor/quickstart.md similarity index 100% rename from specs/015-ui-refactor/quickstart.md rename to specs/archive/015-ui-refactor/quickstart.md diff --git a/specs/015-ui-refactor/research.md b/specs/archive/015-ui-refactor/research.md similarity index 100% rename from specs/015-ui-refactor/research.md rename to specs/archive/015-ui-refactor/research.md diff --git a/specs/015-ui-refactor/spec.md b/specs/archive/015-ui-refactor/spec.md similarity index 100% rename from specs/015-ui-refactor/spec.md rename to specs/archive/015-ui-refactor/spec.md diff --git a/specs/015-ui-refactor/tasks.md b/specs/archive/015-ui-refactor/tasks.md similarity index 100% rename from specs/015-ui-refactor/tasks.md rename to specs/archive/015-ui-refactor/tasks.md diff --git a/specs/016-ui-layout-fix/ARCHITECTURE.md b/specs/archive/016-ui-layout-fix/ARCHITECTURE.md similarity index 100% rename from specs/016-ui-layout-fix/ARCHITECTURE.md rename to specs/archive/016-ui-layout-fix/ARCHITECTURE.md diff --git a/specs/016-ui-layout-fix/COMMAND_UI_MAPPING.md b/specs/archive/016-ui-layout-fix/COMMAND_UI_MAPPING.md similarity index 100% rename from specs/016-ui-layout-fix/COMMAND_UI_MAPPING.md rename to specs/archive/016-ui-layout-fix/COMMAND_UI_MAPPING.md diff --git a/specs/016-ui-layout-fix/GH_DASH_RESEARCH.md b/specs/archive/016-ui-layout-fix/GH_DASH_RESEARCH.md similarity index 100% rename from specs/016-ui-layout-fix/GH_DASH_RESEARCH.md rename to specs/archive/016-ui-layout-fix/GH_DASH_RESEARCH.md diff --git a/specs/016-ui-layout-fix/PHASE_5_VISUAL_VALIDATION.md b/specs/archive/016-ui-layout-fix/PHASE_5_VISUAL_VALIDATION.md similarity index 100% rename from specs/016-ui-layout-fix/PHASE_5_VISUAL_VALIDATION.md rename to specs/archive/016-ui-layout-fix/PHASE_5_VISUAL_VALIDATION.md diff --git a/specs/016-ui-layout-fix/SESSION_CHECKPOINT.md b/specs/archive/016-ui-layout-fix/SESSION_CHECKPOINT.md similarity index 100% rename from specs/016-ui-layout-fix/SESSION_CHECKPOINT.md rename to specs/archive/016-ui-layout-fix/SESSION_CHECKPOINT.md diff --git a/specs/016-ui-layout-fix/archive/IMPLEMENTATION_PLAN.md b/specs/archive/016-ui-layout-fix/archive/IMPLEMENTATION_PLAN.md similarity index 100% rename from specs/016-ui-layout-fix/archive/IMPLEMENTATION_PLAN.md rename to specs/archive/016-ui-layout-fix/archive/IMPLEMENTATION_PLAN.md diff --git a/specs/016-ui-layout-fix/archive/PLAN.md b/specs/archive/016-ui-layout-fix/archive/PLAN.md similarity index 100% rename from specs/016-ui-layout-fix/archive/PLAN.md rename to specs/archive/016-ui-layout-fix/archive/PLAN.md diff --git a/specs/016-ui-layout-fix/archive/RESEARCH.md b/specs/archive/016-ui-layout-fix/archive/RESEARCH.md similarity index 100% rename from specs/016-ui-layout-fix/archive/RESEARCH.md rename to specs/archive/016-ui-layout-fix/archive/RESEARCH.md diff --git a/specs/016-ui-layout-fix/archive/quickstart.md b/specs/archive/016-ui-layout-fix/archive/quickstart.md similarity index 100% rename from specs/016-ui-layout-fix/archive/quickstart.md rename to specs/archive/016-ui-layout-fix/archive/quickstart.md diff --git a/specs/016-ui-layout-fix/archive/spec.md b/specs/archive/016-ui-layout-fix/archive/spec.md similarity index 100% rename from specs/016-ui-layout-fix/archive/spec.md rename to specs/archive/016-ui-layout-fix/archive/spec.md diff --git a/specs/016-ui-layout-fix/archive/tasks.md b/specs/archive/016-ui-layout-fix/archive/tasks.md similarity index 100% rename from specs/016-ui-layout-fix/archive/tasks.md rename to specs/archive/016-ui-layout-fix/archive/tasks.md diff --git a/specs/016-ui-layout-fix/checklists/profile-integration-checklist.md b/specs/archive/016-ui-layout-fix/checklists/profile-integration-checklist.md similarity index 100% rename from specs/016-ui-layout-fix/checklists/profile-integration-checklist.md rename to specs/archive/016-ui-layout-fix/checklists/profile-integration-checklist.md diff --git a/specs/016-ui-layout-fix/checklists/requirements.md b/specs/archive/016-ui-layout-fix/checklists/requirements.md similarity index 100% rename from specs/016-ui-layout-fix/checklists/requirements.md rename to specs/archive/016-ui-layout-fix/checklists/requirements.md diff --git a/specs/016-ui-layout-fix/pr-description.md b/specs/archive/016-ui-layout-fix/pr-description.md similarity index 100% rename from specs/016-ui-layout-fix/pr-description.md rename to specs/archive/016-ui-layout-fix/pr-description.md diff --git a/specs/017-ui-engine/.speckit.json b/specs/archive/017-ui-engine/.speckit.json similarity index 100% rename from specs/017-ui-engine/.speckit.json rename to specs/archive/017-ui-engine/.speckit.json diff --git a/specs/017-ui-engine/ANNOUNCEMENT.md b/specs/archive/017-ui-engine/ANNOUNCEMENT.md similarity index 100% rename from specs/017-ui-engine/ANNOUNCEMENT.md rename to specs/archive/017-ui-engine/ANNOUNCEMENT.md diff --git a/specs/017-ui-engine/IMPLEMENTATION_WORKFLOW.md b/specs/archive/017-ui-engine/IMPLEMENTATION_WORKFLOW.md similarity index 100% rename from specs/017-ui-engine/IMPLEMENTATION_WORKFLOW.md rename to specs/archive/017-ui-engine/IMPLEMENTATION_WORKFLOW.md diff --git a/specs/017-ui-engine/KNOWN_ISSUES.md b/specs/archive/017-ui-engine/KNOWN_ISSUES.md similarity index 100% rename from specs/017-ui-engine/KNOWN_ISSUES.md rename to specs/archive/017-ui-engine/KNOWN_ISSUES.md diff --git a/specs/017-ui-engine/MIGRATION.md b/specs/archive/017-ui-engine/MIGRATION.md similarity index 100% rename from specs/017-ui-engine/MIGRATION.md rename to specs/archive/017-ui-engine/MIGRATION.md diff --git a/specs/017-ui-engine/MILESTONE_FOUNDATION.md b/specs/archive/017-ui-engine/MILESTONE_FOUNDATION.md similarity index 100% rename from specs/017-ui-engine/MILESTONE_FOUNDATION.md rename to specs/archive/017-ui-engine/MILESTONE_FOUNDATION.md diff --git a/specs/017-ui-engine/MILESTONE_PHASE3.md b/specs/archive/017-ui-engine/MILESTONE_PHASE3.md similarity index 100% rename from specs/017-ui-engine/MILESTONE_PHASE3.md rename to specs/archive/017-ui-engine/MILESTONE_PHASE3.md diff --git a/specs/017-ui-engine/MILESTONE_PHASE4.md b/specs/archive/017-ui-engine/MILESTONE_PHASE4.md similarity index 100% rename from specs/017-ui-engine/MILESTONE_PHASE4.md rename to specs/archive/017-ui-engine/MILESTONE_PHASE4.md diff --git a/specs/017-ui-engine/MILESTONE_PHASE5.md b/specs/archive/017-ui-engine/MILESTONE_PHASE5.md similarity index 100% rename from specs/017-ui-engine/MILESTONE_PHASE5.md rename to specs/archive/017-ui-engine/MILESTONE_PHASE5.md diff --git a/specs/017-ui-engine/PHASE3-4_SUMMARY.md b/specs/archive/017-ui-engine/PHASE3-4_SUMMARY.md similarity index 100% rename from specs/017-ui-engine/PHASE3-4_SUMMARY.md rename to specs/archive/017-ui-engine/PHASE3-4_SUMMARY.md diff --git a/specs/017-ui-engine/checklists/requirements.md b/specs/archive/017-ui-engine/checklists/requirements.md similarity index 100% rename from specs/017-ui-engine/checklists/requirements.md rename to specs/archive/017-ui-engine/checklists/requirements.md diff --git a/specs/017-ui-engine/plan.md b/specs/archive/017-ui-engine/plan.md similarity index 100% rename from specs/017-ui-engine/plan.md rename to specs/archive/017-ui-engine/plan.md diff --git a/specs/017-ui-engine/quickstart.md b/specs/archive/017-ui-engine/quickstart.md similarity index 100% rename from specs/017-ui-engine/quickstart.md rename to specs/archive/017-ui-engine/quickstart.md diff --git a/specs/017-ui-engine/research.md b/specs/archive/017-ui-engine/research.md similarity index 100% rename from specs/017-ui-engine/research.md rename to specs/archive/017-ui-engine/research.md diff --git a/specs/017-ui-engine/spec.md b/specs/archive/017-ui-engine/spec.md similarity index 100% rename from specs/017-ui-engine/spec.md rename to specs/archive/017-ui-engine/spec.md diff --git a/specs/017-ui-engine/tasks.md b/specs/archive/017-ui-engine/tasks.md similarity index 100% rename from specs/017-ui-engine/tasks.md rename to specs/archive/017-ui-engine/tasks.md diff --git a/tests/integration/MANUAL_TESTING_SERVICES.md b/tests/integration/MANUAL_TESTING_SERVICES.md deleted file mode 100644 index 7bef25e..0000000 --- a/tests/integration/MANUAL_TESTING_SERVICES.md +++ /dev/null @@ -1,273 +0,0 @@ -# Manual Testing Checklist for Services Command (T115-T120) - -This checklist covers the interactive TUI features that are difficult to automate fully. -Run these tests manually to verify the complete user experience. - -## Prerequisites - -```bash -# Build the latest binary -make build - -# Or use go run for testing -alias arc-dev="go run ./cmd/arc" -``` - -## T115: New UI Test - -**Command:** -```bash -./arc services list -``` - -**Expected Behavior:** -- Interactive TUI with table view launches -- Search bar at the top with placeholder "Search services..." -- Data table in the middle with columns: Service (Technology), Role, Description -- Status bar at the bottom with keyboard shortcuts -- No tree emojis or legacy rendering - -**Visual Check:** -- [ ] Search bar is visible and styled correctly -- [ ] Table has clear borders and headers -- [ ] Status bar shows keyboard shortcuts (↑/↓, /, s, enter, q) -- [ ] All components are properly aligned -- [ ] Theme colors are applied correctly - -**Fallback Test:** -```bash -ARC_USE_LEGACY_UI=1 ./arc services list -``` -- [ ] Shows legacy tree view with emojis -- [ ] No interactive TUI - ---- - -## T116: JSON Output - -**Command:** -```bash -./arc services list --json | jq -``` - -**Expected Behavior:** -- Valid JSON output -- Pretty-printed with `jq` -- Contains `services` array and `total` count - -**Visual Check:** -- [ ] JSON is properly formatted -- [ ] Contains all expected fields: - - `services[]` with service objects - - `total` with count -- [ ] Service objects have: codename, technology, role, description, ports, etc. - -**Additional Test:** -```bash -./arc services list --role data --json | jq '.total' -``` -- [ ] Shows filtered count -- [ ] Only data services in array - ---- - -## T117: No Animation (Static Mode) - -**Command:** -```bash -./arc services list --no-tree -``` - -**Expected Behavior:** -- Static text output (no TUI) -- No tree characters (├──, └──) -- Simple list format -- No escape codes for colors/animation - -**Visual Check:** -- [ ] Plain text output -- [ ] No interactive cursor -- [ ] Services grouped by role -- [ ] Readable in non-TUI terminal - -**Combined Test:** -```bash -./arc services list --no-tree --json -``` -- [ ] JSON output (--json takes precedence) - ---- - -## T118: Search Functionality - -**Command:** -```bash -./arc services list -``` - -**Test Steps:** -1. Launch the TUI -2. Press `/` to focus search bar -3. Type `redis` -4. Observe table filtering in real-time -5. Press `Esc` to clear search -6. Press `/` again -7. Type `DATA` (uppercase) -8. Verify case-insensitive matching - -**Expected Behavior:** -- [ ] `/` key focuses search bar -- [ ] Cursor blinks in search bar -- [ ] Table filters as you type -- [ ] Filtered results show matching services -- [ ] Case-insensitive search works -- [ ] Search matches any column (name, role, technology) -- [ ] `Esc` clears the search -- [ ] All rows return after clearing search - -**Visual Check:** -- [ ] Search bar shows typed characters -- [ ] Table updates smoothly during typing -- [ ] Filtered count shown in status bar (if implemented) -- [ ] No performance lag while typing - ---- - -## T119: Detail Navigation - -**Command:** -```bash -./arc services list -``` - -**Test Steps:** -1. Launch the TUI -2. Use arrow keys (↑/↓) or `j/k` to navigate -3. Select a service (e.g., postgres) -4. Press `Enter` to view details -5. (Future) Press `Backspace` or `Esc` to return to list - -**Current Expected Behavior:** -- [ ] Arrow keys navigate through the list -- [ ] Selected row is highlighted -- [ ] `Enter` key is handled (no crash) -- [ ] Navigation wraps at top/bottom of list - -**Future Expected Behavior (Phase 4):** -- [ ] Detail view shows full service information -- [ ] Backspace returns to list view -- [ ] Previous selection is preserved - -**Visual Check:** -- [ ] Selection highlight is visible and styled -- [ ] Smooth navigation without flicker -- [ ] Keyboard shortcuts work consistently - ---- - -## T120: Large Dataset (Pagination) - -**Setup:** -Ensure the embedded catalog has 50+ services. Check with: -```bash -./arc services list --json | jq '.total' -``` - -**Test Steps:** -1. Launch the TUI with full catalog -2. Navigate through all services using arrow keys -3. Hold down arrow key to scroll quickly -4. Use `Page Down` / `Page Up` if supported -5. Search for a term that matches many services -6. Navigate through filtered results - -**Expected Behavior:** -- [ ] All services load without delay -- [ ] Smooth scrolling through 50+ entries -- [ ] No performance degradation -- [ ] Table pagination handles overflow gracefully -- [ ] Search filters large datasets quickly -- [ ] No memory issues or crashes - -**Performance Test:** -```bash -# Time the rendering -time ./arc services list --no-tree > /dev/null -``` -- [ ] Completes in < 1 second - -**Visual Check:** -- [ ] Scrolling is smooth (no stutter) -- [ ] Table viewport shows correct rows -- [ ] Row count is accurate in status bar -- [ ] No rendering artifacts during fast scrolling - ---- - -## Edge Cases - -### Empty Catalog -**Command:** -```bash -# Temporarily rename catalog to test -mv pkg/catalog/embedded/services.yaml pkg/catalog/embedded/services.yaml.bak -./arc services list -``` -**Expected:** -- [ ] Shows "No services found" message gracefully -- [ ] No panic or crash - -**Cleanup:** -```bash -mv pkg/catalog/embedded/services.yaml.bak pkg/catalog/embedded/services.yaml -``` - -### Very Small Terminal -**Command:** -```bash -# Resize terminal to 40x10 -./arc services list -``` -**Expected:** -- [ ] TUI adapts to small size -- [ ] Minimum dimensions enforced -- [ ] No layout breaking - -### Very Large Terminal -**Command:** -```bash -# Resize terminal to 200x60 -./arc services list -``` -**Expected:** -- [ ] TUI uses available space -- [ ] Content doesn't overflow -- [ ] Centered or left-aligned consistently - ---- - -## Summary Checklist - -Run through all tests and mark each section: - -- [ ] T115: New UI renders correctly -- [ ] T116: JSON output is valid -- [ ] T117: Static mode works (--no-tree) -- [ ] T118: Search functionality works -- [ ] T119: Navigation works (detail view pending) -- [ ] T120: Large dataset (50+ services) performs well - -## Notes - -Record any issues, unexpected behavior, or visual inconsistencies: - -``` -[Add notes here] -``` - -## Sign-off - -- Tester: _______________ -- Date: _______________ -- Build Version: _______________ -- Status: [ ] PASS [ ] FAIL (see notes) diff --git a/tests/integration/SERVICES_TEST_SUMMARY.md b/tests/integration/SERVICES_TEST_SUMMARY.md deleted file mode 100644 index dbc83af..0000000 --- a/tests/integration/SERVICES_TEST_SUMMARY.md +++ /dev/null @@ -1,176 +0,0 @@ -# Services Command Integration Tests Summary (T115-T120) - -## Overview - -This document summarizes the automated integration tests created for the services command (T115-T120) as part of Phase 5 visual validation. - -## Test Coverage - -### Automated Tests - -#### CLI-Level Integration Tests (`pkg/cli/services/integration_test.go`) - -**T115: Test `arc services` command with new UI** -- `TestCLI_ServicesCommand_NewUI/list_command_with_new_UI_mode_executes_without_error` -- `TestCLI_ServicesCommand_NewUI/list_command_without_legacy_UI_flag_uses_new_UI_path` -- Tests that the command executes without error in new UI mode -- Verifies fallback to legacy UI when app context is unavailable - -**T116: Test `arc services --json` command** -- `TestCLI_ServicesCommand_JSON/list_--json_produces_valid_JSON_output` -- `TestCLI_ServicesCommand_JSON/list_--json_with_role_filter_produces_valid_JSON` -- Validates JSON output structure and format -- Verifies presence of `services` array and `total` count -- Tests JSON output with role filters - -**T117: Test `arc services --no-tree` command** -- `TestCLI_ServicesCommand_NoTree/list_--no-tree_produces_static_output` -- `TestCLI_ServicesCommand_NoTree/list_--no-tree_--json_still_produces_JSON` -- Tests static output mode without tree characters -- Verifies that `--json` takes precedence over `--no-tree` - -**T120: Test with 50+ services (pagination) - CLI Level** -- `TestCLI_ServicesCommand_LargeDataset/list_handles_catalog_with_many_services` -- `TestCLI_ServicesCommand_LargeDataset/list_--json_handles_large_datasets_efficiently` -- Tests catalog with 29 services (current embedded catalog size) -- Validates JSON output with large datasets - -#### View-Level Integration Tests (`pkg/ui/views/serviceslistview_test.go`) - -**T118: Test search functionality (type "/redis")** -- `TestServicesListView_SearchFunctionality_T118/forward_slash_focuses_search_bar` -- `TestServicesListView_SearchFunctionality_T118/typing_filters_table_in_real-time` -- `TestServicesListView_SearchFunctionality_T118/clearing_search_restores_all_rows` -- `TestServicesListView_SearchFunctionality_T118/search_is_case-insensitive` -- `TestServicesListView_SearchFunctionality_T118/search_matches_any_column` -- Tests search bar focus with `/` key -- Validates real-time filtering as user types -- Tests case-insensitive search across all columns -- Verifies search clearing with Esc key - -**T119: Test detail navigation (select service, press Enter, press Backspace)** -- `TestServicesListView_DetailNavigation_T119/enter_key_triggers_navigation` -- `TestServicesListView_DetailNavigation_T119/backspace_in_search_bar_deletes_characters` -- `TestServicesListView_DetailNavigation_T119/esc_key_clears_search_when_focused` -- `TestServicesListView_DetailNavigation_T119/arrow_keys_navigate_table_when_search_unfocused` -- Tests Enter key navigation (detail view pending Phase 4) -- Validates backspace behavior in search bar -- Tests keyboard navigation with arrow keys -- Verifies Esc key clears search when focused - -**T120: Test with 50+ services (pagination) - View Level** -- `TestServicesListView_LargeDataset_Pagination_T120/handles_100_services_without_performance_issues` -- `TestServicesListView_LargeDataset_Pagination_T120/navigation_works_smoothly_with_large_dataset` -- `TestServicesListView_LargeDataset_Pagination_T120/search_filtering_works_efficiently_with_large_dataset` -- `TestServicesListView_LargeDataset_Pagination_T120/rendering_large_dataset_completes_without_panic` -- `TestServicesListView_LargeDataset_Pagination_T120/status_bar_shows_filtered_count_for_large_dataset` -- Tests with 100-200 service mock datasets -- Validates smooth navigation through large lists -- Tests search filtering performance with large datasets -- Ensures no panics or memory issues - -## Test Execution - -### Running All T115-T120 Tests - -```bash -# Run all services integration tests -go test -v ./pkg/cli/services/... -run "T115|T116|T117|T120" - -# Run all view-level tests -go test -v ./pkg/ui/views/... -run "T118|T119|T120" - -# Run everything together -go test ./pkg/cli/services/... ./pkg/ui/views/... -v -run "T115|T116|T117|T118|T119|T120" -``` - -### Test Results - -All automated tests pass successfully: - -``` -pkg/cli/services/integration_test.go -✓ TestCLI_ServicesCommand_NewUI (2 subtests) -✓ TestCLI_ServicesCommand_JSON (2 subtests) -✓ TestCLI_ServicesCommand_NoTree (2 subtests) -✓ TestCLI_ServicesCommand_LargeDataset (2 subtests) - -pkg/ui/views/serviceslistview_test.go -✓ TestServicesListView_SearchFunctionality_T118 (5 subtests) -✓ TestServicesListView_DetailNavigation_T119 (4 subtests) -✓ TestServicesListView_LargeDataset_Pagination_T120 (5 subtests) - -Total: 6 test functions, 22 subtests, all PASS -``` - -## Manual Testing - -For features that require interactive TUI testing, see: -- `/Users/dgtalbug/Workspace/arc/cli/tests/integration/MANUAL_TESTING_SERVICES.md` - -This checklist covers: -- Visual validation of TUI components -- Real-time interaction testing -- Edge cases with terminal resizing -- Performance testing with user interaction - -## Test Coverage by Task - -| Task | Description | Automated | Manual | -|------|-------------|-----------|--------| -| T115 | Test `arc services` command with new UI | ✓ | ✓ | -| T116 | Test `arc services --json` command | ✓ | ✓ | -| T117 | Test `arc services --no-tree` command | ✓ | ✓ | -| T118 | Test search functionality (type "/redis") | ✓ | ✓ | -| T119 | Test detail navigation (select, Enter, Backspace) | Partial* | ✓ | -| T120 | Test with 50+ services (pagination) | ✓ | ✓ | - -*T119 is partially automated because detail view navigation will be implemented in Phase 4. -Current tests verify keyboard handling and navigation without crashing. - -## Files Created/Modified - -### New Files -1. `/Users/dgtalbug/Workspace/arc/cli/tests/integration/MANUAL_TESTING_SERVICES.md` - - Comprehensive manual testing checklist - - Visual validation steps - - Edge case testing - -2. `/Users/dgtalbug/Workspace/arc/cli/tests/integration/SERVICES_TEST_SUMMARY.md` (this file) - - Test coverage summary - - Execution instructions - -### Modified Files -1. `/Users/dgtalbug/Workspace/arc/cli/pkg/cli/services/integration_test.go` - - Added T115-T117, T120 CLI-level tests - - Added JSON output validation tests - - Added large dataset tests - -2. `/Users/dgtalbug/Workspace/arc/cli/pkg/ui/views/serviceslistview_test.go` - - Added T118 search functionality tests - - Added T119 navigation tests - - Added T120 large dataset view tests - -## Next Steps - -1. Run manual testing checklist to validate TUI interactions -2. Document any visual issues or performance concerns -3. Update tests as detail view navigation is implemented in Phase 4 -4. Consider adding benchmark tests for large datasets if performance issues arise - -## Success Criteria Met - -- [x] All automated tests pass -- [x] T115-T117 CLI flag tests implemented -- [x] T118 search functionality thoroughly tested -- [x] T119 navigation basics tested (detail view pending) -- [x] T120 large dataset tests with 100+ services -- [x] JSON output validation -- [x] Manual testing checklist provided -- [x] Test documentation complete - -## Notes - -- The embedded catalog currently has 29 services, which is below the 50+ target for T120. The view-level tests use mock data with 100-200 services to ensure pagination handling is robust. -- Detail view navigation (T119) will be fully testable once Phase 4 implements the detail view. Current tests verify the Enter key is handled without crashing. -- All tests follow the existing test patterns in the codebase (e.g., using Update with KeyDown for navigation instead of non-existent SetCursor methods). diff --git a/tests/integration/json_output_test.go b/tests/integration/json_output_test.go deleted file mode 100644 index 896d479..0000000 --- a/tests/integration/json_output_test.go +++ /dev/null @@ -1,215 +0,0 @@ -// Package integration provides integration tests for the ARC CLI UI navigation system. -package integration - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/views" -) - -// createTestFactory builds a ComponentFactory using the default Enterprise profile -// and BorderTierNone (borderless), suitable for unit and integration tests. -func createTestFactory(t *testing.T) ui.ComponentFactory { - t.Helper() - profileCtx := profiles.GetDefaultProfileContext() - return ui.NewComponentFactory(profileCtx, components.BorderTierNone) -} - -// createTestViewContext builds a ViewContext with sensible defaults for tests. -func createTestViewContext(t *testing.T, args map[string]any) *engine.ViewContext { - t.Helper() - profileCtx := profiles.GetDefaultProfileContext() - if args == nil { - args = make(map[string]any) - } - return engine.NewViewContext(profileCtx.Profile(), profileCtx.Theme(), 80, 24, args) -} - -// marshalToMap marshals v to JSON then unmarshals into a map[string]any. -// Fails the test if marshal or unmarshal fails. -func marshalToMap(t *testing.T, v any) map[string]any { - t.Helper() - data, err := json.Marshal(v) - require.NoError(t, err, "json.Marshal should not error") - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result), "json.Unmarshal should not error") - return result -} - -// T248: TestServicesListView_JSON verifies that ServicesListView.ToJSON() returns -// a JSON-marshallable payload with a "services" key containing an array. -func TestServicesListView_JSON(t *testing.T) { - factory := createTestFactory(t) - view := views.NewServicesListView(factory) - - // OnEnter populates allRows (used by ToJSON) - ctx := createTestViewContext(t, nil) - view.OnEnter(ctx) - - jsonData := view.ToJSON() - require.NotNil(t, jsonData, "ToJSON should return non-nil") - - result := marshalToMap(t, jsonData) - - assert.Contains(t, result, "services", "JSON output must contain 'services' key") - - services, ok := result["services"] - require.True(t, ok, "'services' key must be present") - - servicesSlice, ok := services.([]any) - require.True(t, ok, "'services' value must be an array/slice") - assert.NotEmpty(t, servicesSlice, "'services' array should not be empty (fallback mock data is loaded in OnEnter)") - - assert.Contains(t, result, "count", "JSON output must contain 'count' key") -} - -// T249: TestServiceDetailView_JSON verifies that ServiceDetailView.ToJSON() returns -// a JSON-marshallable payload with a "name" key at the top level. -func TestServiceDetailView_JSON(t *testing.T) { - factory := createTestFactory(t) - view := views.NewServiceDetailView(factory) - - // Provide a serviceName via context args so OnEnter sets v.serviceName - ctx := createTestViewContext(t, map[string]any{"serviceName": "postgres"}) - view.OnEnter(ctx) - - jsonData := view.ToJSON() - require.NotNil(t, jsonData, "ToJSON should return non-nil") - - result := marshalToMap(t, jsonData) - - assert.Contains(t, result, "name", "JSON output must contain 'name' key") - assert.Equal(t, "postgres", result["name"], "'name' must match the serviceName passed in args") -} - -// T250: TestInfoView_JSON verifies that InfoView.ToJSON() returns a JSON-marshallable -// payload. When no systemInfo is provided (nil), the view returns an "error" key. -// When systemInfo is available, it returns a "cli" key at the top level. -func TestInfoView_JSON(t *testing.T) { - factory := createTestFactory(t) - view := views.NewInfoView(factory) - - // Call OnEnter without systemInfo — simulates the nil-systemInfo code path - ctx := createTestViewContext(t, nil) - view.OnEnter(ctx) - - jsonData := view.ToJSON() - require.NotNil(t, jsonData, "ToJSON should return non-nil") - - result := marshalToMap(t, jsonData) - - // When systemInfo is nil, InfoView.ToJSON returns {"error": "no system information available"} - assert.Contains(t, result, "error", "JSON output should contain 'error' key when no systemInfo is provided") - assert.Equal( - t, - "no system information available", - result["error"], - "error message should match the documented fallback", - ) -} - -// T251: TestVersionView_JSON verifies that VersionView.ToJSON() returns valid JSON -// with a "version" key at the top level. -func TestVersionView_JSON(t *testing.T) { - factory := createTestFactory(t) - view := views.NewVersionView(factory, "1.2.3", "abc1234", "2026-01-01T00:00:00Z", false) - - // OnEnter initializes the status bar and stores the theme - ctx := createTestViewContext(t, nil) - view.OnEnter(ctx) - - jsonData := view.ToJSON() - require.NotNil(t, jsonData, "ToJSON should return non-nil") - - // Ensure the value can be marshaled to valid JSON - raw, err := json.Marshal(jsonData) - require.NoError(t, err, "json.Marshal should succeed") - assert.True(t, json.Valid(raw), "marshaled output must be valid JSON") - - result := marshalToMap(t, jsonData) - - assert.Contains(t, result, "version", "JSON output must contain 'version' key") - assert.Equal(t, "1.2.3", result["version"], "'version' must match the value passed to NewVersionView") - - assert.Contains(t, result, "commit", "JSON output must contain 'commit' key") - assert.Contains(t, result, "buildDate", "JSON output must contain 'buildDate' key") - assert.Contains(t, result, "goVersion", "JSON output must contain 'goVersion' key") -} - -// T252: TestJSON_Parseability verifies that each view's ToJSON() output can be -// successfully marshaled to JSON without any errors. -func TestJSON_Parseability(t *testing.T) { - factory := createTestFactory(t) - ctx := createTestViewContext(t, nil) - - type viewCase struct { - name string - getData func() any - } - - servicesView := views.NewServicesListView(factory) - servicesView.OnEnter(ctx) - - detailView := views.NewServiceDetailView(factory) - detailView.OnEnter(createTestViewContext(t, map[string]any{"serviceName": "redis"})) - - infoView := views.NewInfoView(factory) - infoView.OnEnter(ctx) - - versionView := views.NewVersionView(factory, "0.9.0", "deadbeef", "2026-02-28T12:00:00Z", true) - versionView.OnEnter(ctx) - - homeView := views.NewHomeView(factory) - homeView.OnEnter(ctx) - - dashboardView := views.NewDashboardView(factory) - dashboardView.OnEnter(ctx) - - cases := []viewCase{ - {"ServicesListView", servicesView.ToJSON}, - {"ServiceDetailView", detailView.ToJSON}, - {"InfoView", infoView.ToJSON}, - {"VersionView", versionView.ToJSON}, - {"HomeView", homeView.ToJSON}, - {"DashboardView", dashboardView.ToJSON}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - data := tc.getData() - require.NotNil(t, data, "ToJSON must return non-nil value") - - raw, err := json.Marshal(data) - require.NoError(t, err, "json.Marshal must not return an error") - assert.True(t, json.Valid(raw), "marshaled output must be valid JSON") - }) - } -} - -// T253: TestJSONMode_RequiresJSONExporter verifies at compile time that all views -// implement the engine.JSONExporter interface. This is a static type-assertion test — -// if any view does not implement ToJSON() any, the file will not compile. -func TestJSONMode_RequiresJSONExporter(t *testing.T) { - factory := createTestFactory(t) - - // Compile-time interface assertions: these assignments fail to compile if - // the concrete type does not satisfy engine.JSONExporter. - var _ engine.JSONExporter = views.NewServicesListView(factory) - var _ engine.JSONExporter = views.NewServiceDetailView(factory) - var _ engine.JSONExporter = views.NewInfoView(factory) - var _ engine.JSONExporter = views.NewVersionView(factory, "", "", "", false) - var _ engine.JSONExporter = views.NewHomeView(factory) - var _ engine.JSONExporter = views.NewDashboardView(factory) - - // If we reached this point the compile-time assertions all passed. - t.Log("All views correctly implement engine.JSONExporter") -} diff --git a/tests/integration/navigation_test.go b/tests/integration/navigation_test.go deleted file mode 100644 index 8e273d0..0000000 --- a/tests/integration/navigation_test.go +++ /dev/null @@ -1,262 +0,0 @@ -// Package integration provides integration tests for the ARC CLI UI navigation system. -package integration - -import ( - "testing" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -// mockNavView is a test view that tracks OnEnter/OnExit lifecycle calls -// and any args passed during navigation. -type mockNavView struct { - name string - enterCount int - exitCount int - lastArgs map[string]any - lastCtx *engine.ViewContext -} - -func newMockNavView(name string) *mockNavView { - return &mockNavView{name: name} -} - -func (m *mockNavView) Init() tea.Cmd { return nil } - -func (m *mockNavView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - -func (m *mockNavView) View() string { return "view:" + m.name } - -func (m *mockNavView) OnEnter(ctx *engine.ViewContext) tea.Cmd { - m.enterCount++ - m.lastCtx = ctx - if ctx != nil { - m.lastArgs = ctx.Args - } - return nil -} - -func (m *mockNavView) OnExit() tea.Cmd { - m.exitCount++ - return nil -} - -func (m *mockNavView) Name() string { return m.name } - -func (m *mockNavView) Keybindings() []engine.KeyBinding { - return []engine.KeyBinding{{Key: "q", Description: "quit"}} -} - -// createTestRouter builds a Router using the default Enterprise profile. -func createTestRouter(t *testing.T) *engine.Router { - t.Helper() - profileCtx := profiles.GetDefaultProfileContext() - return engine.NewRouter(profileCtx.Profile(), profileCtx.Theme()) -} - -// T230: TestHomeToDashboardNavigation verifies registering home and dashboard views, -// navigating home → dashboard, and that history is correctly maintained. -func TestHomeToDashboardNavigation(t *testing.T) { - router := createTestRouter(t) - - homeView := newMockNavView("home") - dashboardView := newMockNavView("dashboard") - - router.Register(homeView) - router.Register(dashboardView) - - // Navigate to home first - err := router.Navigate("home", nil) - require.NoError(t, err, "navigate to home should succeed") - - assert.Equal(t, 1, homeView.enterCount, "home OnEnter should be called once") - assert.Equal(t, 0, homeView.exitCount, "home OnExit should not be called yet") - assert.Equal(t, homeView, router.Current()) - assert.Empty(t, router.History(), "history should be empty after first navigation") - - // Navigate to dashboard - err = router.Navigate("dashboard", nil) - require.NoError(t, err, "navigate to dashboard should succeed") - - assert.Equal(t, 1, homeView.exitCount, "home OnExit should be called") - assert.Equal(t, 1, dashboardView.enterCount, "dashboard OnEnter should be called once") - assert.Equal(t, dashboardView, router.Current()) - assert.Equal(t, []string{"home"}, router.History(), "history should contain 'home'") -} - -// T231: TestDashboardServicesDetailBackFlow verifies a multi-step navigation flow: -// home → services → detail → back (returns to services) → back (returns to home). -func TestDashboardServicesDetailBackFlow(t *testing.T) { - router := createTestRouter(t) - - homeView := newMockNavView("home") - servicesView := newMockNavView("services") - detailView := newMockNavView("detail") - - router.Register(homeView) - router.Register(servicesView) - router.Register(detailView) - - // Step 1: Navigate to home - require.NoError(t, router.Navigate("home", nil)) - assert.Equal(t, homeView, router.Current()) - assert.Empty(t, router.History()) - - // Step 2: Navigate to services - require.NoError(t, router.Navigate("services", nil)) - assert.Equal(t, servicesView, router.Current()) - assert.Equal(t, []string{"home"}, router.History()) - - // Step 3: Navigate to detail - require.NoError(t, router.Navigate("detail", map[string]any{"serviceName": "postgres"})) - assert.Equal(t, detailView, router.Current()) - assert.Equal(t, []string{"home", "services"}, router.History()) - - // Verify detail view received the args - assert.Equal(t, "postgres", detailView.lastArgs["serviceName"]) - - // Step 4: Back → should return to services - require.NoError(t, router.Back()) - assert.Equal(t, servicesView, router.Current(), "back should return to services") - assert.Equal(t, []string{"home"}, router.History(), "history should pop detail") - - // Step 5: Back → should return to home - require.NoError(t, router.Back()) - assert.Equal(t, homeView, router.Current(), "back should return to home") - assert.Empty(t, router.History(), "history should be empty") - - // Step 6: Back → no-op (already at home with empty history) - require.NoError(t, router.Back()) - assert.Equal(t, homeView, router.Current(), "back from home should be no-op") -} - -// T232: TestKeyboardShortcutNavigation verifies navigation with args representing -// keyboard shortcut destinations (d=dashboard, i=info, h=help). -func TestKeyboardShortcutNavigation(t *testing.T) { - router := createTestRouter(t) - - homeView := newMockNavView("home") - dashboardView := newMockNavView("dashboard") - infoView := newMockNavView("info") - helpView := newMockNavView("help") - - router.Register(homeView) - router.Register(dashboardView) - router.Register(infoView) - router.Register(helpView) - - // Start at home - require.NoError(t, router.Navigate("home", nil)) - - // Simulate keyboard shortcut 'd' → navigate to dashboard with shortcut arg - require.NoError(t, router.Navigate("dashboard", map[string]any{"shortcut": "d"})) - assert.Equal(t, dashboardView, router.Current()) - assert.Equal(t, "d", dashboardView.lastArgs["shortcut"]) - - // Simulate keyboard shortcut 'i' → navigate to info - require.NoError(t, router.Navigate("info", map[string]any{"shortcut": "i"})) - assert.Equal(t, infoView, router.Current()) - assert.Equal(t, "i", infoView.lastArgs["shortcut"]) - - // Simulate keyboard shortcut 'h' → navigate to help - require.NoError(t, router.Navigate("help", map[string]any{"shortcut": "h"})) - assert.Equal(t, helpView, router.Current()) - assert.Equal(t, "h", helpView.lastArgs["shortcut"]) - - // Verify history captured the route - history := router.History() - assert.Contains(t, history, "home", "history should include home") - assert.Contains(t, history, "dashboard", "history should include dashboard") - assert.Contains(t, history, "info", "history should include info") -} - -// T233: TestBackNavigationPreservesState verifies that navigating forward with args -// (e.g. a searchTerm), then going back, preserves the history entry so the args -// remain accessible from the history stack. -func TestBackNavigationPreservesState(t *testing.T) { - router := createTestRouter(t) - - listView := newMockNavView("list") - detailView := newMockNavView("detail") - - router.Register(listView) - router.Register(detailView) - - // Navigate to list - require.NoError(t, router.Navigate("list", nil)) - - // Navigate to detail with a searchTerm arg - searchArgs := map[string]any{"searchTerm": "postgres", "page": 2} - require.NoError(t, router.Navigate("detail", searchArgs)) - - // Verify detail received args - assert.Equal(t, "postgres", detailView.lastArgs["searchTerm"]) - assert.Equal(t, 2, detailView.lastArgs["page"]) - - // History should contain "list" - assert.Equal(t, []string{"list"}, router.History()) - - // Navigate back to list - require.NoError(t, router.Back()) - assert.Equal(t, listView, router.Current()) - - // History should be empty (we went back) - assert.Empty(t, router.History()) - - // listView should have been re-entered (OnEnter called again on back) - assert.Equal(t, 2, listView.enterCount, - "list view OnEnter should be called twice: initial navigate + back") - - // detailView should have been exited when going back - assert.Equal(t, 1, detailView.exitCount, - "detail view OnExit should be called once when navigating back") -} - -// T236: BenchmarkNavigationLatency measures the latency of Router.Navigate calls -// and asserts that each navigation completes within 16ms (single frame budget). -func BenchmarkNavigationLatency(b *testing.B) { - profileCtx := profiles.GetDefaultProfileContext() - router := engine.NewRouter(profileCtx.Profile(), profileCtx.Theme()) - - viewA := newMockNavView("view-a") - viewB := newMockNavView("view-b") - router.Register(viewA) - router.Register(viewB) - - // Warm up: ensure first navigation is not measured cold - _ = router.Navigate("view-a", nil) - - b.ResetTimer() - - var totalDuration time.Duration - - for i := 0; i < b.N; i++ { - start := time.Now() - - if i%2 == 0 { - _ = router.Navigate("view-b", nil) - } else { - _ = router.Navigate("view-a", nil) - } - - elapsed := time.Since(start) - totalDuration += elapsed - - // Assert each individual navigation is within 16ms - if elapsed > 16*time.Millisecond { - b.Errorf("navigation %d exceeded 16ms budget: %v", i, elapsed) - } - } - - if b.N > 0 { - avgLatency := totalDuration / time.Duration(b.N) - b.ReportMetric(float64(avgLatency.Nanoseconds()), "ns/navigate") - b.ReportMetric(float64(avgLatency.Microseconds()), "µs/navigate") - } -} diff --git a/tests/integration/profile_integration_test.go b/tests/integration/profile_integration_test.go deleted file mode 100644 index a71fe13..0000000 --- a/tests/integration/profile_integration_test.go +++ /dev/null @@ -1,552 +0,0 @@ -package integration - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/internal/preferences" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -// Integration tests for profile system -// Target coverage: 70%+ -// -// Spec: 014-profile-init-wizard -// Test requirements from T009, T024, T025, T038, T044, T052, T054, T059, T060 - -// T024: Integration test for fresh install -// Verifies that a fresh install (no state.json) defaults to Enterprise profile - -func TestFreshInstall_DefaultsToEnterprise(t *testing.T) { - t.Run("no state.json returns Enterprise via GetProfileContext", func(t *testing.T) { - // Setup temp home - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - // Verify no state.json exists - stateFile := filepath.Join(tempHome, ".arc", "state.json") - _, err := os.Stat(stateFile) - require.True(t, os.IsNotExist(err), "state.json should not exist on fresh install") - - // Load ProfileContext from preferences - ctx := profiles.LoadProfileContextFromPreferences() - require.NotNil(t, ctx, "ProfileContext should not be nil") - - // Verify it's the Enterprise profile - profile := ctx.Profile() - require.NotNil(t, profile, "Profile should not be nil") - assert.Equal(t, "enterprise", profile.ID, "should default to enterprise profile") - assert.Equal(t, "Enterprise", profile.Name, "should have Enterprise name") - - // Verify Enterprise tier names - tierNames := ctx.TierNames() - require.Len(t, tierNames, 3, "should have 3 tier names") - assert.Equal(t, "Starter", tierNames[0], "tier 0 should be Starter") - assert.Equal(t, "Pro", tierNames[1], "tier 1 should be Pro") - assert.Equal(t, "Ultra", tierNames[2], "tier 2 should be Ultra") - }) - - t.Run("empty Profile field defaults to Enterprise", func(t *testing.T) { - // Setup temp home - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - // Create state.json with empty profile field - prefs := &preferences.Preferences{ - Theme: "cyan-purple", - Profile: "", // Explicitly empty - } - err := prefs.Save() - require.NoError(t, err) - - // Load ProfileContext - ctx := profiles.LoadProfileContextFromPreferences() - require.NotNil(t, ctx) - - // Verify Enterprise default - profile := ctx.Profile() - require.NotNil(t, profile) - assert.Equal(t, "enterprise", profile.ID, "empty profile should default to enterprise") - }) - - t.Run("invalid profile ID falls back to Enterprise", func(t *testing.T) { - // Setup temp home - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - // Create state.json with invalid profile ID - prefs := &preferences.Preferences{ - Theme: "cyan-purple", - Profile: "invalid-profile-id-that-does-not-exist", - } - err := prefs.Save() - require.NoError(t, err) - - // Load ProfileContext - ctx := profiles.LoadProfileContextFromPreferences() - require.NotNil(t, ctx, "should fallback to Enterprise instead of nil") - - // Verify Enterprise fallback - profile := ctx.Profile() - require.NotNil(t, profile) - assert.Equal(t, "enterprise", profile.ID, "invalid profile should fallback to enterprise") - }) -} - -// T025: E2E test for default branding -// Verifies that default Enterprise branding is used across the CLI - -func TestDefaultBranding_EnterpriseTierNames(t *testing.T) { - t.Run("default profile shows Enterprise tier names", func(t *testing.T) { - // Setup temp home - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - // Load ProfileContext (should default to Enterprise) - ctx := profiles.LoadProfileContextFromPreferences() - require.NotNil(t, ctx) - - // Verify tier names match Enterprise profile - tierNames := ctx.TierNames() - require.Len(t, tierNames, 3) - assert.Equal(t, "Starter", tierNames[0], "tier 0 should be Starter (not Super Saiyan)") - assert.Equal(t, "Pro", tierNames[1], "tier 1 should be Pro (not Super Saiyan Blue)") - assert.Equal(t, "Ultra", tierNames[2], "tier 2 should be Ultra (not Ultra Instinct)") - }) - - t.Run("default profile has cyan-purple theme", func(t *testing.T) { - // Setup temp home - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - // Load preferences - prefs, err := preferences.Load() - require.NoError(t, err) - - // Default theme should be cyan-purple - assert.Equal(t, "cyan-purple", prefs.GetTheme(), "default theme should be cyan-purple") - }) - - t.Run("default profile has Enterprise banner logo", func(t *testing.T) { - // Setup temp home - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - // Load ProfileContext - ctx := profiles.LoadProfileContextFromPreferences() - require.NotNil(t, ctx) - - // Verify banner logo is present - logo := ctx.BannerLogo() - assert.NotEmpty(t, logo, "Enterprise profile should have a banner logo") - // Logo should contain Enterprise branding text - assert.Contains(t, logo, "Agentic Reasoning Core", "banner should contain Agentic Reasoning Core text") - }) -} - -// NOTE: Tests for theme sync (T044) will be added in User Story 3 (Phase 5) -// when SetProfileWithThemeSync is implemented. These tests are currently commented out -// to avoid dependencies on unimplemented functionality. - -// T052: Integration test for init wizard flow with profile selection -// Tests that profile selection in init wizard correctly updates tier names - -func TestInitWizard_ProfileSelection_UpdatesTierNames(t *testing.T) { - t.Run("jedi profile shows correct tier names", func(t *testing.T) { - // Setup temp home - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - // Verify jedi profile exists - repo, err := profiles.NewRepository() - require.NoError(t, err, "repository should be created") - - jediProfile, err := repo.GetByID("jedi") - require.NoError(t, err, "jedi profile should exist") - - // Verify tier names - require.Len(t, jediProfile.TierNames, 3, "jedi profile should have 3 tier names") - assert.Equal(t, "Padawan", jediProfile.TierNames[0], "tier 0 should be Padawan") - assert.Equal(t, "Knight", jediProfile.TierNames[1], "tier 1 should be Knight") - assert.Equal(t, "Master", jediProfile.TierNames[2], "tier 2 should be Master") - - // Load profile context - ctx := profiles.LoadProfileContext("jedi") - require.NotNil(t, ctx) - - // Verify tier names via context - tierNames := ctx.TierNames() - assert.Equal(t, "Padawan", tierNames[0]) - assert.Equal(t, "Knight", tierNames[1]) - assert.Equal(t, "Master", tierNames[2]) - }) - - t.Run("enterprise profile shows correct tier names", func(t *testing.T) { - // Setup temp home - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - // Load enterprise profile - ctx := profiles.LoadProfileContext("enterprise") - require.NotNil(t, ctx) - - // Verify tier names - tierNames := ctx.TierNames() - require.Len(t, tierNames, 3) - assert.Equal(t, "Starter", tierNames[0]) - assert.Equal(t, "Pro", tierNames[1]) - assert.Equal(t, "Ultra", tierNames[2]) - }) - - t.Run("fallback to Tier 1/2/3 when profile fails", func(t *testing.T) { - // Setup temp home - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - // Try to load non-existent profile - // This should fallback to Enterprise, not generic tier names - ctx := profiles.LoadProfileContext("non-existent-profile") - require.NotNil(t, ctx) - - // Verify it fell back to Enterprise - profile := ctx.Profile() - require.NotNil(t, profile) - assert.Equal(t, "enterprise", profile.ID, "should fallback to enterprise") - - tierNames := ctx.TierNames() - assert.Equal(t, "Starter", tierNames[0], "should use Enterprise tier names after fallback") - }) -} - -func TestInitWizard_ProfileContext_Integration(t *testing.T) { - t.Run("different profiles have distinct tier names", func(t *testing.T) { - // Setup temp home - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - repo, err := profiles.NewRepository() - require.NoError(t, err) - - // Test a few profiles to ensure they have distinct tier names - testCases := []struct { - profileID string - expectedTier0 string - expectedTier1 string - expectedTier2 string - }{ - {"enterprise", "Starter", "Pro", "Ultra"}, - {"jedi", "Padawan", "Knight", "Master"}, - {"saiyan", "Super Saiyan", "Super Saiyan Blue", "Ultra Instinct"}, - } - - for _, tc := range testCases { - t.Run(tc.profileID, func(t *testing.T) { - profile, err := repo.GetByID(tc.profileID) - require.NoError(t, err, "profile %s should exist", tc.profileID) - - require.Len(t, profile.TierNames, 3) - assert.Equal(t, tc.expectedTier0, profile.TierNames[0]) - assert.Equal(t, tc.expectedTier1, profile.TierNames[1]) - assert.Equal(t, tc.expectedTier2, profile.TierNames[2]) - }) - } - }) - - t.Run("profile context caching works correctly", func(t *testing.T) { - // Setup temp home - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - // Load the same profile twice - ctx1 := profiles.LoadProfileContext("jedi") - ctx2 := profiles.LoadProfileContext("jedi") - - // Both should be valid - require.NotNil(t, ctx1) - require.NotNil(t, ctx2) - - // Both should have the same tier names - assert.Equal(t, ctx1.TierNames(), ctx2.TierNames()) - }) -} - -// T038: Integration test for profile banner change -// Verifies that banner changes correctly when profile changes - -func TestProfileBannerChange(t *testing.T) { - t.Run("jedi profile shows Jedi banner", func(t *testing.T) { - // Setup temp home - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - // Load jedi profile - ctx := profiles.LoadProfileContext("jedi") - require.NotNil(t, ctx) - - // Verify profile is jedi - profile := ctx.Profile() - require.NotNil(t, profile) - assert.Equal(t, "jedi", profile.ID) - - // Verify banner logo contains jedi-specific content - logo := ctx.BannerLogo() - assert.NotEmpty(t, logo) - assert.Contains(t, logo, "Force", "Jedi banner should contain 'Force'") - }) - - t.Run("saiyan profile shows Saiyan banner", func(t *testing.T) { - // Setup temp home - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - // Load saiyan profile - ctx := profiles.LoadProfileContext("saiyan") - require.NotNil(t, ctx) - - // Verify profile is saiyan - profile := ctx.Profile() - require.NotNil(t, profile) - assert.Equal(t, "saiyan", profile.ID) - - // Verify banner logo contains saiyan-specific content - logo := ctx.BannerLogo() - assert.NotEmpty(t, logo) - assert.Contains(t, logo, "POWER", "Saiyan banner should contain 'POWER'") - }) - - t.Run("invalid profile falls back to Enterprise banner", func(t *testing.T) { - // Setup temp home - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - // Try to load non-existent profile - ctx := profiles.LoadProfileContext("non-existent-profile") - require.NotNil(t, ctx) - - // Should fallback to Enterprise - profile := ctx.Profile() - require.NotNil(t, profile) - assert.Equal(t, "enterprise", profile.ID) - - // Verify banner logo is Enterprise - logo := ctx.BannerLogo() - assert.NotEmpty(t, logo) - assert.Contains(t, logo, "Agentic Reasoning Core", "should show Enterprise banner") - }) - - t.Run("all profiles have distinct banners", func(t *testing.T) { - // Setup temp home - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - repo, err := profiles.NewRepository() - require.NoError(t, err) - - // Test all 10 profiles have distinct logos - profileIDs := []string{ - "enterprise", "jedi", "saiyan", "shinobi", "pirate", - "pokemon", "triforce", "crystal", "bending", "horcrux", - } - - logos := make(map[string]string) - for _, profileID := range profileIDs { - profile, err := repo.GetByID(profileID) - require.NoError(t, err, "profile %s should exist", profileID) - - // Store logo - logos[profileID] = profile.Logo - assert.NotEmpty(t, profile.Logo, "profile %s should have a logo", profileID) - } - - // Verify all logos are distinct - for i, id1 := range profileIDs { - for j, id2 := range profileIDs { - if i != j { - assert.NotEqual(t, logos[id1], logos[id2], - "profiles %s and %s should have different logos", id1, id2) - } - } - } - }) -} - -// T054: Integration test for tier name consistency -// Verifies that tier names are consistent across the CLI when using different profiles -// Tests workspace info command with different profiles showing correct tier names - -func TestTierNameConsistency_WorkspaceInfo(t *testing.T) { - t.Run("workspace with tier index 0 shows Padawan for jedi profile", func(t *testing.T) { - // Setup temp home - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - // Set preferences to jedi profile - prefs := &preferences.Preferences{ - Profile: "jedi", - Theme: "nord", - } - err := prefs.Save() - require.NoError(t, err) - - // Create resolver with jedi profile - repo, err := profiles.NewRepository() - require.NoError(t, err) - - resolver := profiles.NewResolver(repo, prefs) - - // Get tier name for index 0 (should be Padawan) - tierName, err := resolver.GetActiveTierName(0) - require.NoError(t, err) - assert.Equal(t, "Padawan", tierName, "tier 0 with jedi profile should show Padawan") - - // Test tier index 1 - tierName, err = resolver.GetActiveTierName(1) - require.NoError(t, err) - assert.Equal(t, "Knight", tierName, "tier 1 with jedi profile should show Knight") - - // Test tier index 2 - tierName, err = resolver.GetActiveTierName(2) - require.NoError(t, err) - assert.Equal(t, "Master", tierName, "tier 2 with jedi profile should show Master") - }) - - t.Run("workspace with tier index 0 shows Starter for enterprise profile", func(t *testing.T) { - // Setup temp home - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - // Set preferences to enterprise profile - prefs := &preferences.Preferences{ - Profile: "enterprise", - Theme: "cyan-purple", - } - err := prefs.Save() - require.NoError(t, err) - - // Create resolver with enterprise profile - repo, err := profiles.NewRepository() - require.NoError(t, err) - - resolver := profiles.NewResolver(repo, prefs) - - // Get tier name for index 0 (should be Starter) - tierName, err := resolver.GetActiveTierName(0) - require.NoError(t, err) - assert.Equal(t, "Starter", tierName, "tier 0 with enterprise profile should show Starter") - - // Test tier index 1 - tierName, err = resolver.GetActiveTierName(1) - require.NoError(t, err) - assert.Equal(t, "Pro", tierName, "tier 1 with enterprise profile should show Pro") - - // Test tier index 2 - tierName, err = resolver.GetActiveTierName(2) - require.NoError(t, err) - assert.Equal(t, "Ultra", tierName, "tier 2 with enterprise profile should show Ultra") - }) - - t.Run("tier names update when profile changes", func(t *testing.T) { - // Setup temp home - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - // Start with jedi profile - prefs := &preferences.Preferences{ - Profile: "jedi", - Theme: "nord", - } - err := prefs.Save() - require.NoError(t, err) - - repo, err := profiles.NewRepository() - require.NoError(t, err) - - resolver := profiles.NewResolver(repo, prefs) - - // Verify tier 0 is Padawan - tierName, err := resolver.GetActiveTierName(0) - require.NoError(t, err) - assert.Equal(t, "Padawan", tierName) - - // Change to enterprise profile - prefs.Profile = "enterprise" - err = prefs.Save() - require.NoError(t, err) - - // Create new resolver with updated preferences - resolver = profiles.NewResolver(repo, prefs) - - // Verify tier 0 is now Starter - tierName, err = resolver.GetActiveTierName(0) - require.NoError(t, err) - assert.Equal(t, "Starter", tierName, "tier name should update when profile changes") - }) - - t.Run("legacy DBZ tier IDs resolve to profile-specific names", func(t *testing.T) { - // Setup temp home - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - // Set preferences to jedi profile - prefs := &preferences.Preferences{ - Profile: "jedi", - Theme: "nord", - } - err := prefs.Save() - require.NoError(t, err) - - repo, err := profiles.NewRepository() - require.NoError(t, err) - - resolver := profiles.NewResolver(repo, prefs) - - // Test that legacy DBZ tier IDs map correctly - // super-saiyan (index 0) should resolve to Padawan with jedi profile - tierName, err := resolver.GetActiveTierName(0) - require.NoError(t, err) - assert.Equal(t, "Padawan", tierName, "DBZ tier 0 should resolve to Padawan with jedi profile") - - // super-saiyan-blue (index 1) should resolve to Knight - tierName, err = resolver.GetActiveTierName(1) - require.NoError(t, err) - assert.Equal(t, "Knight", tierName, "DBZ tier 1 should resolve to Knight with jedi profile") - - // ultra-instinct (index 2) should resolve to Master - tierName, err = resolver.GetActiveTierName(2) - require.NoError(t, err) - assert.Equal(t, "Master", tierName, "DBZ tier 2 should resolve to Master with jedi profile") - }) - - t.Run("invalid tier index returns error", func(t *testing.T) { - // Setup temp home - tempHome := t.TempDir() - t.Setenv("HOME", tempHome) - - // Set preferences - prefs := &preferences.Preferences{ - Profile: "enterprise", - Theme: "cyan-purple", - } - err := prefs.Save() - require.NoError(t, err) - - repo, err := profiles.NewRepository() - require.NoError(t, err) - - resolver := profiles.NewResolver(repo, prefs) - - // Test invalid tier indices - _, err = resolver.GetActiveTierName(-1) - assert.Error(t, err, "negative tier index should return error") - - _, err = resolver.GetActiveTierName(3) - assert.Error(t, err, "tier index >= 3 should return error") - - _, err = resolver.GetActiveTierName(99) - assert.Error(t, err, "tier index out of bounds should return error") - }) -} diff --git a/tests/integration/profiles/e2e_test.go b/tests/integration/profiles/e2e_test.go deleted file mode 100644 index a450118..0000000 --- a/tests/integration/profiles/e2e_test.go +++ /dev/null @@ -1,355 +0,0 @@ -package profiles_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/internal/preferences" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" -) - -// TestE2E_ProfileLifecycle tests the complete profile workflow: -// list → set → get → verify tier names changed -func TestE2E_ProfileLifecycle(t *testing.T) { - t.Parallel() - - // Load default preferences - prefs, err := preferences.Load() - require.NoError(t, err) - - // Step 1: Load profile repository - repo, err := profiles.NewRepository() - require.NoError(t, err) - require.NotNil(t, repo) - - // Step 2: List all profiles - should have at least 10 embedded profiles - t.Run("list profiles", func(t *testing.T) { - allProfiles, err := repo.LoadAll() - require.NoError(t, err) - assert.GreaterOrEqual(t, len(allProfiles), 10, "should have at least 10 embedded profiles") - - // Verify required profiles exist - requiredProfiles := []string{ - "enterprise", "saiyan", "shinobi", "pirate", "pokemon", - "triforce", "crystal", "jedi", "bending", "horcrux", - } - - for _, profileID := range requiredProfiles { - profile, err := repo.GetByID(profileID) - assert.NoError(t, err, "profile %s should exist", profileID) - assert.NotNil(t, profile, "profile %s should not be nil", profileID) - if profile != nil { - assert.Equal(t, profileID, profile.ID, "profile ID should match") - assert.NotEmpty(t, profile.Name, "profile name should not be empty") - assert.NotEmpty(t, profile.Description, "profile description should not be empty") - assert.Len(t, profile.TierNames, 3, "profile should have exactly 3 tier names") - } - } - }) - - // Step 3: Verify enterprise tier names (default profile in embedded profiles) - t.Run("check enterprise tier names", func(t *testing.T) { - profile, err := repo.GetByID("enterprise") - require.NoError(t, err) - require.NotNil(t, profile) - assert.Equal(t, "Starter", profile.TierNames[0]) - assert.Equal(t, "Pro", profile.TierNames[1]) - assert.Equal(t, "Ultra", profile.TierNames[2]) - }) - - // Step 4: Verify jedi profile tier names - t.Run("check jedi tier names", func(t *testing.T) { - jediProfile, err := repo.GetByID("jedi") - require.NoError(t, err) - require.NotNil(t, jediProfile) - - // Verify jedi tier names - assert.Equal(t, "Padawan", jediProfile.TierNames[0]) - assert.Equal(t, "Knight", jediProfile.TierNames[1]) - assert.Equal(t, "Master", jediProfile.TierNames[2]) - - // Verify these are different from enterprise - enterpriseProfile, err := repo.GetByID("enterprise") - require.NoError(t, err) - assert.NotEqual(t, enterpriseProfile.TierNames[0], jediProfile.TierNames[0]) - assert.NotEqual(t, enterpriseProfile.TierNames[1], jediProfile.TierNames[1]) - assert.NotEqual(t, enterpriseProfile.TierNames[2], jediProfile.TierNames[2]) - }) - - // Step 5: Test setting profile in preferences - t.Run("set profile in preferences", func(t *testing.T) { - // Set profile - err := prefs.SetProfile("jedi") - assert.NoError(t, err) - - // Verify it was set - currentProfile := prefs.GetProfile() - assert.Equal(t, "jedi", currentProfile) - }) -} - -// TestE2E_AllProfilesHaveValidTierNames tests that all embedded profiles -// have exactly 3 tier names -func TestE2E_AllProfilesHaveValidTierNames(t *testing.T) { - t.Parallel() - - repo, err := profiles.NewRepository() - require.NoError(t, err) - - allProfiles, err := repo.LoadAll() - require.NoError(t, err) - require.GreaterOrEqual(t, len(allProfiles), 10, "should have at least 10 profiles") - - for _, profile := range allProfiles { - t.Run(profile.ID, func(t *testing.T) { - assert.Len(t, profile.TierNames, 3, "profile %s should have exactly 3 tier names", profile.ID) - assert.NotEmpty(t, profile.TierNames[0], "tier 1 name should not be empty") - assert.NotEmpty(t, profile.TierNames[1], "tier 2 name should not be empty") - assert.NotEmpty(t, profile.TierNames[2], "tier 3 name should not be empty") - }) - } -} - -// TestE2E_ProfileThemeMapping tests that all profiles have valid theme IDs -func TestE2E_ProfileThemeMapping(t *testing.T) { - t.Parallel() - - repo, err := profiles.NewRepository() - require.NoError(t, err) - - validThemes := map[string]bool{ - "cyan-purple": true, - "fire": true, - "gruvbox": true, - "ocean": true, - "rainbow": true, - "solarized": true, - "monokai": true, - "nord": true, - "dracula": true, - } - - allProfiles, err := repo.LoadAll() - require.NoError(t, err) - - for _, profile := range allProfiles { - t.Run(profile.ID, func(t *testing.T) { - assert.NotEmpty(t, profile.ThemeID, "profile %s should have a theme ID", profile.ID) - assert.True(t, validThemes[profile.ThemeID], - "profile %s has invalid theme ID: %s", profile.ID, profile.ThemeID) - }) - } -} - -// TestE2E_ProfileValidation tests profile validation -func TestE2E_ProfileValidation(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - profile profiles.Profile - shouldFail bool - reason string - }{ - { - name: "valid profile", - profile: profiles.Profile{ - ID: "valid", - Name: "Valid Profile", - Description: "A valid profile", - TierNames: []string{"Tier1", "Tier2", "Tier3"}, - ThemeID: "fire", - Logo: " A.R.C. Test Logo\n =================", - }, - shouldFail: false, - reason: "valid profile should pass validation", - }, - { - name: "missing tier names", - profile: profiles.Profile{ - ID: "invalid1", - Name: "Invalid Profile", - Description: "Missing tier names", - ThemeID: "fire", - Logo: " A.R.C. Test Logo\n =================", - }, - shouldFail: true, - reason: "profile without tier_names should fail", - }, - { - name: "wrong number of tier names", - profile: profiles.Profile{ - ID: "invalid2", - Name: "Invalid Profile", - Description: "Wrong number of tier names", - TierNames: []string{"Tier1", "Tier2"}, - ThemeID: "fire", - Logo: " A.R.C. Test Logo\n =================", - }, - shouldFail: true, - reason: "profile with 2 tier names (not 3) should fail", - }, - { - name: "missing logo", - profile: profiles.Profile{ - ID: "invalid3", - Name: "Invalid Profile", - Description: "Missing logo", - TierNames: []string{"Tier1", "Tier2", "Tier3"}, - ThemeID: "fire", - }, - shouldFail: true, - reason: "profile without logo should fail", - }, - { - name: "invalid theme id", - profile: profiles.Profile{ - ID: "invalid4", - Name: "Invalid Profile", - Description: "Invalid theme", - TierNames: []string{"Tier1", "Tier2", "Tier3"}, - ThemeID: "nonexistent", - Logo: " A.R.C. Test Logo\n =================", - }, - shouldFail: true, - reason: "profile with invalid theme_id should fail", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // Create validator - validator := profiles.DefaultValidator() - - // Try to validate - err := validator.ValidateProfile(&tc.profile) - - if tc.shouldFail { - assert.Error(t, err, tc.reason) - } else { - assert.NoError(t, err, tc.reason) - } - }) - } -} - -// TestE2E_ProfileSwitchingPreservesState tests that switching profiles -// doesn't affect other state values -func TestE2E_ProfileSwitchingPreservesState(t *testing.T) { - t.Parallel() - - prefs, err := preferences.Load() - require.NoError(t, err) - - // Set theme - err = prefs.SetTheme("rainbow") - require.NoError(t, err) - - // Set profile - err = prefs.SetProfile("saiyan") - require.NoError(t, err) - - // Verify theme was preserved - theme := prefs.GetTheme() - assert.Equal(t, "rainbow", theme, "theme should be preserved when changing profile") - - // Verify profile was changed - profile := prefs.GetProfile() - assert.Equal(t, "saiyan", profile, "profile should be changed") -} - -// TestE2E_SpecificProfiles tests each of the 10 required profiles -func TestE2E_SpecificProfiles(t *testing.T) { - t.Parallel() - - repo, err := profiles.NewRepository() - require.NoError(t, err) - - // Define expected tier names for each profile - expectedProfiles := map[string][]string{ - "enterprise": {"Starter", "Pro", "Ultra"}, - "saiyan": {"Super Saiyan", "Super Saiyan Blue", "Ultra Instinct"}, - "shinobi": {"Genin", "Jonin", "Hokage"}, - "pirate": {"Rookie", "Supernova", "Yonko"}, - "pokemon": {"Basic", "Stage 1", "Stage 2"}, - "triforce": {"Courage", "Wisdom", "Power"}, - "crystal": {"Warrior", "Knight", "Paladin"}, - "jedi": {"Padawan", "Knight", "Master"}, - "bending": {"Bender", "Avatar", "Cosmic"}, - "horcrux": {"Student", "Auror", "Headmaster"}, - } - - for profileID, expectedTiers := range expectedProfiles { - t.Run(profileID, func(t *testing.T) { - profile, err := repo.GetByID(profileID) - require.NoError(t, err, "profile %s should exist", profileID) - require.NotNil(t, profile) - - // Verify tier names match expected - assert.Equal(t, expectedTiers[0], profile.TierNames[0], "tier 1 mismatch for %s", profileID) - assert.Equal(t, expectedTiers[1], profile.TierNames[1], "tier 2 mismatch for %s", profileID) - assert.Equal(t, expectedTiers[2], profile.TierNames[2], "tier 3 mismatch for %s", profileID) - }) - } -} - -// TestE2E_ProfileResolverIntegration tests the ProfileResolver with ProfileRepository -func TestE2E_ProfileResolverIntegration(t *testing.T) { - t.Parallel() - - // Create repository and preferences - repo, err := profiles.NewRepository() - require.NoError(t, err) - - prefs, err := preferences.Load() - require.NoError(t, err) - - // Set profile to jedi - err = prefs.SetProfile("jedi") - require.NoError(t, err) - - // Create resolver - resolver := profiles.NewResolver(repo, prefs) - require.NotNil(t, resolver) - - // Test ResolveTierName with jedi profile - t.Run("resolve jedi tier names", func(t *testing.T) { - tier0, err := resolver.ResolveTierName(0, "jedi") - require.NoError(t, err) - assert.Equal(t, "Padawan", tier0) - - tier1, err := resolver.ResolveTierName(1, "jedi") - require.NoError(t, err) - assert.Equal(t, "Knight", tier1) - - tier2, err := resolver.ResolveTierName(2, "jedi") - require.NoError(t, err) - assert.Equal(t, "Master", tier2) - }) - - // Test GetActiveTierName (should use jedi profile from preferences) - t.Run("get active tier names", func(t *testing.T) { - tier0, err := resolver.GetActiveTierName(0) - require.NoError(t, err) - assert.Equal(t, "Padawan", tier0) - - tier1, err := resolver.GetActiveTierName(1) - require.NoError(t, err) - assert.Equal(t, "Knight", tier1) - - tier2, err := resolver.GetActiveTierName(2) - require.NoError(t, err) - assert.Equal(t, "Master", tier2) - }) - - // Test invalid tier index - t.Run("invalid tier index", func(t *testing.T) { - _, err := resolver.ResolveTierName(3, "jedi") - assert.Error(t, err, "tier index 3 should be invalid") - - _, err = resolver.ResolveTierName(-1, "jedi") - assert.Error(t, err, "tier index -1 should be invalid") - }) -} diff --git a/tests/integration/ui_commands_test.go b/tests/integration/ui_commands_test.go deleted file mode 100644 index 96c9aff..0000000 --- a/tests/integration/ui_commands_test.go +++ /dev/null @@ -1,284 +0,0 @@ -// Package integration provides integration tests for the ARC CLI UI commands. -package integration - -// T386: Each view's ToJSON() returns valid data (non-nil). -// T387: Multiple views render without panic. -// T388: JSON output mode works (engine.JSONMode): create a view, call OnEnter, get ToJSON output. - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/views" -) - -// buildCommandTestFactory creates a ComponentFactory for command tests. -func buildCommandTestFactory(t *testing.T) ui.ComponentFactory { - t.Helper() - profileCtx := profiles.GetDefaultProfileContext() - return ui.NewComponentFactory(profileCtx, components.BorderTierNone) -} - -// buildCommandTestViewContext creates a ViewContext with given args. -func buildCommandTestViewContext(t *testing.T, width, height int, args map[string]any) *engine.ViewContext { - t.Helper() - profileCtx := profiles.GetDefaultProfileContext() - if args == nil { - args = make(map[string]any) - } - return engine.NewViewContext(profileCtx.Profile(), profileCtx.Theme(), width, height, args) -} - -// T386: TestAllViewsToJSONNonNil verifies that every view's ToJSON() returns non-nil. -func TestAllViewsToJSONNonNil(t *testing.T) { - factory := buildCommandTestFactory(t) - ctx := buildCommandTestViewContext(t, 80, 40, nil) - - type viewCase struct { - name string - run func() any - } - - homeView := views.NewHomeView(factory) - homeView.OnEnter(ctx) - - dashboardView := views.NewDashboardView(factory) - dashboardView.OnEnter(ctx) - - servicesView := views.NewServicesListView(factory) - servicesView.OnEnter(ctx) - - detailView := views.NewServiceDetailView(factory) - detailView.OnEnter(buildCommandTestViewContext(t, 80, 40, map[string]any{"serviceName": "postgres"})) - - versionView := views.NewVersionView(factory, "1.0.0", "abc1234", "2026-01-01T00:00:00Z", false) - versionView.OnEnter(ctx) - - themeView := views.NewThemeListView(factory) - themeView.OnEnter(ctx) - - profileView := views.NewProfileListView(factory) - profileView.OnEnter(ctx) - - wsInfoView := views.NewWorkspaceInfoView(factory) - wsInfoView.OnEnter(ctx) - - wsHistView := views.NewWorkspaceHistoryView(factory) - wsHistView.OnEnter(ctx) - - cases := []viewCase{ - {"HomeView", homeView.ToJSON}, - {"DashboardView", dashboardView.ToJSON}, - {"ServicesListView", servicesView.ToJSON}, - {"ServiceDetailView", detailView.ToJSON}, - {"VersionView", versionView.ToJSON}, - {"ThemeListView", themeView.ToJSON}, - {"ProfileListView", profileView.ToJSON}, - {"WorkspaceInfoView", wsInfoView.ToJSON}, - {"WorkspaceHistoryView", wsHistView.ToJSON}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - data := tc.run() - assert.NotNil(t, data, "ToJSON() must return non-nil for %s", tc.name) - - // Also verify it marshals to valid JSON - raw, err := json.Marshal(data) - require.NoError(t, err, "json.Marshal should succeed for %s", tc.name) - assert.True(t, json.Valid(raw), "marshaled output should be valid JSON for %s", tc.name) - }) - } -} - -// T387: TestMultipleViewsRenderWithoutPanic verifies that all major views render -// without panicking across a full lifecycle (OnEnter → View → OnExit). -func TestMultipleViewsRenderWithoutPanic(t *testing.T) { - factory := buildCommandTestFactory(t) - ctx := buildCommandTestViewContext(t, 80, 40, nil) - - type viewCase struct { - name string - run func() - } - - cases := []viewCase{ - { - name: "HomeView", - run: func() { - v := views.NewHomeView(factory) - v.OnEnter(ctx) - _ = v.View() - v.OnExit() - }, - }, - { - name: "DashboardView", - run: func() { - v := views.NewDashboardView(factory) - v.OnEnter(ctx) - _ = v.View() - v.OnExit() - }, - }, - { - name: "ServicesListView", - run: func() { - v := views.NewServicesListView(factory) - v.OnEnter(ctx) - _ = v.View() - v.OnExit() - }, - }, - { - name: "ServiceDetailView", - run: func() { - v := views.NewServiceDetailView(factory) - v.OnEnter(buildCommandTestViewContext(t, 80, 40, map[string]any{"serviceName": "redis"})) - _ = v.View() - v.OnExit() - }, - }, - { - name: "VersionView", - run: func() { - v := views.NewVersionView(factory, "2.0.0", "deadbeef", "2026-02-28T00:00:00Z", false) - v.OnEnter(ctx) - _ = v.View() - v.OnExit() - }, - }, - { - name: "ThemeListView", - run: func() { - v := views.NewThemeListView(factory) - v.OnEnter(ctx) - _ = v.View() - v.OnExit() - }, - }, - { - name: "ProfileListView", - run: func() { - v := views.NewProfileListView(factory) - v.OnEnter(ctx) - _ = v.View() - v.OnExit() - }, - }, - { - name: "WorkspaceInfoView", - run: func() { - v := views.NewWorkspaceInfoView(factory) - v.OnEnter(ctx) - _ = v.View() - v.OnExit() - }, - }, - { - name: "WorkspaceHistoryView", - run: func() { - v := views.NewWorkspaceHistoryView(factory) - v.OnEnter(ctx) - _ = v.View() - v.OnExit() - }, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - require.NotPanics(t, tc.run, "view lifecycle must not panic for %s", tc.name) - }) - } -} - -// T388: TestJSONOutputMode verifies that views implementing engine.JSONExporter -// produce non-nil, valid JSON data when OnEnter is called and ToJSON is invoked. -// This mirrors the engine.JSONMode rendering path (engine.Render with JSONMode). -func TestJSONOutputMode(t *testing.T) { - factory := buildCommandTestFactory(t) - ctx := buildCommandTestViewContext(t, 80, 40, nil) - - type jsonCase struct { - name string - setup func() engine.JSONExporter - expectKey string - } - - cases := []jsonCase{ - { - name: "HomeView", - setup: func() engine.JSONExporter { - v := views.NewHomeView(factory) - v.OnEnter(ctx) - return v - }, - }, - { - name: "DashboardView", - setup: func() engine.JSONExporter { - v := views.NewDashboardView(factory) - v.OnEnter(ctx) - return v - }, - }, - { - name: "ServicesListView", - setup: func() engine.JSONExporter { - v := views.NewServicesListView(factory) - v.OnEnter(ctx) - return v - }, - expectKey: "services", - }, - { - name: "ServiceDetailView", - setup: func() engine.JSONExporter { - v := views.NewServiceDetailView(factory) - v.OnEnter(buildCommandTestViewContext(t, 80, 40, map[string]any{"serviceName": "mysql"})) - return v - }, - expectKey: "name", - }, - { - name: "VersionView", - setup: func() engine.JSONExporter { - v := views.NewVersionView(factory, "3.0.0", "cafebabe", "2026-02-28T00:00:00Z", true) - v.OnEnter(ctx) - return v - }, - expectKey: "version", - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - exporter := tc.setup() - require.NotNil(t, exporter, "JSONExporter must not be nil") - - jsonData := exporter.ToJSON() - require.NotNil(t, jsonData, "ToJSON() must return non-nil for %s", tc.name) - - // Marshal to verify it produces valid JSON (mirrors engine.JSONMode path) - raw, err := json.Marshal(jsonData) - require.NoError(t, err, "json.Marshal must succeed for %s", tc.name) - assert.True(t, json.Valid(raw), "marshaled JSON must be valid for %s", tc.name) - - // If a key is expected, verify it exists in the output - if tc.expectKey != "" { - var result map[string]any - require.NoError(t, json.Unmarshal(raw, &result)) - assert.Contains(t, result, tc.expectKey, - "JSON output for %s should contain key %q", tc.name, tc.expectKey) - } - }) - } -} diff --git a/tests/integration/ui_keyboard_test.go b/tests/integration/ui_keyboard_test.go deleted file mode 100644 index a2c1538..0000000 --- a/tests/integration/ui_keyboard_test.go +++ /dev/null @@ -1,169 +0,0 @@ -// Package integration provides integration tests for the ARC CLI keyboard handling. -package integration - -// T391: Keyboard navigation test -// -// Tests keyboard handling in a unit-style manner (no full TUI): -// - "q" on DashboardView.Update() → returns tea.Quit command -// - ctrl+c on ServicesListView.Update() → returns tea.Quit command -// - tea.WindowSizeMsg updates view dimensions - -import ( - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/views" -) - -// buildKeyboardTestFactory creates a ComponentFactory for keyboard tests. -func buildKeyboardTestFactory(t *testing.T) ui.ComponentFactory { - t.Helper() - profileCtx := profiles.GetDefaultProfileContext() - return ui.NewComponentFactory(profileCtx, components.BorderTierNone) -} - -// buildKeyboardViewContext creates a ViewContext for keyboard tests. -func buildKeyboardViewContext(width, height int) *engine.ViewContext { - profileCtx := profiles.GetDefaultProfileContext() - return engine.NewViewContext(profileCtx.Profile(), profileCtx.Theme(), width, height, nil) -} - -// T391: TestDashboardView_QuitKey verifies that sending "q" to DashboardView.Update() -// returns a non-nil tea.Cmd (the quit command). -func TestDashboardView_QuitKey(t *testing.T) { - factory := buildKeyboardTestFactory(t) - v := views.NewDashboardView(factory) - v.OnEnter(buildKeyboardViewContext(80, 40)) - - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")} - _, cmd := v.Update(msg) - - require.NotNil(t, cmd, "DashboardView should return a non-nil Cmd for 'q'") -} - -// T391: TestDashboardView_CtrlCKey verifies that ctrl+c on DashboardView.Update() -// returns a non-nil tea.Cmd (the quit command). -func TestDashboardView_CtrlCKey(t *testing.T) { - factory := buildKeyboardTestFactory(t) - v := views.NewDashboardView(factory) - v.OnEnter(buildKeyboardViewContext(80, 40)) - - msg := tea.KeyMsg{Type: tea.KeyCtrlC} - _, cmd := v.Update(msg) - - require.NotNil(t, cmd, "DashboardView should return a non-nil Cmd for ctrl+c") -} - -// T391: TestServicesListView_CtrlCKey verifies that ctrl+c on ServicesListView.Update() -// returns a non-nil tea.Cmd (the quit command). -func TestServicesListView_CtrlCKey(t *testing.T) { - factory := buildKeyboardTestFactory(t) - v := views.NewServicesListView(factory) - v.OnEnter(buildKeyboardViewContext(80, 40)) - - msg := tea.KeyMsg{Type: tea.KeyCtrlC} - _, cmd := v.Update(msg) - - require.NotNil(t, cmd, "ServicesListView should return a non-nil Cmd for ctrl+c") -} - -// T391: TestServicesListView_QuitKey verifies that "q" on ServicesListView.Update() -// returns a non-nil tea.Cmd. -func TestServicesListView_QuitKey(t *testing.T) { - factory := buildKeyboardTestFactory(t) - v := views.NewServicesListView(factory) - v.OnEnter(buildKeyboardViewContext(80, 40)) - - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")} - _, cmd := v.Update(msg) - - require.NotNil(t, cmd, "ServicesListView should return a non-nil Cmd for 'q'") -} - -// T391: TestWindowSizeMsg_UpdatesDimensions verifies that tea.WindowSizeMsg updates -// the view's internal width and height. We use DashboardView which exposes this -// via its Update handler. -func TestWindowSizeMsg_UpdatesDimensions(t *testing.T) { - factory := buildKeyboardTestFactory(t) - v := views.NewDashboardView(factory) - v.OnEnter(buildKeyboardViewContext(80, 40)) - - // Send a window resize message - resizeMsg := tea.WindowSizeMsg{Width: 100, Height: 40} - updatedModel, cmd := v.Update(resizeMsg) - - // The model should be updated and no command should be required for resize - require.NotNil(t, updatedModel, "Update must return a non-nil model after WindowSizeMsg") - _ = cmd // cmd may be nil for resize; we only verify no panic and model is valid - - // Render to confirm no panic after resize - require.NotPanics(t, func() { - _ = updatedModel.View() - }, "View() must not panic after WindowSizeMsg") -} - -// T391: TestHomeView_QuitKey verifies that "q" on HomeView.Update() returns -// a non-nil tea.Cmd. -func TestHomeView_QuitKey(t *testing.T) { - factory := buildKeyboardTestFactory(t) - v := views.NewHomeView(factory) - v.OnEnter(buildKeyboardViewContext(80, 40)) - - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")} - _, cmd := v.Update(msg) - - require.NotNil(t, cmd, "HomeView should return a non-nil Cmd for 'q'") -} - -// T391: TestWindowSizeMsg_MultipleViews verifies that sending tea.WindowSizeMsg to -// different views does not cause panics. -func TestWindowSizeMsg_MultipleViews(t *testing.T) { - factory := buildKeyboardTestFactory(t) - ctx := buildKeyboardViewContext(80, 40) - resizeMsg := tea.WindowSizeMsg{Width: 100, Height: 40} - - viewList := []struct { - name string - v tea.Model - }{ - { - name: "HomeView", - v: func() tea.Model { - v := views.NewHomeView(factory) - v.OnEnter(ctx) - return v - }(), - }, - { - name: "DashboardView", - v: func() tea.Model { - v := views.NewDashboardView(factory) - v.OnEnter(ctx) - return v - }(), - }, - { - name: "ServicesListView", - v: func() tea.Model { - v := views.NewServicesListView(factory) - v.OnEnter(ctx) - return v - }(), - }, - } - - for _, tc := range viewList { - t.Run(tc.name, func(t *testing.T) { - assert.NotPanics(t, func() { - _, _ = tc.v.Update(resizeMsg) - }, "WindowSizeMsg must not panic for %s", tc.name) - }) - } -} diff --git a/tests/integration/ui_navigation_test.go b/tests/integration/ui_navigation_test.go deleted file mode 100644 index 3f29df3..0000000 --- a/tests/integration/ui_navigation_test.go +++ /dev/null @@ -1,130 +0,0 @@ -// Package integration provides integration tests for the ARC CLI UI navigation system. -package integration - -// T385: Navigation flow test -// -// This file tests the full navigation flow using real view implementations -// (HomeView → DashboardView → ServicesListView → ServiceDetailView). -// Each view should render without panicking and return the expected Name(). - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/views" -) - -// newNavTestFactory creates a ComponentFactory suitable for navigation tests. -func newNavTestFactory(t *testing.T) ui.ComponentFactory { - t.Helper() - profileCtx := profiles.GetDefaultProfileContext() - return ui.NewComponentFactory(profileCtx, components.BorderTierNone) -} - -// newNavTestViewContext creates a ViewContext with default settings. -func newNavTestViewContext(t *testing.T, args map[string]any) *engine.ViewContext { - t.Helper() - profileCtx := profiles.GetDefaultProfileContext() - if args == nil { - args = make(map[string]any) - } - return engine.NewViewContext(profileCtx.Profile(), profileCtx.Theme(), 80, 40, args) -} - -// T385: TestNavigationFlowViewSequence verifies instantiating views in sequence -// (HomeView → DashboardView → ServicesListView → ServiceDetailView), ensuring each -// view renders without panicking and returns the expected Name(). -func TestNavigationFlowViewSequence(t *testing.T) { - factory := newNavTestFactory(t) - ctx := newNavTestViewContext(t, nil) - - type viewCase struct { - name string - expectedName string - build func() engine.View - args map[string]any - } - - cases := []viewCase{ - { - name: "HomeView", - expectedName: "home", - build: func() engine.View { - return views.NewHomeView(factory) - }, - }, - { - name: "DashboardView", - expectedName: "dashboard", - build: func() engine.View { - return views.NewDashboardView(factory) - }, - }, - { - name: "ServicesListView", - expectedName: "services-list", - build: func() engine.View { - return views.NewServicesListView(factory) - }, - }, - { - name: "ServiceDetailView", - expectedName: "service-detail", - build: func() engine.View { - return views.NewServiceDetailView(factory) - }, - args: map[string]any{"serviceName": "postgres"}, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - v := tc.build() - require.NotNil(t, v, "view should not be nil") - - // Verify Name() - assert.Equal(t, tc.expectedName, v.Name(), "Name() should return expected value") - - // Prepare context — use per-case args if provided - enterCtx := ctx - if tc.args != nil { - enterCtx = newNavTestViewContext(t, tc.args) - } - - // OnEnter must not panic - require.NotPanics(t, func() { - v.OnEnter(enterCtx) - }, "OnEnter should not panic") - - // View() must not panic and should return a string - var rendered string - require.NotPanics(t, func() { - rendered = v.View() - }, "View() should not panic") - - _ = rendered // content is not asserted; non-panic is sufficient - - // OnExit must not panic - require.NotPanics(t, func() { - v.OnExit() - }, "OnExit should not panic") - }) - } -} - -// TestNavigationFlow_NameMatchesConstant is a focused sanity check that each view's -// Name() matches the well-known routing constant used throughout the CLI. -func TestNavigationFlow_NameMatchesConstant(t *testing.T) { - factory := newNavTestFactory(t) - - assert.Equal(t, "home", views.NewHomeView(factory).Name()) - assert.Equal(t, "dashboard", views.NewDashboardView(factory).Name()) - assert.Equal(t, "services-list", views.NewServicesListView(factory).Name()) - assert.Equal(t, "service-detail", views.NewServiceDetailView(factory).Name()) -} diff --git a/tests/integration/ui_profiles_test.go b/tests/integration/ui_profiles_test.go deleted file mode 100644 index 5dc73cb..0000000 --- a/tests/integration/ui_profiles_test.go +++ /dev/null @@ -1,131 +0,0 @@ -// Package integration provides integration tests for profile loading and rendering. -package integration - -// T390: Profile tests -// -// Load all 10 profiles from the profiles repository and verify: -// - profiles.NewRepository() returns all 10 profiles -// - For each profile, profiles.NewProfileContext(profile, theme) succeeds -// - ServicesListView renders without panic for each profile - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" - "github.com/arc-framework/arc-cli/pkg/ui/views" -) - -// knownProfileIDs lists the 10 built-in profiles embedded in the binary. -var knownProfileIDs = []string{ - "enterprise", - "jedi", - "saiyan", - "shinobi", - "pirate", - "pokemon", - "triforce", - "crystal", - "bending", - "horcrux", -} - -// T390: TestRepository_ReturnsAllTenProfiles verifies that profiles.NewRepository() -// loads all 10 embedded profiles. -func TestRepository_ReturnsAllTenProfiles(t *testing.T) { - repo, err := profiles.NewRepository() - require.NoError(t, err, "NewRepository should succeed") - require.NotNil(t, repo, "repository must not be nil") - - allProfiles, err := repo.LoadAll() - require.NoError(t, err, "LoadAll should succeed") - - assert.GreaterOrEqual(t, len(allProfiles), 10, - "repository should contain at least 10 profiles, got %d", len(allProfiles)) - - // Verify each known profile ID is present - found := make(map[string]bool) - for _, p := range allProfiles { - found[p.ID] = true - } - - for _, id := range knownProfileIDs { - assert.True(t, found[id], "profile %q should be present in the repository", id) - } -} - -// T390: TestNewProfileContext_ForEachProfile verifies that profiles.NewProfileContext -// succeeds for every known profile (theme may be nil, which is acceptable). -func TestNewProfileContext_ForEachProfile(t *testing.T) { - repo, err := profiles.NewRepository() - require.NoError(t, err, "NewRepository should succeed") - - // Obtain a default theme to use as a fallback (nil is also acceptable). - var defaultTheme *themes.Theme - if loader := themes.NewLoader(); loader != nil { - defaultTheme, _ = loader.Load("cyan-purple") - } - - for _, id := range knownProfileIDs { - t.Run(id, func(t *testing.T) { - profile, err := repo.GetByID(id) - require.NoError(t, err, "GetByID(%q) should succeed", id) - require.NotNil(t, profile, "profile %q must not be nil", id) - - // Attempt to load the profile's own theme; fall back to defaultTheme. - var profileTheme *themes.Theme - if loader := themes.NewLoader(); loader != nil && profile.ThemeID != "" { - profileTheme, _ = loader.Load(profile.ThemeID) - } - if profileTheme == nil { - profileTheme = defaultTheme - } - - ctx, err := profiles.NewProfileContext(profile, profileTheme) - require.NoError(t, err, "NewProfileContext should succeed for profile %q", id) - require.NotNil(t, ctx, "ProfileContext must not be nil for profile %q", id) - }) - } -} - -// T390: TestServicesListView_RendersForEachProfile verifies that ServicesListView -// renders without panic for every known profile. -func TestServicesListView_RendersForEachProfile(t *testing.T) { - repo, err := profiles.NewRepository() - require.NoError(t, err, "NewRepository should succeed") - - for _, id := range knownProfileIDs { - t.Run(id, func(t *testing.T) { - profile, err := repo.GetByID(id) - require.NoError(t, err, "GetByID(%q) should succeed", id) - - // Load the profile's theme (nil is fine — ProfileContext accepts it). - var profileTheme *themes.Theme - if loader := themes.NewLoader(); loader != nil && profile.ThemeID != "" { - profileTheme, _ = loader.Load(profile.ThemeID) - } - - profileCtx, err := profiles.NewProfileContext(profile, profileTheme) - require.NoError(t, err, "NewProfileContext should succeed for profile %q", id) - - factory := ui.NewComponentFactory(profileCtx, components.BorderTierNone) - v := views.NewServicesListView(factory) - - viewCtx := engine.NewViewContext(profile, profileTheme, 80, 40, nil) - - require.NotPanics(t, func() { - v.OnEnter(viewCtx) - }, "OnEnter must not panic for profile %q", id) - - require.NotPanics(t, func() { - _ = v.View() - }, "View() must not panic for profile %q", id) - }) - } -} diff --git a/tests/integration/ui_responsive_test.go b/tests/integration/ui_responsive_test.go deleted file mode 100644 index c00dee6..0000000 --- a/tests/integration/ui_responsive_test.go +++ /dev/null @@ -1,124 +0,0 @@ -// Package integration provides integration tests for the ARC CLI UI responsive layout. -package integration - -// T389: Responsive layout tests -// -// Verifies that views render correctly (non-empty output, no panic) at different -// terminal widths: 80, 120, and 160 columns. Tests ServicesListView, DashboardView, -// and ThemeListView. - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/views" -) - -// buildResponsiveFactory creates a ComponentFactory for responsive tests. -func buildResponsiveFactory(t *testing.T) ui.ComponentFactory { - t.Helper() - profileCtx := profiles.GetDefaultProfileContext() - return ui.NewComponentFactory(profileCtx, components.BorderTierNone) -} - -// buildResponsiveContext creates a ViewContext at the given terminal dimensions. -func buildResponsiveContext(t *testing.T, width, height int) *engine.ViewContext { - t.Helper() - profileCtx := profiles.GetDefaultProfileContext() - return engine.NewViewContext(profileCtx.Profile(), profileCtx.Theme(), width, height, nil) -} - -// T389: TestResponsiveLayout_ServicesListView verifies that ServicesListView renders -// without panic and produces non-empty output at widths 80, 120, and 160. -func TestResponsiveLayout_ServicesListView(t *testing.T) { - factory := buildResponsiveFactory(t) - heights := 40 - - for _, width := range []int{80, 120, 160} { - t.Run("width="+widthLabel(width), func(t *testing.T) { - v := views.NewServicesListView(factory) - ctx := buildResponsiveContext(t, width, heights) - - require.NotPanics(t, func() { - v.OnEnter(ctx) - }, "OnEnter must not panic at width %d", width) - - var rendered string - require.NotPanics(t, func() { - rendered = v.View() - }, "View() must not panic at width %d", width) - - assert.NotEmpty(t, rendered, "View() output must not be empty at width %d", width) - }) - } -} - -// T389: TestResponsiveLayout_DashboardView verifies that DashboardView renders -// without panic and produces non-empty output at widths 80, 120, and 160. -func TestResponsiveLayout_DashboardView(t *testing.T) { - factory := buildResponsiveFactory(t) - heights := 40 - - for _, width := range []int{80, 120, 160} { - t.Run("width="+widthLabel(width), func(t *testing.T) { - v := views.NewDashboardView(factory) - ctx := buildResponsiveContext(t, width, heights) - - require.NotPanics(t, func() { - v.OnEnter(ctx) - }, "OnEnter must not panic at width %d", width) - - var rendered string - require.NotPanics(t, func() { - rendered = v.View() - }, "View() must not panic at width %d", width) - - assert.NotEmpty(t, rendered, "View() output must not be empty at width %d", width) - }) - } -} - -// T389: TestResponsiveLayout_ThemeListView verifies that ThemeListView renders -// without panic and produces non-empty output at widths 80, 120, and 160. -func TestResponsiveLayout_ThemeListView(t *testing.T) { - factory := buildResponsiveFactory(t) - heights := 40 - - for _, width := range []int{80, 120, 160} { - t.Run("width="+widthLabel(width), func(t *testing.T) { - v := views.NewThemeListView(factory) - ctx := buildResponsiveContext(t, width, heights) - - require.NotPanics(t, func() { - v.OnEnter(ctx) - }, "OnEnter must not panic at width %d", width) - - var rendered string - require.NotPanics(t, func() { - rendered = v.View() - }, "View() must not panic at width %d", width) - - assert.NotEmpty(t, rendered, "View() output must not be empty at width %d", width) - }) - } -} - -// widthLabel converts an integer width to a display string for sub-test names. -func widthLabel(w int) string { - switch w { - case 80: - return "80" - case 120: - return "120" - case 160: - return "160" - default: - return "unknown" - } -} diff --git a/tests/performance/.gitkeep b/tests/performance/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/performance/README.md b/tests/performance/README.md deleted file mode 100644 index cac8345..0000000 --- a/tests/performance/README.md +++ /dev/null @@ -1,330 +0,0 @@ -# Performance Tests - -**Purpose**: Benchmark tests to enforce performance targets for UI Engine. - -## Performance Targets - -As defined in `specs/017-ui-engine/spec.md` (Success Criteria): - -| Metric | Target | Test | -|--------|--------|------| -| **Startup Time** | <100ms | From CLI execution to first render | -| **Navigation Latency** | <16ms | Keypress to view switch (60fps) | -| **Search Filtering** | <100ms | Real-time filter for 100 items | -| **Table Sorting** | <50ms | Sort operation for 100 rows | -| **Memory Footprint** | <30MB | Resident memory during dashboard | - -## Test Structure - -``` -tests/performance/ -├── README.md # This file -├── engine_bench_test.go # Engine benchmarks (navigation, routing) -├── ui_bench_test.go # UI benchmarks (startup, filtering, rendering) -├── RESULTS.md # Performance test results (T128-T129) -└── .gitkeep # Directory marker -``` - -## Running Benchmarks - -```bash -# Run all benchmarks -go test -bench=. -benchmem ./tests/performance/... - -# Run specific benchmark -go test -bench=BenchmarkNavigationLatency -benchmem ./tests/performance/... - -# Generate CPU profile -go test -bench=. -cpuprofile=cpu.prof ./tests/performance/... - -# Generate memory profile -go test -bench=. -memprofile=mem.prof ./tests/performance/... - -# Analyze profile -go tool pprof cpu.prof -``` - -## Benchmark Examples - -### Navigation Latency - -```go -func BenchmarkNavigationLatency(b *testing.B) { - router := setupRouter(b) - router.Register(views.NewHomeView(factory)) - router.Register(views.NewServicesView(factory)) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - router.Navigate("services", nil) - router.Navigate("home", nil) - } -} -``` - -**Expected**: <16ms per operation (target: 60fps) - -### Search Filtering - -```go -func BenchmarkSearchFilter100Items(b *testing.B) { - items := generateMockServices(100) - searchBar := setupSearchBar(b) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - searchBar.Filter(items, "postgres") - } -} -``` - -**Expected**: <100ms for 100 items - -### Memory Footprint - -```go -func TestDashboardMemoryFootprint(t *testing.T) { - var m runtime.MemStats - - // Setup dashboard - dashboard := setupDashboardView(t) - - // Measure before - runtime.GC() - runtime.ReadMemStats(&m) - before := m.Alloc - - // Render dashboard - _ = dashboard.View() - - // Measure after - runtime.ReadMemStats(&m) - after := m.Alloc - - delta := after - before - require.Less(t, delta, 30*1024*1024, "Memory footprint exceeded 30MB") -} -``` - -**Expected**: <30MB resident memory - -## Benchmark Scenarios - -### Engine Benchmarks - -- `BenchmarkRouterRegistration` - View registration overhead -- `BenchmarkNavigationLatency` - View switching time -- `BenchmarkBackNavigation` - History stack performance -- `BenchmarkRenderModeSwitching` - TUI/JSON/Static mode switching - -### Component Benchmarks - -- `BenchmarkHeroRender` - Hero component render time -- `BenchmarkDataTableRender100Rows` - Table with 100 rows -- `BenchmarkDataTableRender1000Rows` - Table with 1000 rows -- `BenchmarkSearchBarFilter` - Search filtering performance -- `BenchmarkSidebarRender` - Sidebar navigation render - -### Cache Benchmarks - -- `BenchmarkLRUCacheHit` - Cache hit performance -- `BenchmarkLRUCacheMiss` - Cache miss + population -- `BenchmarkComponentCaching` - Hero/Sidebar/Table header caching - -## Performance Regression Detection - -Benchmarks run in CI/CD with historical comparison: - -```bash -# Baseline (main branch) -go test -bench=. -benchmem ./tests/performance/... > baseline.txt - -# Current branch -go test -bench=. -benchmem ./tests/performance/... > current.txt - -# Compare -benchcmp baseline.txt current.txt -``` - -**CI Failure Conditions**: -- Any benchmark >20% slower than baseline -- Memory allocation increase >30% -- Startup time exceeds 100ms - -## Profiling Workflow - -### CPU Profiling - -```bash -# Generate profile -go test -bench=BenchmarkNavigationLatency -cpuprofile=cpu.prof ./tests/performance/... - -# Analyze top functions -go tool pprof -top cpu.prof - -# Interactive analysis -go tool pprof cpu.prof -# (pprof) top10 -# (pprof) list FunctionName -# (pprof) web # Opens browser visualization -``` - -### Memory Profiling - -```bash -# Generate profile -go test -bench=BenchmarkDataTableRender -memprofile=mem.prof ./tests/performance/... - -# Analyze allocations -go tool pprof -alloc_space mem.prof - -# Find memory leaks -go tool pprof -inuse_space mem.prof -``` - -### Trace Analysis - -```bash -# Generate execution trace -go test -bench=. -trace=trace.out ./tests/performance/... - -# Analyze trace -go tool trace trace.out -``` - -## Optimization Guidelines - -### Component Caching - -Use LRU cache for expensive renders: - -```go -type ComponentCache struct { - cache *lru.Cache -} - -func (c *ComponentCache) GetOrRender(key string, renderFn func() string) string { - if val, ok := c.cache.Get(key); ok { - return val.(string) - } - - rendered := renderFn() - c.cache.Add(key, rendered) - return rendered -} -``` - -**Cache Keys**: -- Hero: `"hero:"` -- Sidebar: `"sidebar:"` -- Table Headers: `"table:header:"` - -### String Building - -Use `strings.Builder` for concatenation: - -```go -// SLOW (repeated allocations) -result := "" -for _, line := range lines { - result += line + "\n" -} - -// FAST (single allocation) -var builder strings.Builder -builder.Grow(len(lines) * 80) // Pre-allocate -for _, line := range lines { - builder.WriteString(line) - builder.WriteByte('\n') -} -result := builder.String() -``` - -### Lipgloss Caching - -Cache styled strings: - -```go -// Cache style definitions -var titleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FF5733")) - -// Reuse style (don't recreate) -func renderTitle(text string) string { - return titleStyle.Render(text) -} -``` - -## Performance Debugging - -### Slow Navigation - -Check: -- Component re-initialization in `OnEnter` -- Missing cache usage -- Expensive computations in `Update` -- Large string allocations in `View` - -### High Memory Usage - -Check: -- Unbounded caches (use LRU with max size) -- String leaks (repeated concatenation) -- Large data structures held in view state -- Profile loading on every render - -### Startup Slowdown - -Check: -- Profile loading (should be lazy) -- Component initialization (should be deferred) -- File I/O during init -- Reflection usage - -## Test Utilities - -### `generateMockServices(n int)` - -Generates `n` mock service objects for testing. - -### `setupRouter(b *testing.B)` - -Creates router with factory for benchmarks. - -### `measureMemory(fn func())` - -Measures memory delta of a function. - -### `assertBenchmarkTarget(b *testing.B, target time.Duration)` - -Fails benchmark if average exceeds target. - -## CI/CD Integration - -Performance tests run on: -- Every commit (fast benchmarks only) -- Pull requests (full benchmark suite) -- Nightly builds (with historical comparison) - -**Slack Notifications**: Posted when benchmarks show >10% regression. - -## Related Documentation - -- Feature Spec: `specs/017-ui-engine/spec.md` (Section: Success Criteria) -- Implementation Plan: `specs/017-ui-engine/plan.md` (Section: Performance Optimizations) -- Tasks: `specs/017-ui-engine/tasks.md` (Performance benchmark tasks) - -## Test Results - -See [RESULTS.md](./RESULTS.md) for detailed performance test results. - -**Summary**: -- ✅ T128: Startup Time - 0.564ms (Target: <100ms) - **PASS** -- ✅ T129: Search Filtering - 0.001ms (Target: <100ms) - **PASS** - -## Status - -**Phase 1**: ✅ Infrastructure created -**Phase 2**: ✅ Baseline benchmarks - Complete (T128-T129) -**Phase 7**: ⏳ Full benchmark suite - In Progress diff --git a/tests/performance/RESULTS.md b/tests/performance/RESULTS.md deleted file mode 100644 index c1bbabd..0000000 --- a/tests/performance/RESULTS.md +++ /dev/null @@ -1,171 +0,0 @@ -# UI Engine Performance Results - -## Test Environment -- **Machine**: Apple M4 (arm64) -- **CPU**: Apple M4 -- **RAM**: 16 GB -- **OS**: Darwin 24.5.0 -- **Go version**: 1.24.2 -- **Date**: 2026-02-16 - -## Summary - -All performance targets met with significant headroom: - -| Metric | Target | Actual (50 services) | Status | -|--------|--------|----------------------|--------| -| Startup Time | <100ms | 0.56ms | ✅ PASS (177x faster) | -| Search Filtering | <100ms | 0.001ms | ✅ PASS (100,000x faster) | - -## Detailed Results - -### T128: Startup Time (Target: <100ms) - -Measures time to initialize and render ServicesListView with full profile/theme setup. - -``` -BenchmarkServicesStartup-10 6242 564198 ns/op 241329 B/op 4195 allocs/op -BenchmarkServicesStartupSmall-10 10000 352013 ns/op 174532 B/op 2878 allocs/op -BenchmarkServicesStartupLarge-10 6853 528226 ns/op 256214 B/op 4195 allocs/op -``` - -**Analysis**: -- 50 services: **0.564ms** (564,198 ns) -- 10 services: **0.352ms** (352,013 ns) -- 200 services: **0.528ms** (528,226 ns) -- **Status**: ✅ PASS - All results well under 100ms target -- **Headroom**: 177x faster than target (100ms / 0.564ms) - -The startup time is remarkably fast and scales linearly with data size. The slight variation between 50 and 200 services shows efficient rendering with minimal overhead. - -### T129: Search Filtering (Target: <100ms) - -Measures search filter performance with case-insensitive substring matching across all columns. - -``` -BenchmarkSearchFiltering-10 3250065 1084 ns/op 3 B/op 1 allocs/op -BenchmarkSearchFilteringSmall-10 1941772 1854 ns/op 1576 B/op 22 allocs/op -BenchmarkSearchFilteringLarge-10 37930 95310 ns/op 84008 B/op 1008 allocs/op -BenchmarkSearchFilteringNoMatch-10 210400 17121 ns/op 16736 B/op 221 allocs/op -BenchmarkSearchFilteringFullMatch-10 387042 9582 ns/op 16488 B/op 108 allocs/op -``` - -**Analysis**: -- 100 services: **0.001ms** (1,084 ns) - primary benchmark -- 10 services: **0.002ms** (1,854 ns) -- 500 services: **0.095ms** (95,310 ns) -- No match (worst case): **0.017ms** (17,121 ns) -- Full match (best case): **0.010ms** (9,582 ns) -- **Status**: ✅ PASS - All results well under 100ms target -- **Headroom**: 100,000x faster than target for 100 services - -The filtering algorithm is exceptionally fast with minimal memory allocations. Even with 500 services, filtering stays under the 100ms target. - -### Additional Benchmarks - -#### Component Initialization -``` -BenchmarkComponentInitialization-10 1000000000 0.2279 ns/op 0 B/op 0 allocs/op -``` -- **Analysis**: Component instantiation is near-zero cost with excellent caching - -#### Table Rendering Performance -``` -BenchmarkTableRendering/10rows-10 165406 22008 ns/op 13080 B/op 212 allocs/op -BenchmarkTableRendering/50rows-10 165754 21975 ns/op 13080 B/op 212 allocs/op -BenchmarkTableRendering/100rows-10 163443 22243 ns/op 13080 B/op 212 allocs/op -BenchmarkTableRendering/200rows-10 162500 21991 ns/op 13080 B/op 212 allocs/op -``` -- **Analysis**: Table rendering shows O(1) performance regardless of row count due to viewport pagination -- Consistent ~22ms render time across all data sizes -- Memory usage remains constant (13KB) due to viewport-based rendering - -#### Navigation Performance (from engine_bench_test.go) -``` -BenchmarkNavigationLatency-10 41887821 76.95 ns/op 256 B/op 4 allocs/op -BenchmarkNavigationWithArgs-10 55775904 63.88 ns/op 208 B/op 3 allocs/op -BenchmarkBackNavigation-10 54490688 65.87 ns/op 192 B/op 4 allocs/op -BenchmarkViewContextCreation-10 1000000000 0.2277 ns/op 0 B/op 0 allocs/op -``` -- **Analysis**: Navigation operations are extremely fast (<100ns) -- Well below 16ms target for 60fps smooth transitions - -## Memory Efficiency - -| Operation | Memory/op | Allocations/op | -|-----------|-----------|----------------| -| Startup (50 services) | 241 KB | 4,195 | -| Search filtering (100 services) | 3 B | 1 | -| Table rendering | 13 KB | 212 | -| Navigation | 256 B | 4 | - -**Key Findings**: -- Minimal memory allocations during filtering (only 3 bytes!) -- Startup memory scales linearly with data size -- Table rendering uses constant memory regardless of row count -- Navigation operations have negligible memory footprint - -## Performance Characteristics - -### Scaling Behavior -- **Startup**: O(n) - Linear scaling with service count -- **Filtering**: O(n) - Linear search with early termination -- **Rendering**: O(1) - Constant time due to viewport pagination - -### Optimization Opportunities -While all targets are met with significant headroom, potential future optimizations include: - -1. **Startup**: Component factory could be cached across views (currently recreated per benchmark) -2. **Filtering**: Could implement incremental filtering or indexing for datasets >1000 items -3. **Memory**: Startup allocations could be pooled to reduce GC pressure - -## Conclusion - -**T128 (Startup Time)**: ✅ PASS -- Target: <100ms -- Actual: 0.564ms (50 services) -- **177x faster than target** - -**T129 (Search Filtering)**: ✅ PASS -- Target: <100ms -- Actual: 0.001ms (100 services) -- **100,000x faster than target** - -The UI engine demonstrates exceptional performance characteristics with massive headroom above the targets. The implementation is production-ready with no performance bottlenecks identified. - -## Running the Benchmarks - -To reproduce these results: - -```bash -# Run all UI benchmarks -go test -bench=. -benchmem ./tests/performance/ - -# Run specific benchmarks -go test -bench=BenchmarkServicesStartup -benchmem ./tests/performance/ -go test -bench=BenchmarkSearchFiltering -benchmem ./tests/performance/ - -# Run with custom benchmark time -go test -bench=. -benchmem -benchtime=5s ./tests/performance/ -``` - -## Benchmark Test Coverage - -The following benchmarks validate performance requirements: - -### T128: Startup Time -- `BenchmarkServicesStartup` - Primary benchmark (50 services) -- `BenchmarkServicesStartupSmall` - Small dataset (10 services) -- `BenchmarkServicesStartupLarge` - Large dataset (200 services) - -### T129: Search Filtering -- `BenchmarkSearchFiltering` - Primary benchmark (100 services) -- `BenchmarkSearchFilteringSmall` - Small dataset (10 services) -- `BenchmarkSearchFilteringLarge` - Large dataset (500 services) -- `BenchmarkSearchFilteringNoMatch` - Worst case (no matches) -- `BenchmarkSearchFilteringFullMatch` - Best case (all match) - -### Additional Coverage -- `BenchmarkComponentInitialization` - Component creation overhead -- `BenchmarkTableRendering` - Rendering performance across data sizes -- Navigation benchmarks (from engine_bench_test.go) diff --git a/tests/performance/engine_bench_test.go b/tests/performance/engine_bench_test.go deleted file mode 100644 index b576ba0..0000000 --- a/tests/performance/engine_bench_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package performance - -import ( - "testing" - - tea "github.com/charmbracelet/bubbletea" - - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" -) - -// mockView implements engine.View for benchmarking -type mockView struct { - name string -} - -func (m *mockView) Init() tea.Cmd { - return nil -} - -func (m *mockView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - return m, nil -} - -func (m *mockView) View() string { - return "benchmark view: " + m.name -} - -func (m *mockView) OnEnter(ctx *engine.ViewContext) tea.Cmd { - return nil -} - -func (m *mockView) OnExit() tea.Cmd { - return nil -} - -func (m *mockView) Name() string { - return m.name -} - -func (m *mockView) Keybindings() []engine.KeyBinding { - return []engine.KeyBinding{ - {Key: "q", Description: "Quit"}, - } -} - -// setupRouter creates a router with sample views for benchmarking -func setupRouter() *engine.Router { - profile := &profiles.Profile{Name: "enterprise"} - theme := &themes.Theme{Name: "enterprise-dark"} - router := engine.NewRouter(profile, theme) - - // Register multiple views - router.Register(&mockView{name: "home"}) - router.Register(&mockView{name: "services"}) - router.Register(&mockView{name: "service-detail"}) - router.Register(&mockView{name: "dashboard"}) - router.Register(&mockView{name: "config"}) - - return router -} - -// BenchmarkNavigationLatency measures the time to navigate between views. -// Target: <16ms per navigation (60fps requirement) -func BenchmarkNavigationLatency(b *testing.B) { - router := setupRouter() - - // Navigate to initial view - _ = router.Navigate("home", nil) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = router.Navigate("services", nil) - _ = router.Navigate("home", nil) - } -} - -// BenchmarkNavigationWithArgs measures navigation with route parameters. -func BenchmarkNavigationWithArgs(b *testing.B) { - router := setupRouter() - - args := map[string]any{ - "serviceName": "postgres", - "showConfig": true, - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = router.Navigate("service-detail", args) - _ = router.Navigate("home", nil) - } -} - -// BenchmarkBackNavigation measures back navigation performance. -func BenchmarkBackNavigation(b *testing.B) { - router := setupRouter() - - // Build up history - _ = router.Navigate("home", nil) - _ = router.Navigate("services", nil) - _ = router.Navigate("service-detail", nil) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = router.Back() - _ = router.Navigate("service-detail", nil) - } -} - -// BenchmarkRouterRegister measures view registration overhead. -func BenchmarkRouterRegister(b *testing.B) { - router := setupRouter() - - b.ResetTimer() - for i := 0; i < b.N; i++ { - router.Register(&mockView{name: "benchmark-view"}) - } -} - -// BenchmarkViewContextCreation measures ViewContext allocation overhead. -func BenchmarkViewContextCreation(b *testing.B) { - profile := &profiles.Profile{Name: "enterprise"} - theme := &themes.Theme{Name: "enterprise-dark"} - args := map[string]any{ - "key": "value", - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = engine.NewViewContext(profile, theme, 120, 40, args) - } -} - -// BenchmarkHistoryManagement measures history stack operations. -func BenchmarkHistoryManagement(b *testing.B) { - router := setupRouter() - - // Navigate to build history - for i := 0; i < 10; i++ { - _ = router.Navigate("home", nil) - _ = router.Navigate("services", nil) - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = router.History() - } -} - -// BenchmarkMultipleViewNavigations measures navigation through multiple views. -func BenchmarkMultipleViewNavigations(b *testing.B) { - router := setupRouter() - - views := []string{"home", "services", "dashboard", "config", "service-detail"} - - b.ResetTimer() - for i := 0; i < b.N; i++ { - for _, viewName := range views { - _ = router.Navigate(viewName, nil) - } - } -} - -// BenchmarkRenderModeFromFlags measures flag parsing overhead. -func BenchmarkRenderModeFromFlags(b *testing.B) { - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = engine.RenderModeFromFlags(false, false) - _ = engine.RenderModeFromFlags(true, false) - _ = engine.RenderModeFromFlags(false, true) - } -} diff --git a/tests/performance/ui_bench_test.go b/tests/performance/ui_bench_test.go deleted file mode 100644 index 086bfc5..0000000 --- a/tests/performance/ui_bench_test.go +++ /dev/null @@ -1,435 +0,0 @@ -package performance - -import ( - "fmt" - "testing" - - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/components/table" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" - "github.com/arc-framework/arc-cli/pkg/ui/views" -) - -// setupComponentFactory creates a factory with profile and theme for benchmarking -func setupComponentFactory() ui.ComponentFactory { - // Load enterprise profile and theme - profile := &profiles.Profile{ - ID: "enterprise", - Name: "Enterprise", - Description: "Professional-grade architecture tools", - TierNames: []string{"Starter", "Pro", "Ultra"}, - ThemeID: "enterprise-dark", - } - theme := &themes.Theme{ - Name: "enterprise-dark", - Colors: themes.ColorSet{ - Primary: "#4A90E2", - Secondary: "#E94B3C", - Success: "#50C878", - Warning: "#F5A623", - Error: "#E94B3C", - Info: "#4A90E2", - Foreground: "#FFFFFF", - Background: "#1E1E1E", - Muted: "#6C757D", - Border: "#444444", - }, - } - - profileCtx, err := profiles.NewProfileContext(profile, theme) - if err != nil { - panic(fmt.Sprintf("failed to create profile context: %v", err)) - } - return ui.NewComponentFactory(profileCtx, components.BorderTierClassic) -} - -// generateMockServices creates a specified number of mock service rows -func generateMockServices(count int) []table.Row { - technologies := []string{"PostgreSQL", "Redis", "MongoDB", "MySQL", "Elasticsearch"} - roles := []string{"Data", "Cache", "Search", "Storage", "Analytics"} - descriptions := []string{ - "Relational database", - "In-memory cache", - "Document database", - "SQL database", - "Search engine", - } - - rows := make([]table.Row, count) - for i := 0; i < count; i++ { - techIdx := i % len(technologies) - rows[i] = table.Row{ - fmt.Sprintf("service-%d (%s)", i, technologies[techIdx]), - roles[techIdx], - descriptions[techIdx], - } - } - return rows -} - -// BenchmarkServicesStartup measures time to initialize and render ServicesListView. -// Target: <100ms (T128) -// -// This benchmark simulates the full startup sequence: -// 1. Create component factory with profile/theme -// 2. Initialize ServicesListView -// 3. Call OnEnter to setup components -// 4. Render initial view -func BenchmarkServicesStartup(b *testing.B) { - // Test with 50 services - rows := generateMockServices(50) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - // Simulate full startup sequence - factory := setupComponentFactory() - view := views.NewServicesListView(factory) - - // Simulate OnEnter + first render - ctx := engine.NewViewContext( - &profiles.Profile{ID: "enterprise", Name: "Enterprise"}, - factory.Theme(), - 120, // width - 40, // height - map[string]any{ - "rows": rows, - }, - ) - _ = view.OnEnter(ctx) - _ = view.View() - } -} - -// BenchmarkServicesStartupSmall measures startup with minimal data (10 services). -func BenchmarkServicesStartupSmall(b *testing.B) { - rows := generateMockServices(10) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - factory := setupComponentFactory() - view := views.NewServicesListView(factory) - - ctx := engine.NewViewContext( - &profiles.Profile{ID: "enterprise", Name: "Enterprise"}, - factory.Theme(), - 120, - 40, - map[string]any{ - "rows": rows, - }, - ) - _ = view.OnEnter(ctx) - _ = view.View() - } -} - -// BenchmarkServicesStartupLarge measures startup with large dataset (200 services). -func BenchmarkServicesStartupLarge(b *testing.B) { - rows := generateMockServices(200) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - factory := setupComponentFactory() - view := views.NewServicesListView(factory) - - ctx := engine.NewViewContext( - &profiles.Profile{ID: "enterprise", Name: "Enterprise"}, - factory.Theme(), - 120, - 40, - map[string]any{ - "rows": rows, - }, - ) - _ = view.OnEnter(ctx) - _ = view.View() - } -} - -// BenchmarkSearchFiltering measures search filter performance. -// Target: <100ms (T129) -// -// This benchmark measures the time to filter 100 services by a search query. -// Tests the filterRows() method which does case-insensitive substring matching. -func BenchmarkSearchFiltering(b *testing.B) { - factory := setupComponentFactory() - view := views.NewServicesListView(factory) - - // Load with 100 services - rows := generateMockServices(100) - ctx := engine.NewViewContext( - &profiles.Profile{ID: "enterprise", Name: "Enterprise"}, - factory.Theme(), - 120, - 40, - map[string]any{ - "rows": rows, - }, - ) - _ = view.OnEnter(ctx) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - // Simulate user typing "post" to filter for postgres - // This calls the internal filterRows() method via the search bar onChange - view.Update(b) - } -} - -// BenchmarkSearchFilteringSmall measures filtering with small dataset (10 services). -func BenchmarkSearchFilteringSmall(b *testing.B) { - factory := setupComponentFactory() - view := views.NewServicesListView(factory) - - rows := generateMockServices(10) - ctx := engine.NewViewContext( - &profiles.Profile{ID: "enterprise", Name: "Enterprise"}, - factory.Theme(), - 120, - 40, - map[string]any{ - "rows": rows, - }, - ) - _ = view.OnEnter(ctx) - - // Create a mock search query - query := "postgres" - - b.ResetTimer() - for i := 0; i < b.N; i++ { - // Directly test the filtering logic - filtered := filterRows(rows, query) - _ = filtered - } -} - -// BenchmarkSearchFilteringLarge measures filtering with large dataset (500 services). -func BenchmarkSearchFilteringLarge(b *testing.B) { - factory := setupComponentFactory() - view := views.NewServicesListView(factory) - - rows := generateMockServices(500) - ctx := engine.NewViewContext( - &profiles.Profile{ID: "enterprise", Name: "Enterprise"}, - factory.Theme(), - 120, - 40, - map[string]any{ - "rows": rows, - }, - ) - _ = view.OnEnter(ctx) - - query := "postgres" - - b.ResetTimer() - for i := 0; i < b.N; i++ { - filtered := filterRows(rows, query) - _ = filtered - } -} - -// BenchmarkSearchFilteringNoMatch measures worst-case filtering (no matches). -func BenchmarkSearchFilteringNoMatch(b *testing.B) { - rows := generateMockServices(100) - query := "nonexistent-service-xyz" - - b.ResetTimer() - for i := 0; i < b.N; i++ { - filtered := filterRows(rows, query) - _ = filtered - } -} - -// BenchmarkSearchFilteringFullMatch measures best-case filtering (all match). -func BenchmarkSearchFilteringFullMatch(b *testing.B) { - rows := generateMockServices(100) - query := "service" // All rows contain "service-" - - b.ResetTimer() - for i := 0; i < b.N; i++ { - filtered := filterRows(rows, query) - _ = filtered - } -} - -// BenchmarkComponentInitialization measures individual component creation overhead. -func BenchmarkComponentInitialization(b *testing.B) { - factory := setupComponentFactory() - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = views.NewServicesListView(factory) - } -} - -// BenchmarkTableRendering measures table render performance with various row counts. -func BenchmarkTableRendering(b *testing.B) { - factory := setupComponentFactory() - theme := factory.Theme() - - columns := []table.Column{ - {Title: "Service", Width: 30}, - {Title: "Role", Width: 15}, - {Title: "Description", Width: 45}, - } - - testCases := []struct { - name string - rowCount int - }{ - {"10rows", 10}, - {"50rows", 50}, - {"100rows", 100}, - {"200rows", 200}, - } - - for _, tc := range testCases { - b.Run(tc.name, func(b *testing.B) { - rows := generateMockServices(tc.rowCount) - dt := table.NewDataTable(columns, rows, theme) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = dt.View() - } - }) - } -} - -// filterRows is a helper that replicates the ServicesListView filtering logic. -// Used for isolated benchmarking of the filter algorithm. -func filterRows(allRows []table.Row, query string) []table.Row { - if query == "" { - return allRows - } - - // Filter rows by checking if any column contains the query (case-insensitive) - queryLower := toLower(query) - filtered := []table.Row{} - - for _, row := range allRows { - for _, cell := range row { - if contains(toLower(cell), queryLower) { - filtered = append(filtered, row) - break // Match found in this row, move to next row - } - } - } - - return filtered -} - -// toLower is a simple lowercase conversion (avoiding strings import for benchmark purity) -func toLower(s string) string { - // Use strings.ToLower in production, but inline for benchmark isolation - result := make([]rune, len(s)) - for i, r := range s { - if r >= 'A' && r <= 'Z' { - result[i] = r + 32 - } else { - result[i] = r - } - } - return string(result) -} - -// contains checks if s contains substr (simple implementation) -func contains(s, substr string) bool { - if len(substr) > len(s) { - return false - } - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} - -// BenchmarkVersionViewStartup measures VersionView creation time (T276). -// Target: <100ms startup. -func BenchmarkVersionViewStartup(b *testing.B) { - factory := setupComponentFactory() - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = views.NewVersionView(factory, "1.0.0", "abc1234", "2026-02-28T00:00:00Z", false) - } -} - -// BenchmarkVersionViewStartupVerbose measures VersionView verbose mode creation (T276). -func BenchmarkVersionViewStartupVerbose(b *testing.B) { - factory := setupComponentFactory() - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = views.NewVersionView(factory, "1.0.0", "abc1234", "2026-02-28T00:00:00Z", true) - } -} - -// BenchmarkTableSort measures table row sorting performance (T279). -// This benchmarks the overhead of re-filtering/sorting a large dataset. -func BenchmarkTableSort(b *testing.B) { - factory := setupComponentFactory() - theme := factory.Theme() - - columns := []table.Column{ - {Title: "Service", Width: 30}, - {Title: "Role", Width: 15}, - {Title: "Description", Width: 45}, - } - - testCases := []struct { - name string - rowCount int - }{ - {"50rows", 50}, - {"100rows", 100}, - {"500rows", 500}, - } - - for _, tc := range testCases { - b.Run(tc.name, func(b *testing.B) { - rows := generateMockServices(tc.rowCount) - dt := table.NewDataTable(columns, rows, theme) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - // Simulate sort by resetting rows (approximates sort overhead) - dt.SetRows(rows) - _ = dt.View() - } - }) - } -} - -// BenchmarkTableSortFiltered measures sort + filter combined performance (T279). -func BenchmarkTableSortFiltered(b *testing.B) { - factory := setupComponentFactory() - theme := factory.Theme() - - columns := []table.Column{ - {Title: "Service", Width: 30}, - {Title: "Role", Width: 15}, - {Title: "Description", Width: 45}, - } - - rows := generateMockServices(200) - queries := []string{"data", "postgres", "redis", "xyz"} - - for _, q := range queries { - b.Run("query="+q, func(b *testing.B) { - b.ResetTimer() - for i := 0; i < b.N; i++ { - filtered := filterRows(rows, q) - dt := table.NewDataTable(columns, filtered, theme) - _ = dt.View() - } - }) - } -} diff --git a/tests/visual/.gitkeep b/tests/visual/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/visual/IMPLEMENTATION_SUMMARY.md b/tests/visual/IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index 00365f2..0000000 --- a/tests/visual/IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,402 +0,0 @@ -# Visual Regression Testing Implementation Summary - -**Implementation Date**: 2026-02-16 -**Feature**: 016-ui-layout-fix Phase 5 -**Tasks**: T121-T125 -**Status**: ✅ Complete - -## Overview - -Successfully implemented a comprehensive visual regression testing system for A.R.C. CLI UI components. The system captures golden files (baseline snapshots) for all 10 profiles and enables automated detection of visual regressions. - -## Deliverables - -### 1. Test Infrastructure - -**File**: `visual_regression_test.go` (441 lines) - -**Features**: -- Profile context loading with theme integration -- View rendering with fixed dimensions (120x40) -- ANSI code stripping for platform-independent comparison -- Golden file management (compare/update modes) -- Comprehensive test coverage (6 test groups) -- Performance benchmarks - -**Key Functions**: -```go -loadProfileContext(profileID string) *profiles.ProfileContext -stripANSI(s string) string -goldenFilePath(testName, profileID string) string -compareOrUpdateGolden(t *testing.T, testName, profileID, rendered string) -renderServicesListView(profileCtx *profiles.ProfileContext) string -renderServiceDetailView(profileCtx *profiles.ProfileContext, serviceName string) string -``` - -### 2. Golden Files (11 total) - -**Services List View** (10 profiles): -- `golden/services_list_enterprise.txt` - 5.5K -- `golden/services_list_saiyan.txt` - 5.5K -- `golden/services_list_jedi.txt` - 5.5K -- `golden/services_list_pirate.txt` - 5.5K -- `golden/services_list_horcrux.txt` - 5.5K -- `golden/services_list_pokemon.txt` - 5.5K -- `golden/services_list_shinobi.txt` - 5.5K -- `golden/services_list_triforce.txt` - 5.5K -- `golden/services_list_bending.txt` - 5.5K -- `golden/services_list_crystal.txt` - 5.5K - -**Service Detail View** (1 baseline): -- `golden/service_detail_enterprise.txt` - 5.3K - -**Total Size**: ~60KB - -### 3. Documentation - -**Files Created**: -1. `VISUAL_TESTING.md` (6.3K) - Comprehensive testing guide -2. `TEST_REPORT.md` (7.6K) - Detailed test results and metrics -3. `IMPLEMENTATION_SUMMARY.md` (this file) - -**Topics Covered**: -- Test execution (comparison and update modes) -- Golden file format and structure -- Adding new visual tests -- Best practices and troubleshooting -- Performance metrics and benchmarks - -## Task Completion Details - -### ✅ T121: Create golden file for services list (Enterprise profile) - -**Status**: Complete -**File**: `golden/services_list_enterprise.txt` - -**Contents**: -- Search bar with "🔍 Search services..." placeholder -- Data table with 4 columns: Service, Technology, Role, Description -- 4 mock services: postgres, redis, mongodb, mysql -- Status bar with keybindings: ↑/↓, /, s, enter, q -- 40 lines total, properly formatted with borders - -**Verification**: -```bash -go test -v ./tests/visual -run "AllProfiles/enterprise" -# PASS: TestServicesListView_VisualRegression_AllProfiles/enterprise -``` - -### ✅ T122: Create golden file for services list (Saiyan profile) - -**Status**: Complete -**File**: `golden/services_list_saiyan.txt` - -**Contents**: -- Same structure as Enterprise -- Theme-specific styling (ANSI codes stripped) -- Consistent with all other profiles - -**Verification**: -```bash -go test -v ./tests/visual -run "AllProfiles/saiyan" -# PASS: TestServicesListView_VisualRegression_AllProfiles/saiyan -``` - -### ✅ T123: Create golden file for service detail - -**Status**: Complete -**File**: `golden/service_detail_enterprise.txt` - -**Contents**: -- Breadcrumb navigation: Home › Services › postgres -- Tree structure with expandable sections: - - Configuration (Port: 5432, Host: localhost, Database: mydb) - - Status (State: running, Uptime: 2h 30m) - - Dependencies (None) -- Status bar with keybindings: b, ↑/↓, q -- 39 lines total, properly formatted - -**Verification**: -```bash -go test -v ./tests/visual -run "ServiceDetailView_VisualRegression_Enterprise" -# PASS: TestServiceDetailView_VisualRegression_Enterprise -``` - -### ✅ T124: Write visual regression test runner - -**Status**: Complete -**File**: `visual_regression_test.go` - -**Test Cases Implemented**: - -1. **TestServicesListView_VisualRegression_AllProfiles** - - Tests all 10 profiles against golden files - - Verifies search bar, table, status bar content - - Compares rendered output with golden baseline - -2. **TestServiceDetailView_VisualRegression_Enterprise** - - Tests detail view against golden file - - Verifies breadcrumb, tree, service data - - Baseline for future profile expansion - -3. **TestServicesListView_NoRenderingErrors** - - Verifies all profiles render without panics - - Checks for non-empty output - - No golden file comparison - -4. **TestServiceDetailView_NoRenderingErrors** - - Verifies detail view renders for all profiles - - Checks for non-empty output - - No golden file comparison - -5. **TestGoldenFilesDeterministic** - - Verifies rendering is deterministic - - Ensures same input produces same output - - Critical for reliable regression detection - -6. **TestANSIStripping** - - Verifies ANSI code removal works correctly - - Tests various ANSI sequences - - Ensures clean golden files - -**Benchmark Tests**: -```go -BenchmarkServicesListView_Rendering -// Tests rendering performance across all profiles -// Average: ~300µs per render, ~4000 ops/sec -``` - -**Features**: -- `-update-golden` flag for regenerating baselines -- Fixed dimensions (120x40) for consistency -- ANSI stripping for clean comparison -- Comprehensive error messages on mismatch -- Profile context loading with theme integration - -**Verification**: -```bash -go test -v ./tests/visual -# PASS: All 6 test groups -# Total execution time: ~180ms -``` - -### ✅ T125: Test all 10 profiles render correctly - -**Status**: Complete -**Test**: `TestServicesListView_NoRenderingErrors` - -**Profiles Verified**: -1. ✅ Enterprise - PASS -2. ✅ Saiyan - PASS -3. ✅ Jedi - PASS -4. ✅ Pirate - PASS -5. ✅ Horcrux - PASS -6. ✅ Pokemon - PASS -7. ✅ Shinobi - PASS -8. ✅ Triforce - PASS -9. ✅ Bending - PASS -10. ✅ Crystal - PASS - -**Verification**: -```bash -go test -v ./tests/visual -run "NoRenderingErrors" -# PASS: TestServicesListView_NoRenderingErrors (all 10 profiles) -# PASS: TestServiceDetailView_NoRenderingErrors (all 10 profiles) -``` - -**Test Results**: -- No panics during rendering ✓ -- Non-empty output for all profiles ✓ -- Consistent structure across profiles ✓ -- Theme integration working correctly ✓ - -## Performance Metrics - -### Test Execution Speed - -| Test | Duration | Status | -|------|----------|--------| -| Visual Regression (All Profiles) | 10ms | PASS | -| Service Detail Regression | <1ms | PASS | -| No Rendering Errors (20 profiles) | 10ms | PASS | -| Deterministic Check | <1ms | PASS | -| ANSI Stripping | <1ms | PASS | -| **Total Suite** | **~180ms** | **PASS** | - -### Rendering Performance (Benchmarks) - -``` -Profile Ops/sec ns/op Memory Allocs ----------------------------------------------- -Enterprise 4,045 295µs 163KB 2,452 -Saiyan 3,999 295µs 164KB 2,452 -Jedi 4,092 298µs 164KB 2,452 -Pirate 4,003 327µs 164KB 2,452 -Horcrux 3,915 295µs 163KB 2,452 -Pokemon 4,094 297µs 163KB 2,452 -Shinobi 4,035 294µs 163KB 2,452 -Triforce 4,087 332µs 163KB 2,452 -Bending 4,087 294µs 163KB 2,452 -Crystal 4,027 297µs 164KB 2,452 ----------------------------------------------- -Average 4,028 300µs 163KB 2,452 -``` - -**Key Insights**: -- Rendering is fast (~300µs per view) -- Consistent performance across profiles -- Low memory footprint (~163KB) -- Efficient allocation count (~2,452) - -## Quality Assurance - -### Test Coverage - -- **Views Tested**: 2 (ServicesListView, ServiceDetailView) -- **Profiles Tested**: 10/10 (100%) -- **Test Cases**: 6 test groups -- **Golden Files**: 11 files -- **Pass Rate**: 100% - -### Code Quality - -- **Lines of Code**: 441 (test file) -- **Documentation**: 3 comprehensive guides -- **Comments**: Extensive inline documentation -- **Error Handling**: Comprehensive with clear messages -- **Maintainability**: High (clear structure, good naming) - -### Reliability - -- **Determinism**: ✅ Verified (identical output on multiple runs) -- **Platform Independence**: ✅ ANSI stripping ensures consistency -- **Error Detection**: ✅ Clear diffs when regressions occur -- **Update Workflow**: ✅ Simple `-update-golden` flag - -## Usage Examples - -### Run All Tests - -```bash -go test -v ./tests/visual -``` - -### Update Golden Files - -```bash -go test -v ./tests/visual -update-golden -``` - -### Test Specific Profile - -```bash -go test -v ./tests/visual -run "AllProfiles/saiyan" -``` - -### Run Benchmarks - -```bash -go test -bench=. -benchmem ./tests/visual -``` - -### Check Coverage - -```bash -go test -cover ./tests/visual -``` - -## Integration Points - -### Profile System Integration - -- Uses `profiles.NewRepository()` to load profiles -- Loads themes via `themes.NewLoader()` -- Creates `ProfileContext` for each test -- Verifies theme application across all profiles - -### View System Integration - -- Tests `ServicesListView` and `ServiceDetailView` -- Uses `engine.ViewContext` for initialization -- Simulates `tea.WindowSizeMsg` for layout -- Verifies component rendering (search, table, tree, status) - -### Component Integration - -- Search bar with placeholder text -- Data table with sorting and navigation -- Tree structure with expandable nodes -- Status bar with keybindings -- Breadcrumb navigation - -## Files Modified/Created - -### New Files (14 total) - -``` -tests/visual/ -├── golden/ (11 files) -│ ├── service_detail_enterprise.txt -│ └── services_list_*.txt (10 files) -├── visual_regression_test.go (1 file) -├── VISUAL_TESTING.md (1 file) -├── TEST_REPORT.md (1 file) -└── IMPLEMENTATION_SUMMARY.md (1 file - this file) -``` - -### No Files Modified - -All implementation was additive - no existing files were modified. - -## Git Status - -```bash -git status tests/visual/ -# On branch 016-ui-layout-fix -# Changes to be committed: -# new file: tests/visual/TEST_REPORT.md -# new file: tests/visual/VISUAL_TESTING.md -# new file: tests/visual/golden/service_detail_enterprise.txt -# new file: tests/visual/golden/services_list_bending.txt -# new file: tests/visual/golden/services_list_crystal.txt -# new file: tests/visual/golden/services_list_enterprise.txt -# new file: tests/visual/golden/services_list_horcrux.txt -# new file: tests/visual/golden/services_list_jedi.txt -# new file: tests/visual/golden/services_list_pirate.txt -# new file: tests/visual/golden/services_list_pokemon.txt -# new file: tests/visual/golden/services_list_saiyan.txt -# new file: tests/visual/golden/services_list_shinobi.txt -# new file: tests/visual/golden/services_list_triforce.txt -# new file: tests/visual/visual_regression_test.go -``` - -## Next Steps - -### Recommended Actions - -1. **CI Integration**: Add visual regression tests to GitHub Actions -2. **Pre-commit Hook**: Integrate tests into pre-commit workflow -3. **Coverage Expansion**: Add more views as they are developed -4. **Performance Monitoring**: Track benchmark results over time -5. **Documentation**: Link to this system from main README - -### Future Enhancements - -1. Add golden files for more profiles (if new profiles are added) -2. Extend to test DashboardView and HomeView -3. Add pixel-perfect visual diff tool (optional) -4. Create update script for batch golden file regeneration -5. Add visual diff preview in CI (show before/after on failures) - -## Conclusion - -Visual regression testing infrastructure is **complete and operational**. All tasks (T121-T125) have been successfully implemented with: - -✅ 11 golden files covering all 10 profiles -✅ Comprehensive test runner with 6 test groups -✅ Excellent performance (~300µs per render) -✅ 100% test pass rate -✅ Full documentation (3 guides) -✅ Ready for production use - -The system provides a solid foundation for catching visual regressions across all A.R.C. CLI profiles and ensures consistent UI rendering quality. diff --git a/tests/visual/QUICK_REFERENCE.md b/tests/visual/QUICK_REFERENCE.md deleted file mode 100644 index 9a3fa78..0000000 --- a/tests/visual/QUICK_REFERENCE.md +++ /dev/null @@ -1,164 +0,0 @@ -# Visual Regression Testing - Quick Reference - -## Common Commands - -### Run All Tests -```bash -go test -v ./tests/visual -``` - -### Update Golden Files -```bash -go test -v ./tests/visual -update-golden -``` - -### Test Specific Profile -```bash -go test -v ./tests/visual -run "AllProfiles/enterprise" -go test -v ./tests/visual -run "AllProfiles/saiyan" -``` - -### Run Benchmarks -```bash -go test -bench=. -benchmem ./tests/visual -``` - -### Watch Mode (with entr) -```bash -ls tests/visual/*.go | entr -c go test ./tests/visual -``` - -## Test Groups - -| Test | What It Does | When to Run | -|------|--------------|-------------| -| `TestServicesListView_VisualRegression_AllProfiles` | Compares services list against golden files (all profiles) | After UI changes | -| `TestServiceDetailView_VisualRegression_Enterprise` | Compares service detail against golden file | After detail view changes | -| `TestServicesListView_NoRenderingErrors` | Verifies all profiles render without errors | Before committing | -| `TestServiceDetailView_NoRenderingErrors` | Verifies detail view renders for all profiles | Before committing | -| `TestGoldenFilesDeterministic` | Ensures rendering is deterministic | After rendering logic changes | -| `TestANSIStripping` | Verifies ANSI code removal | After stripANSI changes | - -## When to Update Golden Files - -✅ **Update When**: -- Intentional UI changes (new features, redesigns) -- Bug fixes that change visual output -- Component improvements -- Theme updates - -❌ **Don't Update When**: -- Tests fail unexpectedly -- You're not sure why output changed -- You haven't reviewed the diff -- Changes weren't intentional - -## Workflow - -### Making UI Changes - -1. Make your code changes -2. Run tests: `go test ./tests/visual` -3. If tests fail: - - Review the diff - - If intentional: `go test ./tests/visual -update-golden` - - If unintentional: fix your code -4. Commit both code and golden files - -### Adding New Views - -1. Create render helper in `visual_regression_test.go` -2. Create test function (copy existing pattern) -3. Generate golden files: `go test ./tests/visual -update-golden -run YourNewTest` -4. Verify golden files look correct -5. Run tests normally: `go test ./tests/visual` -6. Commit all files - -## Golden File Locations - -``` -tests/visual/golden/ -├── service_detail_enterprise.txt # Detail view baseline -├── services_list_enterprise.txt # List view for Enterprise -├── services_list_saiyan.txt # List view for Saiyan -├── services_list_jedi.txt # List view for Jedi -├── services_list_pirate.txt # List view for Pirate -├── services_list_horcrux.txt # List view for Horcrux -├── services_list_pokemon.txt # List view for Pokemon -├── services_list_shinobi.txt # List view for Shinobi -├── services_list_triforce.txt # List view for Triforce -├── services_list_bending.txt # List view for Bending -└── services_list_crystal.txt # List view for Crystal -``` - -## Troubleshooting - -### "Output does not match golden file" -- **Cause**: Visual regression detected -- **Fix**: Review diff, update if intentional: `go test ./tests/visual -update-golden` - -### "Failed to read golden file" -- **Cause**: Golden file doesn't exist -- **Fix**: Generate it: `go test ./tests/visual -update-golden -run ` - -### Tests pass locally but fail in CI -- **Cause**: Platform-specific rendering differences -- **Fix**: Ensure ANSI stripping is working (should be automatic) - -### All profiles show identical output -- **Cause**: Expected after ANSI stripping -- **Note**: Colors are in ANSI codes which are stripped for comparison - -## Performance Expectations - -- Single test: ~10ms -- Full suite: ~180ms -- Single render: ~300µs -- Benchmark: ~4000 ops/sec - -If tests are slower, investigate: -- Profile loading issues -- File system performance -- Memory pressure - -## Best Practices - -1. ✅ Always review diffs before updating -2. ✅ Run tests before committing -3. ✅ Update golden files deliberately -4. ✅ Test all profiles when making UI changes -5. ✅ Document why you updated golden files in commits - -## Quick Checks - -### Is Everything Working? -```bash -go test ./tests/visual -# Should see: PASS (all tests) -``` - -### Did I Break Something? -```bash -go test -v ./tests/visual | grep FAIL -# Should be empty if nothing broke -``` - -### How Fast Are My Changes? -```bash -go test -bench=BenchmarkServicesListView_Rendering ./tests/visual -# Should be ~300µs per render -``` - -## Help & Documentation - -- **Full Guide**: `VISUAL_TESTING.md` -- **Test Report**: `TEST_REPORT.md` -- **Implementation**: `IMPLEMENTATION_SUMMARY.md` -- **This File**: `QUICK_REFERENCE.md` - -## Contact - -For questions about visual regression testing: -- Check documentation files above -- Review test code: `visual_regression_test.go` -- Check Phase 5 spec: `specs/016-ui-layout-fix/PHASE_5_VISUAL_VALIDATION.md` diff --git a/tests/visual/README.md b/tests/visual/README.md deleted file mode 100644 index 0540f8a..0000000 --- a/tests/visual/README.md +++ /dev/null @@ -1,189 +0,0 @@ -# Visual Regression Tests - -**Purpose**: Golden file tests for UI output validation across all profiles and views. - -## Overview - -Visual regression tests capture rendered TUI output and compare against baseline "golden files" to detect unintended visual changes. - -## Test Structure - -``` -tests/visual/ -├── README.md # This file -├── golden/ # Expected output baselines -│ ├── home_enterprise.txt -│ ├── home_saiyan.txt -│ ├── services_list_enterprise.txt -│ └── ... -├── visual_test.go # Main test file -└── helpers.go # Test utilities -``` - -## Test Matrix - -**Profiles** (10 total): -- Enterprise -- Saiyan -- Jedi -- Pirate -- Steampunk -- Cyberpunk -- Gothic -- Renaissance -- Samurai -- Viking - -**Views** (6+ primary): -- Home (hero layout) -- Info (hero layout) -- Version (compact layout) -- Dashboard (sidebar layout) -- Services List (sidebar + table) -- Service Detail (sidebar + tree) - -**Total Golden Files**: 60+ (10 profiles × 6 views) - -## Running Tests - -```bash -# Run all visual tests -go test -v ./tests/visual/... - -# Update golden files (after intentional changes) -go test -v ./tests/visual/... -update - -# Test specific profile -go test -v ./tests/visual/... -run TestHomeView/Enterprise - -# Test specific view -go test -v ./tests/visual/... -run TestHomeView -``` - -## Test Example - -```go -package visual_test - -import ( - "testing" - "github.com/stretchr/testify/require" -) - -func TestHomeView(t *testing.T) { - profiles := []string{ - "enterprise", "saiyan", "jedi", "pirate", - "steampunk", "cyberpunk", "gothic", "renaissance", - "samurai", "viking", - } - - for _, profile := range profiles { - t.Run(profile, func(t *testing.T) { - // Setup view with profile - view := setupHomeView(t, profile) - - // Render output - output := view.View() - - // Compare with golden file - goldenFile := filepath.Join("golden", fmt.Sprintf("home_%s.txt", profile)) - compareWithGolden(t, output, goldenFile) - }) - } -} -``` - -## Golden File Management - -### Creating Golden Files - -1. Implement view -2. Run test with `-update` flag: - ```bash - go test -v ./tests/visual/... -update - ``` -3. Verify generated golden files manually -4. Commit golden files to repository - -### Updating Golden Files - -After intentional UI changes: - -1. Update view implementation -2. Run test with `-update` flag -3. Review diff in golden files carefully -4. Commit updated golden files with descriptive message - -**WARNING**: Only update golden files after verifying changes are intentional! - -## Test Coverage Requirements - -- All views must have visual regression tests -- All 10 profiles must be tested for each view -- Terminal widths tested: 80, 120, 160 columns - -## CI/CD Integration - -Visual tests run automatically on: -- Every commit to feature branches -- All pull requests -- Pre-release builds - -## Debugging Failed Tests - -When a visual test fails: - -1. Check the diff output in test logs -2. Run test locally to inspect actual output -3. Compare actual vs expected golden file -4. Determine if change is intentional or a bug - -**Common Causes**: -- Border rendering issues (ANSI width calculations) -- Profile color changes -- Component layout shifts -- Terminal width assumptions - -## Test Utilities - -### `compareWithGolden(t, actual, goldenPath)` - -Compares actual output with golden file. - -**Behavior**: -- If golden file missing: Creates it (with `-update` flag) -- If output matches: Test passes -- If output differs: Test fails with diff - -### `setupViewWithProfile(t, profile)` - -Helper to initialize view with specific profile. - -### `renderAtWidth(view, width)` - -Renders view at specific terminal width for responsive tests. - -## Performance Considerations - -Visual tests are I/O intensive (file reads/writes). To optimize: - -- Run in parallel where possible (`t.Parallel()`) -- Use golden file caching -- Skip slow tests during development (`-short` flag) - -```bash -# Quick test (skip large profile matrix) -go test -v -short ./tests/visual/... -``` - -## Related Documentation - -- Feature Spec: `specs/017-ui-engine/spec.md` (Section: Testing Approach) -- Implementation Plan: `specs/017-ui-engine/plan.md` (Section: Testing Strategy) -- Tasks: `specs/017-ui-engine/tasks.md` (Visual regression tasks) - -## Status - -**Phase 1**: ✅ Infrastructure created -**Phase 2**: ⏳ Test utilities - Pending -**Phase 3+**: ⏳ View-specific tests - Pending diff --git a/tests/visual/TEST_REPORT.md b/tests/visual/TEST_REPORT.md deleted file mode 100644 index c55ca0a..0000000 --- a/tests/visual/TEST_REPORT.md +++ /dev/null @@ -1,266 +0,0 @@ -# Visual Regression Test Report - -**Date**: 2026-02-16 -**Spec**: 016-ui-layout-fix Phase 5 -**Tasks**: T121-T125 - -## Summary - -Visual regression testing infrastructure has been successfully implemented with golden files for all 10 profiles. All tests pass with excellent performance. - -## Test Results - -### Test Execution - -``` -=== Test Suite Results === -PASS: TestServicesListView_VisualRegression_AllProfiles (0.01s) - ✓ enterprise (0.00s) - ✓ saiyan (0.00s) - ✓ jedi (0.00s) - ✓ pirate (0.00s) - ✓ horcrux (0.00s) - ✓ pokemon (0.00s) - ✓ shinobi (0.00s) - ✓ triforce (0.00s) - ✓ bending (0.00s) - ✓ crystal (0.00s) - -PASS: TestServiceDetailView_VisualRegression_Enterprise (0.00s) - -PASS: TestServicesListView_NoRenderingErrors (0.01s) - ✓ All 10 profiles render without errors - -PASS: TestServiceDetailView_NoRenderingErrors (0.00s) - ✓ All 10 profiles render without errors - -PASS: TestGoldenFilesDeterministic (0.00s) - ✓ Rendering is deterministic - -PASS: TestANSIStripping (0.00s) - ✓ ANSI codes stripped correctly - -Total: 6 test groups, 0 failures -Execution Time: 0.183s -``` - -## Golden Files Generated - -### Services List View (10 profiles) - -| Profile | File | Lines | Size | -|---------|------|-------|------| -| Enterprise | `services_list_enterprise.txt` | 40 | 5.5K | -| Saiyan | `services_list_saiyan.txt` | 40 | 5.5K | -| Jedi | `services_list_jedi.txt` | 40 | 5.5K | -| Pirate | `services_list_pirate.txt` | 40 | 5.5K | -| Horcrux | `services_list_horcrux.txt` | 40 | 5.5K | -| Pokemon | `services_list_pokemon.txt` | 40 | 5.5K | -| Shinobi | `services_list_shinobi.txt` | 40 | 5.5K | -| Triforce | `services_list_triforce.txt` | 40 | 5.5K | -| Bending | `services_list_bending.txt` | 40 | 5.5K | -| Crystal | `services_list_crystal.txt` | 40 | 5.5K | - -### Service Detail View (1 baseline) - -| Profile | File | Lines | Size | -|---------|------|-------|------| -| Enterprise | `service_detail_enterprise.txt` | 39 | 5.3K | - -**Total Golden Files**: 11 -**Total Size**: ~60KB - -## Performance Benchmarks - -### Rendering Performance - -``` -Profile | Ops/sec | ns/op | Memory/op | Allocs/op --------------|---------|--------|-----------|---------- -Enterprise | 4,045 | 295µs | 163KB | 2,452 -Saiyan | 3,999 | 295µs | 164KB | 2,452 -Jedi | 4,092 | 298µs | 164KB | 2,452 -Pirate | 4,003 | 327µs | 164KB | 2,452 -Horcrux | 3,915 | 295µs | 163KB | 2,452 -Pokemon | 4,094 | 297µs | 163KB | 2,452 -Shinobi | 4,035 | 294µs | 163KB | 2,452 -Triforce | 4,087 | 332µs | 163KB | 2,452 -Bending | 4,087 | 294µs | 163KB | 2,452 -Crystal | 4,027 | 297µs | 164KB | 2,452 -``` - -**Average Performance**: -- Render time: ~300µs (0.3ms) -- Operations/sec: ~4,000 -- Memory usage: ~163KB per render -- Consistent across all profiles - -## Task Completion - -### T121: Create golden file for services list (Enterprise profile) ✓ - -**Status**: Complete -**File**: `golden/services_list_enterprise.txt` -**Verification**: -- Contains search bar with placeholder -- Shows 4 mock services in table format -- Includes status bar with keybindings -- 40 lines, properly formatted - -### T122: Create golden file for services list (Saiyan profile) ✓ - -**Status**: Complete -**File**: `golden/services_list_saiyan.txt` -**Verification**: -- Identical structure to Enterprise -- ANSI codes stripped for comparison -- Theme differences preserved in raw output - -### T123: Create golden file for service detail ✓ - -**Status**: Complete -**File**: `golden/service_detail_enterprise.txt` -**Verification**: -- Shows breadcrumb: Home › Services › postgres -- Tree structure with Configuration, Status, Dependencies -- Status bar with navigation keybindings -- 39 lines, properly formatted - -### T124: Write visual regression test runner ✓ - -**Status**: Complete -**File**: `visual_regression_test.go` -**Features**: -- Profile loading and context creation -- View rendering with fixed dimensions (120x40) -- ANSI stripping for clean comparison -- Golden file management (compare/update modes) -- Comprehensive test coverage -- Performance benchmarks - -**Functions**: -- `loadProfileContext()` - Loads profile with theme -- `stripANSI()` - Removes ANSI escape codes -- `goldenFilePath()` - Resolves golden file paths -- `compareOrUpdateGolden()` - Compares or updates golden files -- `renderServicesListView()` - Renders services list view -- `renderServiceDetailView()` - Renders service detail view - -**Test Cases**: -1. `TestServicesListView_VisualRegression_AllProfiles` - Tests all 10 profiles -2. `TestServiceDetailView_VisualRegression_Enterprise` - Tests detail view -3. `TestServicesListView_NoRenderingErrors` - Error checking (T125) -4. `TestServiceDetailView_NoRenderingErrors` - Error checking -5. `TestGoldenFilesDeterministic` - Determinism verification -6. `TestANSIStripping` - ANSI stripping verification - -### T125: Test all 10 profiles render correctly ✓ - -**Status**: Complete -**Test**: `TestServicesListView_NoRenderingErrors` -**Verification**: -- All 10 profiles load successfully -- No panics during rendering -- Non-empty output for all profiles -- Consistent mock data across profiles - -**Profiles Tested**: -1. Enterprise ✓ -2. Saiyan ✓ -3. Jedi ✓ -4. Pirate ✓ -5. Horcrux ✓ -6. Pokemon ✓ -7. Shinobi ✓ -8. Triforce ✓ -9. Bending ✓ -10. Crystal ✓ - -## Test Infrastructure - -### Directory Structure - -``` -tests/visual/ -├── golden/ # Golden files directory -│ ├── service_detail_enterprise.txt # Detail view baseline -│ └── services_list_*.txt # List view for all profiles (10 files) -├── visual_regression_test.go # Test runner (441 lines) -├── VISUAL_TESTING.md # Testing guide -└── TEST_REPORT.md # This report -``` - -### Key Features - -1. **Deterministic Rendering** - - Fixed dimensions (120x40) - - Consistent mock data - - Reproducible output - -2. **ANSI Stripping** - - Human-readable golden files - - Easy version control diffs - - Platform-independent comparison - -3. **Update Mode** - - `-update-golden` flag - - Regenerates baseline files - - Intentional change workflow - -4. **Comprehensive Coverage** - - All 10 profiles tested - - Multiple views tested - - Error checking included - -## Usage Examples - -### Run Tests (Comparison Mode) - -```bash -go test -v ./tests/visual -``` - -### Update Golden Files - -```bash -go test -v ./tests/visual -update-golden -``` - -### Run Benchmarks - -```bash -go test -bench=. -benchmem ./tests/visual -``` - -### Test Specific Profile - -```bash -go test -v ./tests/visual -run "AllProfiles/saiyan" -``` - -## Quality Metrics - -- **Test Coverage**: 100% of target views (ServicesListView, ServiceDetailView) -- **Profile Coverage**: 100% (all 10 profiles) -- **Performance**: Excellent (~300µs per render) -- **Determinism**: Verified (identical output on multiple runs) -- **Reliability**: All tests passing - -## Recommendations - -1. **CI Integration**: Add visual regression tests to CI pipeline -2. **Pre-commit Hook**: Run tests before commits to catch regressions early -3. **Golden File Review**: Always review diffs when updating golden files -4. **Performance Monitoring**: Track benchmark results over time -5. **Coverage Expansion**: Add more views as they are developed - -## Conclusion - -Visual regression testing infrastructure is complete and operational. All tasks (T121-T125) have been successfully completed with: -- 11 golden files covering all 10 profiles -- Comprehensive test runner with 6 test groups -- Excellent performance (~300µs per render) -- 100% test pass rate -- Full documentation - -The system is ready for production use and provides a solid foundation for catching visual regressions across all A.R.C. CLI profiles. diff --git a/tests/visual/VISUAL_TESTING.md b/tests/visual/VISUAL_TESTING.md deleted file mode 100644 index 18ac156..0000000 --- a/tests/visual/VISUAL_TESTING.md +++ /dev/null @@ -1,207 +0,0 @@ -# Visual Regression Testing - -This directory contains visual regression tests for A.R.C. CLI UI components. These tests ensure that UI rendering remains consistent across all 10 profiles and that visual changes are intentional and reviewed. - -## Overview - -Visual regression testing captures "golden files" (baseline snapshots) of rendered UI components and compares them against future renders to detect unintended visual changes. - -## Test Coverage - -### Views Tested - -1. **ServicesListView** - Tested across all 10 profiles - - Enterprise, Saiyan, Jedi, Pirate, Horcrux, Pokemon, Shinobi, Triforce, Bending, Crystal - - Verifies: search bar, data table, status bar, keybindings - -2. **ServiceDetailView** - Tested with Enterprise profile (baseline) - - Verifies: breadcrumb, tree structure, service details, keybindings - -### Test Structure - -- `visual_regression_test.go` - Main test file with all visual regression tests -- `golden/` - Directory containing baseline golden files (11 files total) - - `services_list_*.txt` - One for each of the 10 profiles - - `service_detail_enterprise.txt` - Baseline service detail view - -## Running Tests - -### Comparison Mode (Default) - -Run tests to compare rendered output against golden files: - -```bash -go test -v ./tests/visual -``` - -This will fail if any visual changes are detected. - -### Update Mode - -Regenerate golden files when visual changes are intentional: - -```bash -go test -v ./tests/visual -update-golden -``` - -**Important**: Only use update mode when you've verified that visual changes are correct and intentional. - -### Run Specific Tests - -Test services list view across all profiles: -```bash -go test -v ./tests/visual -run TestServicesListView_VisualRegression_AllProfiles -``` - -Test service detail view: -```bash -go test -v ./tests/visual -run TestServiceDetailView_VisualRegression_Enterprise -``` - -Test for rendering errors (T125): -```bash -go test -v ./tests/visual -run TestServicesListView_NoRenderingErrors -``` - -## Test Configuration - -### Fixed Dimensions - -Golden files use fixed dimensions for deterministic output: -- Width: 120 columns -- Height: 40 rows - -This ensures consistent rendering regardless of terminal size. - -### Mock Data - -Tests use consistent mock service data: -- postgres (PostgreSQL) - Data - Relational database -- redis (Redis) - Data - In-memory cache -- mongodb (MongoDB) - Data - Document database -- mysql (MySQL) - Data - Relational database - -### ANSI Stripping - -Golden files have ANSI color codes stripped for: -- Human readability -- Easier diffing in version control -- Consistent comparison across terminals - -## Golden File Format - -Each golden file is a plain text snapshot of the rendered view: - -``` -╭──────────────────────────────────────────╮ -│ 🔍 Search services... │ -╰──────────────────────────────────────────╯ - - Service (Technology) Role Description -──────────────────────────────────────────── - postgres (PostgreSQL) Data Relational database - redis (Redis) Data In-memory cache - ... -``` - -## Adding New Visual Tests - -To add a new view to visual regression testing: - -1. Create a render helper function: -```go -func renderMyNewView(profileCtx *profiles.ProfileContext) string { - factory := ui.NewComponentFactory(profileCtx, components.BorderTierBlock) - view := views.NewMyView(factory) - - viewCtx := &engine.ViewContext{ - Profile: profileCtx.Profile(), - Theme: profileCtx.Theme(), - Width: goldenWidth, - Height: goldenHeight, - Args: map[string]any{ - // ... view-specific args - }, - } - - view.OnEnter(viewCtx) - view.Update(tea.WindowSizeMsg{Width: goldenWidth, Height: goldenHeight}) - - return view.View() -} -``` - -2. Create a test function: -```go -func TestMyNewView_VisualRegression(t *testing.T) { - for _, profileID := range allProfiles { - t.Run(profileID, func(t *testing.T) { - profileCtx := loadProfileContext(profileID) - rendered := renderMyNewView(profileCtx) - compareOrUpdateGolden(t, "my_new_view", profileID, rendered) - }) - } -} -``` - -3. Generate golden files: -```bash -go test -v ./tests/visual -update-golden -run TestMyNewView_VisualRegression -``` - -4. Verify and commit: -```bash -git add tests/visual/golden/my_new_view_*.txt -git commit -m "Add visual regression tests for MyNewView" -``` - -## Best Practices - -1. **Review Visual Changes** - Always review diffs in golden files before committing -2. **Update Deliberately** - Only use `-update-golden` when changes are intentional -3. **Test All Profiles** - Ensure changes work across all 10 profiles -4. **Keep Deterministic** - Use fixed dimensions and consistent mock data -5. **Document Changes** - Explain why golden files were updated in commit messages - -## Troubleshooting - -### Test Fails with "Output does not match golden file" - -This indicates a visual regression. To debug: - -1. Check the test output for differences -2. Review recent code changes that might affect rendering -3. If the change is intentional, regenerate golden files with `-update-golden` -4. If unintentional, fix the rendering issue - -### "Failed to read golden file" Error - -Golden file doesn't exist. Generate it: -```bash -go test -v ./tests/visual -update-golden -run -``` - -### All Profiles Show Identical Output - -This is expected after ANSI stripping. The actual color differences are in the ANSI codes that are stripped for comparison. To verify color differences, check the raw rendered output before stripping. - -## Performance - -Visual regression tests are fast: -- Average: ~10ms per profile test -- Total suite: <200ms for all tests -- Benchmark tests available: `BenchmarkServicesListView_Rendering` - -## Task Mapping - -This visual regression system implements: -- **T121**: Golden file for services list (Enterprise) ✓ -- **T122**: Golden file for services list (Saiyan) ✓ -- **T123**: Golden file for service detail ✓ -- **T124**: Visual regression test runner ✓ -- **T125**: Test all 10 profiles render correctly ✓ - -## Related Documentation - -- [Phase 5 Visual Validation](../../specs/016-ui-layout-fix/PHASE_5_VISUAL_VALIDATION.md) -- [Architecture Overview](../../specs/016-ui-layout-fix/ARCHITECTURE.md) diff --git a/tests/visual/golden/home_bending.txt b/tests/visual/golden/home_bending.txt deleted file mode 100644 index 72e7824..0000000 --- a/tests/visual/golden/home_bending.txt +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - 🌊 🔥 🌍 💨 - - ▄▀█ █▀█ █▀▀ - █▀█ █▀▄ █▄▄ - - 🌊 🔥 🌍 💨 - Four Elements -q: quit \ No newline at end of file diff --git a/tests/visual/golden/home_crystal.txt b/tests/visual/golden/home_crystal.txt deleted file mode 100644 index dd69d49..0000000 --- a/tests/visual/golden/home_crystal.txt +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - ✦━━━━━━━━━━━━━━━✦ - - ▄▀█ █▀█ █▀▀ - █▀█ █▀▄ █▄▄ - - ✦━━━━━━━━━━━━━━━✦ - Crystal Core -q: quit \ No newline at end of file diff --git a/tests/visual/golden/home_enterprise.txt b/tests/visual/golden/home_enterprise.txt deleted file mode 100644 index 31a975a..0000000 --- a/tests/visual/golden/home_enterprise.txt +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - █████╗ ██████╗ ██████╗ - ██╔══██╗██╔══██╗██╔════╝ - ███████║██████╔╝██║ - ██╔══██║██╔══██╗██║ - ██║ ██║██║ ██║╚██████╗ - ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ - - Agentic Reasoning Core -q: quit \ No newline at end of file diff --git a/tests/visual/golden/home_horcrux.txt b/tests/visual/golden/home_horcrux.txt deleted file mode 100644 index af2804e..0000000 --- a/tests/visual/golden/home_horcrux.txt +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - ═══════════════════ - ║ ║ - ║ ▄▀█ █▀█ █▀▀ ║ - ║ █▀█ █▀▄ █▄▄ ║ - ║ ║ - ║ ⚡ Hogwarts ⚡ ║ - ═══════════════════ -q: quit \ No newline at end of file diff --git a/tests/visual/golden/home_jedi.txt b/tests/visual/golden/home_jedi.txt deleted file mode 100644 index cf76343..0000000 --- a/tests/visual/golden/home_jedi.txt +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - ╔════════════════════════╗ - ║ ║ - ║ ▄▀█ █▀█ █▀▀ ║ - ║ █▀█ █▀▄ █▄▄ ║ - ║ ║ - ║ ═══⚔ Force ⚔═══ ║ - ╚════════════════════════╝ -q: quit \ No newline at end of file diff --git a/tests/visual/golden/home_pirate.txt b/tests/visual/golden/home_pirate.txt deleted file mode 100644 index eebd168..0000000 --- a/tests/visual/golden/home_pirate.txt +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - ⚓━━━━━━━━━━━━━━━━⚓ - - ▄▀█ █▀█ █▀▀ - █▀█ █▀▄ █▄▄ - - ⚓━━━━━━━━━━━━━━━━⚓ - Grand Line -q: quit \ No newline at end of file diff --git a/tests/visual/golden/home_pokemon.txt b/tests/visual/golden/home_pokemon.txt deleted file mode 100644 index cc2bea2..0000000 --- a/tests/visual/golden/home_pokemon.txt +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - ╔══════════════════╗ - ║ ║ - ║ ⚪ A.R.C. ⚪ ║ - ║ ║ - ║ ◉─◉─◉ ║ - ║ Evolution ║ - ╚══════════════════╝ -q: quit \ No newline at end of file diff --git a/tests/visual/golden/home_saiyan.txt b/tests/visual/golden/home_saiyan.txt deleted file mode 100644 index 1959e7d..0000000 --- a/tests/visual/golden/home_saiyan.txt +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - ⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡ - - _____ ____ ____ - / _ \| _ \ / ___| - / /_\ \ \ |_) | | - / ___ \ \ _ <| |___ - /_/ \_\_\|_| \_\_____| - - POWER ▰▰▰▰▰▰▰▰▱▱ - ⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡ -q: quit \ No newline at end of file diff --git a/tests/visual/golden/home_shinobi.txt b/tests/visual/golden/home_shinobi.txt deleted file mode 100644 index 32b8fe4..0000000 --- a/tests/visual/golden/home_shinobi.txt +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ - ▓ ___ ____ ____ ▓ - ▓ /__/\ / ___\/ ___) ▓ 🥷 - ▓ \ __ \\___ \ \___ ▓ - ▓ /_/\_/\___/ \___/ ▓ - ▓ ▓ - ▓ [Hidden Leaf] ▓ - ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ -q: quit \ No newline at end of file diff --git a/tests/visual/golden/home_triforce.txt b/tests/visual/golden/home_triforce.txt deleted file mode 100644 index 5ebf845..0000000 --- a/tests/visual/golden/home_triforce.txt +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - ▲ - ▲ ▲ - - ▄▀█ █▀█ █▀▀ - █▀█ █▀▄ █▄▄ - - ▲ ▲ ▲ - ▲ ▲ ▲ ▲ - ═══════════ - Hyrule Core -q: quit \ No newline at end of file diff --git a/tests/visual/golden/info_bending.txt b/tests/visual/golden/info_bending.txt deleted file mode 100644 index 0893434..0000000 --- a/tests/visual/golden/info_bending.txt +++ /dev/null @@ -1,35 +0,0 @@ - 🌊 🔥 🌍 💨 - - ▄▀█ █▀█ █▀▀ - █▀█ █▀▄ █▄▄ - - 🌊 🔥 🌍 💨 - Four Elements - -System Information -├── CLI -│ ├── Version: 1.0.0 -│ ├── Build Date: 2024-02-17 -│ └── Commit: abc1234 -├── Go Runtime -│ ├── Version: go1.24.0 -│ └── OS/Arch: darwin/arm64 -├── Hardware -│ ├── CPU: Apple M4 -│ ├── Cores: 10 -│ └── Memory: 32.0 GB total, 16.0 GB free -├── System -│ ├── Hostname: test-hostname -│ ├── User: testuser -│ ├── Home: /Users/testuser -│ └── Working Dir: /Users/testuser/workspace -├── Configuration -│ ├── Config Dir: /Users/testuser/.arc -│ └── State DB: /Users/testuser/.arc/state.db -└── Git Repository - ├── Branch: 017-ui-engine - ├── Commit: abc1234 - ├── Status: clean - └── Remote: git@github.com:arc-framework/arc-cli.git - -↑/↓: navigate • q: quit \ No newline at end of file diff --git a/tests/visual/golden/info_crystal.txt b/tests/visual/golden/info_crystal.txt deleted file mode 100644 index 2b32467..0000000 --- a/tests/visual/golden/info_crystal.txt +++ /dev/null @@ -1,35 +0,0 @@ - ✦━━━━━━━━━━━━━━━✦ - - ▄▀█ █▀█ █▀▀ - █▀█ █▀▄ █▄▄ - - ✦━━━━━━━━━━━━━━━✦ - Crystal Core - -System Information -├── CLI -│ ├── Version: 1.0.0 -│ ├── Build Date: 2024-02-17 -│ └── Commit: abc1234 -├── Go Runtime -│ ├── Version: go1.24.0 -│ └── OS/Arch: darwin/arm64 -├── Hardware -│ ├── CPU: Apple M4 -│ ├── Cores: 10 -│ └── Memory: 32.0 GB total, 16.0 GB free -├── System -│ ├── Hostname: test-hostname -│ ├── User: testuser -│ ├── Home: /Users/testuser -│ └── Working Dir: /Users/testuser/workspace -├── Configuration -│ ├── Config Dir: /Users/testuser/.arc -│ └── State DB: /Users/testuser/.arc/state.db -└── Git Repository - ├── Branch: 017-ui-engine - ├── Commit: abc1234 - ├── Status: clean - └── Remote: git@github.com:arc-framework/arc-cli.git - -↑/↓: navigate • q: quit \ No newline at end of file diff --git a/tests/visual/golden/info_enterprise.txt b/tests/visual/golden/info_enterprise.txt deleted file mode 100644 index 4bf0328..0000000 --- a/tests/visual/golden/info_enterprise.txt +++ /dev/null @@ -1,36 +0,0 @@ - █████╗ ██████╗ ██████╗ - ██╔══██╗██╔══██╗██╔════╝ - ███████║██████╔╝██║ - ██╔══██║██╔══██╗██║ - ██║ ██║██║ ██║╚██████╗ - ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ - - Agentic Reasoning Core - -System Information -├── CLI -│ ├── Version: 1.0.0 -│ ├── Build Date: 2024-02-17 -│ └── Commit: abc1234 -├── Go Runtime -│ ├── Version: go1.24.0 -│ └── OS/Arch: darwin/arm64 -├── Hardware -│ ├── CPU: Apple M4 -│ ├── Cores: 10 -│ └── Memory: 32.0 GB total, 16.0 GB free -├── System -│ ├── Hostname: test-hostname -│ ├── User: testuser -│ ├── Home: /Users/testuser -│ └── Working Dir: /Users/testuser/workspace -├── Configuration -│ ├── Config Dir: /Users/testuser/.arc -│ └── State DB: /Users/testuser/.arc/state.db -└── Git Repository - ├── Branch: 017-ui-engine - ├── Commit: abc1234 - ├── Status: clean - └── Remote: git@github.com:arc-framework/arc-cli.git - -↑/↓: navigate • q: quit \ No newline at end of file diff --git a/tests/visual/golden/info_horcrux.txt b/tests/visual/golden/info_horcrux.txt deleted file mode 100644 index 4a98669..0000000 --- a/tests/visual/golden/info_horcrux.txt +++ /dev/null @@ -1,35 +0,0 @@ - ═══════════════════ - ║ ║ - ║ ▄▀█ █▀█ █▀▀ ║ - ║ █▀█ █▀▄ █▄▄ ║ - ║ ║ - ║ ⚡ Hogwarts ⚡ ║ - ═══════════════════ - -System Information -├── CLI -│ ├── Version: 1.0.0 -│ ├── Build Date: 2024-02-17 -│ └── Commit: abc1234 -├── Go Runtime -│ ├── Version: go1.24.0 -│ └── OS/Arch: darwin/arm64 -├── Hardware -│ ├── CPU: Apple M4 -│ ├── Cores: 10 -│ └── Memory: 32.0 GB total, 16.0 GB free -├── System -│ ├── Hostname: test-hostname -│ ├── User: testuser -│ ├── Home: /Users/testuser -│ └── Working Dir: /Users/testuser/workspace -├── Configuration -│ ├── Config Dir: /Users/testuser/.arc -│ └── State DB: /Users/testuser/.arc/state.db -└── Git Repository - ├── Branch: 017-ui-engine - ├── Commit: abc1234 - ├── Status: clean - └── Remote: git@github.com:arc-framework/arc-cli.git - -↑/↓: navigate • q: quit \ No newline at end of file diff --git a/tests/visual/golden/info_jedi.txt b/tests/visual/golden/info_jedi.txt deleted file mode 100644 index 9aed532..0000000 --- a/tests/visual/golden/info_jedi.txt +++ /dev/null @@ -1,35 +0,0 @@ - ╔════════════════════════╗ - ║ ║ - ║ ▄▀█ █▀█ █▀▀ ║ - ║ █▀█ █▀▄ █▄▄ ║ - ║ ║ - ║ ═══⚔ Force ⚔═══ ║ - ╚════════════════════════╝ - -System Information -├── CLI -│ ├── Version: 1.0.0 -│ ├── Build Date: 2024-02-17 -│ └── Commit: abc1234 -├── Go Runtime -│ ├── Version: go1.24.0 -│ └── OS/Arch: darwin/arm64 -├── Hardware -│ ├── CPU: Apple M4 -│ ├── Cores: 10 -│ └── Memory: 32.0 GB total, 16.0 GB free -├── System -│ ├── Hostname: test-hostname -│ ├── User: testuser -│ ├── Home: /Users/testuser -│ └── Working Dir: /Users/testuser/workspace -├── Configuration -│ ├── Config Dir: /Users/testuser/.arc -│ └── State DB: /Users/testuser/.arc/state.db -└── Git Repository - ├── Branch: 017-ui-engine - ├── Commit: abc1234 - ├── Status: clean - └── Remote: git@github.com:arc-framework/arc-cli.git - -↑/↓: navigate • q: quit \ No newline at end of file diff --git a/tests/visual/golden/info_pirate.txt b/tests/visual/golden/info_pirate.txt deleted file mode 100644 index 9ba47ff..0000000 --- a/tests/visual/golden/info_pirate.txt +++ /dev/null @@ -1,35 +0,0 @@ - ⚓━━━━━━━━━━━━━━━━⚓ - - ▄▀█ █▀█ █▀▀ - █▀█ █▀▄ █▄▄ - - ⚓━━━━━━━━━━━━━━━━⚓ - Grand Line - -System Information -├── CLI -│ ├── Version: 1.0.0 -│ ├── Build Date: 2024-02-17 -│ └── Commit: abc1234 -├── Go Runtime -│ ├── Version: go1.24.0 -│ └── OS/Arch: darwin/arm64 -├── Hardware -│ ├── CPU: Apple M4 -│ ├── Cores: 10 -│ └── Memory: 32.0 GB total, 16.0 GB free -├── System -│ ├── Hostname: test-hostname -│ ├── User: testuser -│ ├── Home: /Users/testuser -│ └── Working Dir: /Users/testuser/workspace -├── Configuration -│ ├── Config Dir: /Users/testuser/.arc -│ └── State DB: /Users/testuser/.arc/state.db -└── Git Repository - ├── Branch: 017-ui-engine - ├── Commit: abc1234 - ├── Status: clean - └── Remote: git@github.com:arc-framework/arc-cli.git - -↑/↓: navigate • q: quit \ No newline at end of file diff --git a/tests/visual/golden/info_pokemon.txt b/tests/visual/golden/info_pokemon.txt deleted file mode 100644 index 2d9412f..0000000 --- a/tests/visual/golden/info_pokemon.txt +++ /dev/null @@ -1,35 +0,0 @@ - ╔══════════════════╗ - ║ ║ - ║ ⚪ A.R.C. ⚪ ║ - ║ ║ - ║ ◉─◉─◉ ║ - ║ Evolution ║ - ╚══════════════════╝ - -System Information -├── CLI -│ ├── Version: 1.0.0 -│ ├── Build Date: 2024-02-17 -│ └── Commit: abc1234 -├── Go Runtime -│ ├── Version: go1.24.0 -│ └── OS/Arch: darwin/arm64 -├── Hardware -│ ├── CPU: Apple M4 -│ ├── Cores: 10 -│ └── Memory: 32.0 GB total, 16.0 GB free -├── System -│ ├── Hostname: test-hostname -│ ├── User: testuser -│ ├── Home: /Users/testuser -│ └── Working Dir: /Users/testuser/workspace -├── Configuration -│ ├── Config Dir: /Users/testuser/.arc -│ └── State DB: /Users/testuser/.arc/state.db -└── Git Repository - ├── Branch: 017-ui-engine - ├── Commit: abc1234 - ├── Status: clean - └── Remote: git@github.com:arc-framework/arc-cli.git - -↑/↓: navigate • q: quit \ No newline at end of file diff --git a/tests/visual/golden/info_saiyan.txt b/tests/visual/golden/info_saiyan.txt deleted file mode 100644 index d3d56b6..0000000 --- a/tests/visual/golden/info_saiyan.txt +++ /dev/null @@ -1,38 +0,0 @@ - ⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡ - - _____ ____ ____ - / _ \| _ \ / ___| - / /_\ \ \ |_) | | - / ___ \ \ _ <| |___ - /_/ \_\_\|_| \_\_____| - - POWER ▰▰▰▰▰▰▰▰▱▱ - ⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡ - -System Information -├── CLI -│ ├── Version: 1.0.0 -│ ├── Build Date: 2024-02-17 -│ └── Commit: abc1234 -├── Go Runtime -│ ├── Version: go1.24.0 -│ └── OS/Arch: darwin/arm64 -├── Hardware -│ ├── CPU: Apple M4 -│ ├── Cores: 10 -│ └── Memory: 32.0 GB total, 16.0 GB free -├── System -│ ├── Hostname: test-hostname -│ ├── User: testuser -│ ├── Home: /Users/testuser -│ └── Working Dir: /Users/testuser/workspace -├── Configuration -│ ├── Config Dir: /Users/testuser/.arc -│ └── State DB: /Users/testuser/.arc/state.db -└── Git Repository - ├── Branch: 017-ui-engine - ├── Commit: abc1234 - ├── Status: clean - └── Remote: git@github.com:arc-framework/arc-cli.git - -↑/↓: navigate • q: quit \ No newline at end of file diff --git a/tests/visual/golden/info_shinobi.txt b/tests/visual/golden/info_shinobi.txt deleted file mode 100644 index 618f7d5..0000000 --- a/tests/visual/golden/info_shinobi.txt +++ /dev/null @@ -1,36 +0,0 @@ - ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ - ▓ ___ ____ ____ ▓ - ▓ /__/\ / ___\/ ___) ▓ 🥷 - ▓ \ __ \\___ \ \___ ▓ - ▓ /_/\_/\___/ \___/ ▓ - ▓ ▓ - ▓ [Hidden Leaf] ▓ - ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ - -System Information -├── CLI -│ ├── Version: 1.0.0 -│ ├── Build Date: 2024-02-17 -│ └── Commit: abc1234 -├── Go Runtime -│ ├── Version: go1.24.0 -│ └── OS/Arch: darwin/arm64 -├── Hardware -│ ├── CPU: Apple M4 -│ ├── Cores: 10 -│ └── Memory: 32.0 GB total, 16.0 GB free -├── System -│ ├── Hostname: test-hostname -│ ├── User: testuser -│ ├── Home: /Users/testuser -│ └── Working Dir: /Users/testuser/workspace -├── Configuration -│ ├── Config Dir: /Users/testuser/.arc -│ └── State DB: /Users/testuser/.arc/state.db -└── Git Repository - ├── Branch: 017-ui-engine - ├── Commit: abc1234 - ├── Status: clean - └── Remote: git@github.com:arc-framework/arc-cli.git - -↑/↓: navigate • q: quit \ No newline at end of file diff --git a/tests/visual/golden/info_triforce.txt b/tests/visual/golden/info_triforce.txt deleted file mode 100644 index 5b165e1..0000000 --- a/tests/visual/golden/info_triforce.txt +++ /dev/null @@ -1,38 +0,0 @@ - ▲ - ▲ ▲ - - ▄▀█ █▀█ █▀▀ - █▀█ █▀▄ █▄▄ - - ▲ ▲ ▲ - ▲ ▲ ▲ ▲ - ═══════════ - Hyrule Core - -System Information -├── CLI -│ ├── Version: 1.0.0 -│ ├── Build Date: 2024-02-17 -│ └── Commit: abc1234 -├── Go Runtime -│ ├── Version: go1.24.0 -│ └── OS/Arch: darwin/arm64 -├── Hardware -│ ├── CPU: Apple M4 -│ ├── Cores: 10 -│ └── Memory: 32.0 GB total, 16.0 GB free -├── System -│ ├── Hostname: test-hostname -│ ├── User: testuser -│ ├── Home: /Users/testuser -│ └── Working Dir: /Users/testuser/workspace -├── Configuration -│ ├── Config Dir: /Users/testuser/.arc -│ └── State DB: /Users/testuser/.arc/state.db -└── Git Repository - ├── Branch: 017-ui-engine - ├── Commit: abc1234 - ├── Status: clean - └── Remote: git@github.com:arc-framework/arc-cli.git - -↑/↓: navigate • q: quit \ No newline at end of file diff --git a/tests/visual/golden/service_detail_enterprise.txt b/tests/visual/golden/service_detail_enterprise.txt deleted file mode 100644 index e9ecc4b..0000000 --- a/tests/visual/golden/service_detail_enterprise.txt +++ /dev/null @@ -1,40 +0,0 @@ -Home › Services › postgres -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -postgres -├── Configuration -│ ├── Port: 5432 -│ ├── Host: localhost -│ └── Database: mydb -├── Status -│ ├── State: running -│ └── Uptime: 2h 30m -└── Dependencies - └── None - - - - - - - - - - - - - - - - - - - - - - - - - - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -b: back • ↑/↓: navigate • q: quit \ No newline at end of file diff --git a/tests/visual/golden/services_list_bending.txt b/tests/visual/golden/services_list_bending.txt deleted file mode 100644 index bd83f24..0000000 --- a/tests/visual/golden/services_list_bending.txt +++ /dev/null @@ -1,40 +0,0 @@ -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ 🔍 Search services... │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - - Service (Technology) Role Description -──────────────────────────────────────────────────────────────────────────────────────────────── - postgres (PostgreSQL) Data Relational database - redis (Redis) Data In-memory cache - mongodb (MongoDB) Data Document database - mysql (MySQL) Data Relational database - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -↑/↓: navigate • /: search • s: sort • enter: view • q: quit \ No newline at end of file diff --git a/tests/visual/golden/services_list_crystal.txt b/tests/visual/golden/services_list_crystal.txt deleted file mode 100644 index bd83f24..0000000 --- a/tests/visual/golden/services_list_crystal.txt +++ /dev/null @@ -1,40 +0,0 @@ -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ 🔍 Search services... │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - - Service (Technology) Role Description -──────────────────────────────────────────────────────────────────────────────────────────────── - postgres (PostgreSQL) Data Relational database - redis (Redis) Data In-memory cache - mongodb (MongoDB) Data Document database - mysql (MySQL) Data Relational database - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -↑/↓: navigate • /: search • s: sort • enter: view • q: quit \ No newline at end of file diff --git a/tests/visual/golden/services_list_enterprise.txt b/tests/visual/golden/services_list_enterprise.txt deleted file mode 100644 index bd83f24..0000000 --- a/tests/visual/golden/services_list_enterprise.txt +++ /dev/null @@ -1,40 +0,0 @@ -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ 🔍 Search services... │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - - Service (Technology) Role Description -──────────────────────────────────────────────────────────────────────────────────────────────── - postgres (PostgreSQL) Data Relational database - redis (Redis) Data In-memory cache - mongodb (MongoDB) Data Document database - mysql (MySQL) Data Relational database - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -↑/↓: navigate • /: search • s: sort • enter: view • q: quit \ No newline at end of file diff --git a/tests/visual/golden/services_list_horcrux.txt b/tests/visual/golden/services_list_horcrux.txt deleted file mode 100644 index bd83f24..0000000 --- a/tests/visual/golden/services_list_horcrux.txt +++ /dev/null @@ -1,40 +0,0 @@ -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ 🔍 Search services... │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - - Service (Technology) Role Description -──────────────────────────────────────────────────────────────────────────────────────────────── - postgres (PostgreSQL) Data Relational database - redis (Redis) Data In-memory cache - mongodb (MongoDB) Data Document database - mysql (MySQL) Data Relational database - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -↑/↓: navigate • /: search • s: sort • enter: view • q: quit \ No newline at end of file diff --git a/tests/visual/golden/services_list_jedi.txt b/tests/visual/golden/services_list_jedi.txt deleted file mode 100644 index bd83f24..0000000 --- a/tests/visual/golden/services_list_jedi.txt +++ /dev/null @@ -1,40 +0,0 @@ -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ 🔍 Search services... │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - - Service (Technology) Role Description -──────────────────────────────────────────────────────────────────────────────────────────────── - postgres (PostgreSQL) Data Relational database - redis (Redis) Data In-memory cache - mongodb (MongoDB) Data Document database - mysql (MySQL) Data Relational database - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -↑/↓: navigate • /: search • s: sort • enter: view • q: quit \ No newline at end of file diff --git a/tests/visual/golden/services_list_pirate.txt b/tests/visual/golden/services_list_pirate.txt deleted file mode 100644 index bd83f24..0000000 --- a/tests/visual/golden/services_list_pirate.txt +++ /dev/null @@ -1,40 +0,0 @@ -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ 🔍 Search services... │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - - Service (Technology) Role Description -──────────────────────────────────────────────────────────────────────────────────────────────── - postgres (PostgreSQL) Data Relational database - redis (Redis) Data In-memory cache - mongodb (MongoDB) Data Document database - mysql (MySQL) Data Relational database - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -↑/↓: navigate • /: search • s: sort • enter: view • q: quit \ No newline at end of file diff --git a/tests/visual/golden/services_list_pokemon.txt b/tests/visual/golden/services_list_pokemon.txt deleted file mode 100644 index bd83f24..0000000 --- a/tests/visual/golden/services_list_pokemon.txt +++ /dev/null @@ -1,40 +0,0 @@ -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ 🔍 Search services... │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - - Service (Technology) Role Description -──────────────────────────────────────────────────────────────────────────────────────────────── - postgres (PostgreSQL) Data Relational database - redis (Redis) Data In-memory cache - mongodb (MongoDB) Data Document database - mysql (MySQL) Data Relational database - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -↑/↓: navigate • /: search • s: sort • enter: view • q: quit \ No newline at end of file diff --git a/tests/visual/golden/services_list_saiyan.txt b/tests/visual/golden/services_list_saiyan.txt deleted file mode 100644 index bd83f24..0000000 --- a/tests/visual/golden/services_list_saiyan.txt +++ /dev/null @@ -1,40 +0,0 @@ -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ 🔍 Search services... │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - - Service (Technology) Role Description -──────────────────────────────────────────────────────────────────────────────────────────────── - postgres (PostgreSQL) Data Relational database - redis (Redis) Data In-memory cache - mongodb (MongoDB) Data Document database - mysql (MySQL) Data Relational database - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -↑/↓: navigate • /: search • s: sort • enter: view • q: quit \ No newline at end of file diff --git a/tests/visual/golden/services_list_shinobi.txt b/tests/visual/golden/services_list_shinobi.txt deleted file mode 100644 index bd83f24..0000000 --- a/tests/visual/golden/services_list_shinobi.txt +++ /dev/null @@ -1,40 +0,0 @@ -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ 🔍 Search services... │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - - Service (Technology) Role Description -──────────────────────────────────────────────────────────────────────────────────────────────── - postgres (PostgreSQL) Data Relational database - redis (Redis) Data In-memory cache - mongodb (MongoDB) Data Document database - mysql (MySQL) Data Relational database - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -↑/↓: navigate • /: search • s: sort • enter: view • q: quit \ No newline at end of file diff --git a/tests/visual/golden/services_list_triforce.txt b/tests/visual/golden/services_list_triforce.txt deleted file mode 100644 index bd83f24..0000000 --- a/tests/visual/golden/services_list_triforce.txt +++ /dev/null @@ -1,40 +0,0 @@ -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ 🔍 Search services... │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - - Service (Technology) Role Description -──────────────────────────────────────────────────────────────────────────────────────────────── - postgres (PostgreSQL) Data Relational database - redis (Redis) Data In-memory cache - mongodb (MongoDB) Data Document database - mysql (MySQL) Data Relational database - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -↑/↓: navigate • /: search • s: sort • enter: view • q: quit \ No newline at end of file diff --git a/tests/visual/visual_regression_test.go b/tests/visual/visual_regression_test.go deleted file mode 100644 index 0597a23..0000000 --- a/tests/visual/visual_regression_test.go +++ /dev/null @@ -1,559 +0,0 @@ -package visual_test - -import ( - "flag" - "os" - "path/filepath" - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/arc-framework/arc-cli/internal/branding" - "github.com/arc-framework/arc-cli/pkg/ui" - "github.com/arc-framework/arc-cli/pkg/ui/components" - "github.com/arc-framework/arc-cli/pkg/ui/components/table" - "github.com/arc-framework/arc-cli/pkg/ui/engine" - "github.com/arc-framework/arc-cli/pkg/ui/profiles" - "github.com/arc-framework/arc-cli/pkg/ui/themes" - "github.com/arc-framework/arc-cli/pkg/ui/views" -) - -// Test configuration -const ( - // Fixed dimensions for deterministic golden files - goldenWidth = 120 - goldenHeight = 40 -) - -var ( - // Flag to update golden files instead of comparing - updateGolden = flag.Bool("update-golden", false, "update golden files instead of comparing") - - // All 10 profiles to test - allProfiles = []string{ - "enterprise", - "saiyan", - "jedi", - "pirate", - "horcrux", - "pokemon", - "shinobi", - "triforce", - "bending", - "crystal", - } - - // Mock service data for consistent testing across all profiles - mockServiceRows = []table.Row{ - {"postgres (PostgreSQL)", "Data", "Relational database"}, - {"redis (Redis)", "Data", "In-memory cache"}, - {"mongodb (MongoDB)", "Data", "Document database"}, - {"mysql (MySQL)", "Data", "Relational database"}, - } -) - -// TestMain ensures flags are parsed before running tests -func TestMain(m *testing.M) { - flag.Parse() - os.Exit(m.Run()) -} - -// loadProfileContext loads a ProfileContext for a specific profile ID. -// This is a test helper that ensures proper profile and theme loading. -func loadProfileContext(profileID string) *profiles.ProfileContext { - repo, err := profiles.NewRepository() - if err != nil { - // Fallback to default on error - return profiles.GetDefaultProfileContext() - } - - profile, err := repo.GetByID(profileID) - if err != nil { - // Fallback to default on error - return profiles.GetDefaultProfileContext() - } - - // Load theme - var theme *themes.Theme - if profile.ThemeID != "" { - themeLoader := themes.NewLoader() - theme, _ = themeLoader.Load(profile.ThemeID) - } - - // Create context - ctx, err := profiles.NewProfileContext(profile, theme) - if err != nil { - return profiles.GetDefaultProfileContext() - } - - return ctx -} - -// stripANSI removes ANSI escape codes for cleaner comparison. -// This makes golden files human-readable and easier to debug. -func stripANSI(s string) string { - // Remove ANSI escape sequences - // Pattern: ESC [ ... m (where ... is one or more digits/semicolons) - var result strings.Builder - inEscape := false - - for i := 0; i < len(s); i++ { - if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '[' { - inEscape = true - i++ // Skip '[' - continue - } - - if inEscape { - if s[i] == 'm' || s[i] == 'K' || s[i] == 'H' || s[i] == 'J' { - inEscape = false - } - continue - } - - result.WriteByte(s[i]) - } - - return result.String() -} - -// goldenFilePath returns the path to a golden file for a given test name and profile. -func goldenFilePath(testName, profileID string) string { - filename := testName + "_" + profileID + ".txt" - return filepath.Join("golden", filename) -} - -// compareOrUpdateGolden compares the rendered output with the golden file, -// or updates the golden file if the -update-golden flag is set. -func compareOrUpdateGolden(t *testing.T, testName, profileID, rendered string) { - t.Helper() - - goldenPath := goldenFilePath(testName, profileID) - - // Strip ANSI for cleaner golden files - cleanRendered := stripANSI(rendered) - - if *updateGolden { - // Update mode: write the new golden file - err := os.WriteFile(goldenPath, []byte(cleanRendered), 0o644) - require.NoError(t, err, "Failed to write golden file") - t.Logf("Updated golden file: %s", goldenPath) - return - } - - // Comparison mode: read and compare - goldenData, err := os.ReadFile(goldenPath) - if err != nil { - t.Fatalf("Failed to read golden file %s: %v\nRun with -update-golden to create it", goldenPath, err) - } - - expected := string(goldenData) - if expected != cleanRendered { - t.Errorf("Output does not match golden file %s\n\nExpected:\n%s\n\nGot:\n%s", - goldenPath, expected, cleanRendered) - } -} - -// renderServicesListView renders a ServicesListView with the given profile and returns the output. -func renderServicesListView(profileCtx *profiles.ProfileContext) string { - factory := ui.NewComponentFactory(profileCtx, components.BorderTierBlock) - view := views.NewServicesListView(factory) - - // Create view context - viewCtx := &engine.ViewContext{ - Profile: profileCtx.Profile(), - Theme: profileCtx.Theme(), - Width: goldenWidth, - Height: goldenHeight, - Args: map[string]any{ - "rows": mockServiceRows, - }, - } - - // Initialize the view - view.OnEnter(viewCtx) - - // Simulate window size message to ensure proper layout - view.Update(tea.WindowSizeMsg{Width: goldenWidth, Height: goldenHeight}) - - // Render the view - return view.View() -} - -// renderServiceDetailView renders a ServiceDetailView with the given profile and returns the output. -func renderServiceDetailView(profileCtx *profiles.ProfileContext, serviceName string) string { - factory := ui.NewComponentFactory(profileCtx, components.BorderTierBlock) - view := views.NewServiceDetailView(factory) - - // Create view context - viewCtx := &engine.ViewContext{ - Profile: profileCtx.Profile(), - Theme: profileCtx.Theme(), - Width: goldenWidth, - Height: goldenHeight, - Args: map[string]any{ - "serviceName": serviceName, - }, - } - - // Initialize the view - view.OnEnter(viewCtx) - - // Simulate window size message to ensure proper layout - view.Update(tea.WindowSizeMsg{Width: goldenWidth, Height: goldenHeight}) - - // Render the view - return view.View() -} - -// TestServicesListView_VisualRegression_AllProfiles tests visual regression for ServicesListView -// across all 10 profiles to ensure consistent rendering. -func TestServicesListView_VisualRegression_AllProfiles(t *testing.T) { - for _, profileID := range allProfiles { - t.Run(profileID, func(t *testing.T) { - // Load profile context - profileCtx := loadProfileContext(profileID) - require.NotNil(t, profileCtx) - require.NotNil(t, profileCtx.Profile()) - require.NotNil(t, profileCtx.Theme()) - - // Render the view - rendered := renderServicesListView(profileCtx) - require.NotEmpty(t, rendered, "Rendered output should not be empty") - - // Verify basic content is present (smoke test) - assert.Contains(t, rendered, "Search", "Should contain search bar") - assert.Contains(t, rendered, "postgres", "Should contain mock service data") - - // Compare with golden file - compareOrUpdateGolden(t, "services_list", profileID, rendered) - }) - } -} - -// TestServiceDetailView_VisualRegression_Enterprise tests visual regression for ServiceDetailView -// with the Enterprise profile (baseline for all profiles). -func TestServiceDetailView_VisualRegression_Enterprise(t *testing.T) { - profileID := "enterprise" - serviceName := "postgres" - - // Load profile context - profileCtx := loadProfileContext(profileID) - require.NotNil(t, profileCtx) - require.NotNil(t, profileCtx.Profile()) - require.NotNil(t, profileCtx.Theme()) - - // Render the view - rendered := renderServiceDetailView(profileCtx, serviceName) - require.NotEmpty(t, rendered, "Rendered output should not be empty") - - // Verify basic content is present (smoke test) - assert.Contains(t, rendered, serviceName, "Should contain service name") - assert.Contains(t, rendered, "Configuration", "Should contain configuration section") - assert.Contains(t, rendered, "Status", "Should contain status section") - - // Compare with golden file - compareOrUpdateGolden(t, "service_detail", profileID, rendered) -} - -// TestServicesListView_NoRenderingErrors tests that all profiles render without panics or errors. -// This is T125 - verify all 10 profiles render correctly. -func TestServicesListView_NoRenderingErrors(t *testing.T) { - for _, profileID := range allProfiles { - t.Run(profileID, func(t *testing.T) { - // Load profile context - profileCtx := loadProfileContext(profileID) - require.NotNil(t, profileCtx) - - // Render the view - should not panic - assert.NotPanics(t, func() { - rendered := renderServicesListView(profileCtx) - assert.NotEmpty(t, rendered, "Profile %s should render non-empty output", profileID) - }) - }) - } -} - -// TestServiceDetailView_NoRenderingErrors tests that all profiles render detail view without errors. -func TestServiceDetailView_NoRenderingErrors(t *testing.T) { - for _, profileID := range allProfiles { - t.Run(profileID, func(t *testing.T) { - // Load profile context - profileCtx := loadProfileContext(profileID) - require.NotNil(t, profileCtx) - - // Render the view - should not panic - assert.NotPanics(t, func() { - rendered := renderServiceDetailView(profileCtx, "postgres") - assert.NotEmpty(t, rendered, "Profile %s should render non-empty output", profileID) - }) - }) - } -} - -// TestGoldenFilesDeterministic verifies that rendering the same view twice produces identical output. -// This ensures golden files are deterministic and not affected by random factors. -func TestGoldenFilesDeterministic(t *testing.T) { - profileID := "enterprise" - profileCtx := loadProfileContext(profileID) - - // Render twice - rendered1 := renderServicesListView(profileCtx) - rendered2 := renderServicesListView(profileCtx) - - // Should be identical - assert.Equal(t, rendered1, rendered2, "Rendering should be deterministic") -} - -// TestANSIStripping verifies that ANSI stripping works correctly. -func TestANSIStripping(t *testing.T) { - testCases := []struct { - name string - input string - expected string - }{ - { - name: "no ANSI codes", - input: "plain text", - expected: "plain text", - }, - { - name: "color codes", - input: "\x1b[31mred text\x1b[0m", - expected: "red text", - }, - { - name: "multiple codes", - input: "\x1b[1m\x1b[31mbold red\x1b[0m normal", - expected: "bold red normal", - }, - { - name: "clear line", - input: "text\x1b[K", - expected: "text", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - result := stripANSI(tc.input) - assert.Equal(t, tc.expected, result) - }) - } -} - -// BenchmarkServicesListView_Rendering benchmarks view rendering performance across profiles. -func BenchmarkServicesListView_Rendering(b *testing.B) { - for _, profileID := range allProfiles { - b.Run(profileID, func(b *testing.B) { - profileCtx := loadProfileContext(profileID) - b.ResetTimer() - - for i := 0; i < b.N; i++ { - _ = renderServicesListView(profileCtx) - } - }) - } -} - -// generateMockSystemInfo creates mock system info data for consistent testing. -// This ensures deterministic golden files across test runs. -func generateMockSystemInfo() *branding.SystemInfo { - return &branding.SystemInfo{ - CLIVersion: "1.0.0", - CLIBuildDate: "2024-02-17", - CLICommit: "abc1234", - GoVersion: "go1.24.0", - GoOS: "darwin", - GoArch: "arm64", - NumCPU: 10, - MemoryTotal: 34359738368, // 32GB - MemoryFree: 17179869184, // 16GB - CPUModel: "Apple M4", - Hostname: "test-hostname", - Username: "testuser", - HomeDir: "/Users/testuser", - WorkingDir: "/Users/testuser/workspace", - ConfigDir: "/Users/testuser/.arc", - StateDBPath: "/Users/testuser/.arc/state.db", - IsGitRepo: true, - GitBranch: "017-ui-engine", - GitCommit: "abc1234", - GitStatus: "clean", - GitRemote: "git@github.com:arc-framework/arc-cli.git", - } -} - -// renderInfoView renders an InfoView with the given profile and returns the output. -func renderInfoView(profileCtx *profiles.ProfileContext) string { - factory := ui.NewComponentFactory(profileCtx, components.BorderTierBlock) - view := views.NewInfoView(factory) - - // Create view context with mock system info - viewCtx := &engine.ViewContext{ - Profile: profileCtx.Profile(), - Theme: profileCtx.Theme(), - Width: goldenWidth, - Height: goldenHeight, - Args: map[string]any{ - "systemInfo": generateMockSystemInfo(), - }, - } - - // Initialize the view - view.OnEnter(viewCtx) - - // Simulate window size message to ensure proper layout - view.Update(tea.WindowSizeMsg{Width: goldenWidth, Height: goldenHeight}) - - // Render the view - return view.View() -} - -// TestInfoView_VisualRegression_AllProfiles tests visual regression for InfoView -// across all 10 profiles to ensure consistent rendering. -func TestInfoView_VisualRegression_AllProfiles(t *testing.T) { - for _, profileID := range allProfiles { - t.Run(profileID, func(t *testing.T) { - // Load profile context - profileCtx := loadProfileContext(profileID) - require.NotNil(t, profileCtx) - require.NotNil(t, profileCtx.Profile()) - require.NotNil(t, profileCtx.Theme()) - - // Render the view - rendered := renderInfoView(profileCtx) - require.NotEmpty(t, rendered, "Rendered output should not be empty") - - // Verify basic content is present (smoke test) - assert.Contains(t, rendered, "System Information", "Should contain system info heading") - assert.Contains(t, rendered, "CLI", "Should contain CLI section") - assert.Contains(t, rendered, "1.0.0", "Should contain mock version data") - - // Compare with golden file - compareOrUpdateGolden(t, "info", profileID, rendered) - }) - } -} - -// TestInfoView_NoRenderingErrors tests that all profiles render InfoView without panics or errors. -func TestInfoView_NoRenderingErrors(t *testing.T) { - for _, profileID := range allProfiles { - t.Run(profileID, func(t *testing.T) { - // Load profile context - profileCtx := loadProfileContext(profileID) - require.NotNil(t, profileCtx) - - // Render the view - should not panic - assert.NotPanics(t, func() { - rendered := renderInfoView(profileCtx) - assert.NotEmpty(t, rendered, "Profile %s should render non-empty output", profileID) - }) - }) - } -} - -// renderHomeView renders a HomeView with the given profile and returns the output. -// T183: Visual regression helper for HomeView across all profiles. -func renderHomeView(profileCtx *profiles.ProfileContext) string { - factory := ui.NewComponentFactory(profileCtx, components.BorderTierBlock) - view := views.NewHomeView(factory) - - // Create view context - viewCtx := &engine.ViewContext{ - Profile: profileCtx.Profile(), - Theme: profileCtx.Theme(), - Width: goldenWidth, - Height: goldenHeight, - } - - // Initialize the view - view.OnEnter(viewCtx) - - // Simulate window size message to ensure proper layout - view.Update(tea.WindowSizeMsg{Width: goldenWidth, Height: goldenHeight}) - - // Render the view - return view.View() -} - -// TestHomeView_VisualRegression_AllProfiles tests visual regression for HomeView -// across all 10 profiles to ensure consistent rendering. (T185) -func TestHomeView_VisualRegression_AllProfiles(t *testing.T) { - for _, profileID := range allProfiles { - t.Run(profileID, func(t *testing.T) { - // Load profile context - profileCtx := loadProfileContext(profileID) - require.NotNil(t, profileCtx) - require.NotNil(t, profileCtx.Profile()) - require.NotNil(t, profileCtx.Theme()) - - // Render the view - rendered := renderHomeView(profileCtx) - require.NotEmpty(t, rendered, "Rendered output should not be empty") - - // Compare with golden file - compareOrUpdateGolden(t, "home", profileID, rendered) - }) - } -} - -// TestHomeView_NoRenderingErrors tests that all profiles render HomeView without panics. -func TestHomeView_NoRenderingErrors(t *testing.T) { - for _, profileID := range allProfiles { - t.Run(profileID, func(t *testing.T) { - profileCtx := loadProfileContext(profileID) - require.NotNil(t, profileCtx) - - assert.NotPanics(t, func() { - rendered := renderHomeView(profileCtx) - assert.NotEmpty(t, rendered, "Profile %s should render non-empty output", profileID) - }) - }) - } -} - -// TestHomeView_NarrowTerminal tests rendering at 80-column width (T187). -func TestHomeView_NarrowTerminal(t *testing.T) { - profileCtx := loadProfileContext("enterprise") - require.NotNil(t, profileCtx) - - factory := ui.NewComponentFactory(profileCtx, components.BorderTierBlock) - view := views.NewHomeView(factory) - - // Narrow terminal: 80 columns - viewCtx := &engine.ViewContext{ - Profile: profileCtx.Profile(), - Theme: profileCtx.Theme(), - Width: 80, - Height: 24, - } - view.OnEnter(viewCtx) - view.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) - - rendered := view.View() - assert.NotEmpty(t, rendered, "Should render at 80 columns") - assert.NotPanics(t, func() { _ = view.View() }, "Should not panic at narrow width") -} - -// TestHomeView_ProfileSwitching tests that Enterprise and Saiyan profiles render differently (T188). -func TestHomeView_ProfileSwitching(t *testing.T) { - enterpriseCtx := loadProfileContext("enterprise") - saiyanCtx := loadProfileContext("saiyan") - - require.NotNil(t, enterpriseCtx) - require.NotNil(t, saiyanCtx) - - renderedEnterprise := renderHomeView(enterpriseCtx) - renderedSaiyan := renderHomeView(saiyanCtx) - - assert.NotEmpty(t, renderedEnterprise, "Enterprise profile should render") - assert.NotEmpty(t, renderedSaiyan, "Saiyan profile should render") - - // The two profiles should produce different output (different logos/colors) - assert.NotEqual(t, stripANSI(renderedEnterprise), stripANSI(renderedSaiyan), - "Enterprise and Saiyan profiles should render differently") -}