diff --git a/docs/PACKAGES_PANE.md b/docs/PACKAGES_PANE.md new file mode 100644 index 0000000..16a2b7c --- /dev/null +++ b/docs/PACKAGES_PANE.md @@ -0,0 +1,133 @@ +# Packages Pane + +devdash displays installed development packages (Go, Python, Node.js, etc.) alongside services. + +## Features + +- **Automatic Discovery**: Scans `.devenv/profile/bin/` to find all installed packages +- **Version Information**: Shows package versions from Nix store paths +- **Adaptive Layout**: Automatically adjusts based on terminal size +- **Toggle View**: Press `p` to switch between services and packages on narrow terminals + +## Layout Modes + +### Wide Terminals (≥140 columns) + +Both Services and Packages panes are visible simultaneously: +- Services pane in top quarter +- Packages pane in second quarter +- Logs pane in bottom half +- Full information always visible + +### Narrow Terminals (<140 columns) + +One pane at a time with toggle: +- Default: Services pane with `[p:packages]` indicator +- Press `p`: Packages pane with `[p:services]` indicator +- Press `p` again: Back to Services pane + +## Package Information + +Each package shows: +- **NAME**: Package binary name (e.g., "go", "python3") +- **VERSION**: Version extracted from Nix store path +- **TYPE**: Categorized type (Go, Python, Node.js, Rust, etc.) + +## Keyboard Shortcuts + +- `p`: Toggle between Services and Packages view (narrow terminals) +- `Tab`: Cycle focus through panes (includes Packages when visible) +- Mouse hover: Move focus to pane under cursor + +## Project Types + +- **Service-based projects**: Have both services and packages +- **Package-only projects**: Development environments without long-running services + - Example: CLI tools, libraries, static sites + - Only use `devenv shell`, never run `devenv up` + - Still show useful package information + +## Technical Details + +Package discovery: +1. Scans `.devenv/profile/bin/` directory +2. Resolves symlinks to Nix store paths +3. Parses package name and version from path format: `/nix/store/--/...` +4. Groups multiple binaries from same package (e.g., go, gofmt, godoc) +5. Categorizes by detected language/tool type + +## Supported Package Types + +The packages pane automatically categorizes packages into the following types: + +- **Go**: go, gopls, gofmt, godoc, goimports +- **Python**: python, python3, pip, pytest, poetry +- **Node.js**: node, nodejs, npm, npx, yarn +- **Rust**: cargo, rustc, rustup +- **C/C++**: gcc, g++, clang, clang++ +- **Ruby**: ruby, gem, bundle +- **Java**: java, javac, maven, gradle +- **Other**: Any unrecognized packages + +## Examples + +### Wide Terminal (150 columns) + +``` +┌─ SERVICES [myproject] ─────────────────┐ +│ NAME STATUS CPU MEM │ +│ postgres running 2% 45MB │ +│ redis running 1% 12MB │ +└────────────────────────────────────────┘ + +┌─ PACKAGES (5) ─────────────────────────┐ +│ NAME VERSION TYPE │ +│ go 1.21.5 Go │ +│ python3 3.11.7 Python │ +│ node 20.10.0 Node.js │ +│ cargo 1.75.0 Rust │ +│ gcc 13.2.0 C/C++ │ +└────────────────────────────────────────┘ + +┌─ LOGS ─────────────────────────────────┐ +│ [postgres] Server started │ +│ [redis] Ready to accept connections │ +└────────────────────────────────────────┘ +``` + +### Narrow Terminal (100 columns) + +Showing services: +``` +┌─ SERVICES [myproject] [p:packages] ───┐ +│ NAME STATUS CPU MEM │ +│ postgres running 2% 45MB │ +└───────────────────────────────────────┘ + +┌─ LOGS ────────────────────────────────┐ +│ [postgres] Server started │ +└───────────────────────────────────────┘ +``` + +After pressing `p`: +``` +┌─ PACKAGES (5) [p:services] ───────────┐ +│ NAME VERSION TYPE │ +│ go 1.21.5 Go │ +│ python3 3.11.7 Python │ +└───────────────────────────────────────┘ + +┌─ LOGS ────────────────────────────────┐ +│ [postgres] Server started │ +└───────────────────────────────────────┘ +``` + +## Why Packages Pane? + +The packages pane serves several purposes: + +1. **Environment Verification**: Quickly verify which tools are available in your devenv +2. **Version Tracking**: See exact versions without running multiple `--version` commands +3. **Debugging**: Identify version conflicts or missing tools +4. **Documentation**: Know what's installed without checking configuration files +5. **Package-Only Projects**: Useful for projects that don't run services but still use devenv for environment management diff --git a/docs/plans/2026-01-21-packages-pane-design.md b/docs/plans/2026-01-21-packages-pane-design.md new file mode 100644 index 0000000..dac8073 --- /dev/null +++ b/docs/plans/2026-01-21-packages-pane-design.md @@ -0,0 +1,316 @@ +# Packages Pane Feature Design + +**Date:** 2026-01-21 +**Status:** Approved +**Branch:** feature/packages-pane + +## Overview + +Add a packages pane to devdash that displays installed development packages (Go, Python, Node.js, etc.) for each project. This complements the existing services pane and helps users understand their complete development environment. + +The feature includes adaptive layout that shows both services and packages when screen space allows, or provides a toggle between them on smaller terminals. + +## Motivation + +Currently, devdash focuses on long-running services managed by process-compose. However, devenv projects also include development packages and language runtimes. Users want visibility into: + +- What packages are installed in each project +- Package versions for debugging compatibility issues +- Complete picture of their development environment + +Some projects are package-only (no services) - they use `devenv shell` for environment activation but never run `devenv up`. These projects currently show "No services running" which doesn't reflect their purpose. + +## Architecture & Components + +### New Components + +1. **Package Scanner** (`internal/packages/`) + - Scans `.devenv/profile/bin/` to discover installed packages + - Parses Nix store paths to extract package names and versions + - Implements caching based on `.devenv/profile` mtime + - Groups binaries by their source package + +2. **PackagesView Component** (`internal/ui/packages.go`) + - Renders package list with columns: NAME, VERSION, TYPE + - Scrollable list matching ServicesView style + - Shows package count in header + - Handles empty state gracefully + +3. **Adaptive Layout Manager** (enhanced `internal/ui/model.go`) + - Determines whether to show both panes or single pane + - Threshold: 140 columns + - Manages 'p' key toggle for switching views + - Updates indicators to show hidden pane + +### Enhanced Detection + +Current project detection already works correctly: +- Socket exists at `.devenv/run/pc.sock` → Service-based project +- No socket → Package-only project + +This detection aligns with devenv behavior: +- `devenv up` creates pc.sock (only for projects with services) +- `devenv shell` activates environment (all projects, doesn't create socket) + +## Package Detection & Data Flow + +### Discovery Strategy + +**Dynamic scanning approach:** + +1. **Enumerate all binaries** in `.devenv/profile/bin/` + - No hardcoded whitelist of package names + - Discover everything that exists + +2. **Parse Nix store paths:** + - Symlink format: `/nix/store/--/bin/` + - Extract: package name, version, store hash + - Example: `/nix/store/abc123-python3-3.11.7/bin/python3` → name: python3, version: 3.11.7 + +3. **Group by package:** + - Multiple binaries from same Nix package (same hash) → single entry + - Example: `go`, `gofmt`, `godoc` all from `abc123-go-1.21.5` → show as "Go 1.21.5" + - Example: `gopls` from different hash → separate entry "gopls 0.14.0" + +4. **Categorize for display:** + - Detect type from package name patterns (go → Go, python → Python, node → Node.js) + - Used for TYPE column, not for filtering + +5. **Enrich with version commands** (optional): + - Attempt ` --version`, ` version`, ` -v` + - Parse output if successful + - Fallback to Nix store path version + +### Data Structures + +```go +type Package struct { + Name string // e.g., "go", "python3", "node" + Version string // e.g., "1.21.5", "3.11.7" + Type string // e.g., "Go", "Python", "Node.js" + Binary string // Full path to binary +} + +type PackageInfo struct { + ProjectPath string + Packages []Package + LastScanned time.Time +} +``` + +### Data Flow + +**When project is selected:** + +1. Model calls `packages.Scan(projectPath)` +2. Scanner checks cache: + - If cached and `.devenv/profile` mtime unchanged → return cached data + - Otherwise, perform fresh scan +3. Scanner reads `.devenv/profile/bin/` directory +4. For each file: + - Resolve symlink to Nix store path + - Parse store path + - Group by package identifier +5. Return grouped `[]Package` +6. Model passes data to PackagesView for rendering + +**When toggling between Services/Packages:** +- 'p' key updates `showPackages` boolean flag +- Model recalculates layout based on current width and flag +- Re-renders appropriate pane + +**Adaptive Layout Decision:** +``` +if terminalWidth >= 140: + show both Services and Packages panes + split vertical space: Services top, Packages bottom +else: + show only one pane based on showPackages flag + display indicator: "SERVICES [p:packages]" or "PACKAGES [p:services]" +``` + +### Caching Strategy + +- Scan packages once when project selected +- Re-scan only when `.devenv/profile` symlink changes (mtime check) +- Avoids repeated filesystem operations +- Cache stored in Model, cleared on project switch + +### Error Handling + +- `.devenv/profile/bin/` doesn't exist → show empty packages list +- Symlink resolution fails → skip that binary, log error +- Version parsing fails → show "unknown" version, still display package +- No packages found → show friendly message: "No packages detected" + +## UI Layout & Interaction + +### PackagesView Component Structure + +Similar to ServicesView: +- Header with title and count: "PACKAGES (12)" +- Column headers: "NAME", "VERSION", "TYPE" +- Scrollable list of packages +- Styles matching current theme +- Focus highlighting + +### Layout Configurations + +**Wide terminal (≥140 columns):** +``` +┌─────────────┬──────────────────────────────────┐ +│ PROJECTS │ SERVICES (3) │ +│ │ NAME STATUS CPU MEM │ +│ ● proj-a │ web Running 2.1% 45MB │ +│ proj-b │ api Running 1.5% 32MB │ +│ │ worker Stopped 0.0% 0MB │ +│ ├──────────────────────────────────┤ +│ │ PACKAGES (8) │ +│ │ NAME VERSION TYPE │ +│ │ go 1.21.5 Go │ +│ │ gopls 0.14.0 Go Tools │ +│ │ python3 3.11.7 Python │ +│ ├──────────────────────────────────┤ +│ │ LOGS │ +│ │ [log entries...] │ +└─────────────┴──────────────────────────────────┘ +``` + +**Narrow terminal (<140 columns, showing Services):** +``` +┌─────────────┬──────────────────────────────────┐ +│ PROJECTS │ SERVICES (3) [p:packages]│ +│ │ NAME STATUS CPU MEM │ +│ ● proj-a │ web Running 2.1% 45MB │ +│ proj-b │ api Running 1.5% 32MB │ +│ │ worker Stopped 0.0% 0MB │ +│ ├──────────────────────────────────┤ +│ │ LOGS │ +│ │ [log entries...] │ +└─────────────┴──────────────────────────────────┘ +``` + +**Narrow terminal (<140 columns, after pressing 'p'):** +``` +┌─────────────┬──────────────────────────────────┐ +│ PROJECTS │ PACKAGES (8) [p:services]│ +│ │ NAME VERSION TYPE │ +│ ● proj-a │ go 1.21.5 Go │ +│ proj-b │ gopls 0.14.0 Go Tools │ +│ │ python3 3.11.7 Python │ +│ │ node 20.10.0 Node.js │ +│ ├──────────────────────────────────┤ +│ │ LOGS │ +│ │ [log entries...] │ +└─────────────┴──────────────────────────────────┘ +``` + +### Key Bindings + +- `p` - Toggle between Services and Packages view (when terminal < 140 columns) +- `Tab` - Cycle focus (Sidebar → Services/Packages → Logs) +- Mouse hover still updates focus appropriately +- All existing keys remain unchanged + +### Visual Feedback + +- Indicator updates immediately when 'p' pressed +- Smooth transition without flicker +- Focus remains on pane after toggle +- Package count in header shows total packages found +- Conservative 140-column threshold (most modern terminals exceed this) + +## Implementation Plan + +### File Structure + +**New files:** +``` +internal/packages/ +├── scanner.go # Package discovery and parsing +├── scanner_test.go # Scanner unit tests +└── types.go # Package data structures + +internal/ui/ +├── packages.go # PackagesView component +└── packages_test.go # PackagesView unit tests +``` + +**Modified files:** +``` +internal/ui/ +├── model.go # Add adaptive layout logic, 'p' toggle +└── model_test.go # Add tests for layout switching +``` + +### Key Functions + +```go +// internal/packages/scanner.go +func Scan(projectPath string) ([]Package, error) +func parseNixStorePath(symlinkTarget string) (name, version string) +func groupByPackage(binaries []binaryInfo) []Package + +// internal/ui/packages.go +func NewPackagesView(styles *Styles) *PackagesView +func (pv *PackagesView) SetPackages(packages []Package) +func (pv *PackagesView) View() string + +// internal/ui/model.go +func (m *Model) shouldShowBothPanes() bool +func (m *Model) togglePackagesView() tea.Cmd +``` + +### Testing Strategy + +**1. Package Scanner Tests:** +- Create mock `.devenv/profile/bin/` with test symlinks +- Verify parsing of various Nix store path formats +- Test caching behavior (mtime checks) +- Test error handling (missing directories, broken symlinks) +- Test grouping logic (multiple binaries from same package) + +**2. PackagesView Tests:** +- Test rendering with various package counts (0, 1, many) +- Verify column alignment and truncation +- Test empty state message display +- Test styling consistency with theme +- Test focus highlighting + +**3. Layout Tests:** +- Test adaptive layout at 139, 140, 141 column widths +- Verify 'p' toggle switches view correctly +- Verify indicator text appears in correct format +- Test that both panes render when wide enough +- Test space allocation between Services and Packages panes + +**4. Integration Tests:** +- Test project switching updates packages +- Verify focus cycling includes packages pane +- Test mouse interaction with packages pane +- Test that package-only projects display packages (no services) +- Test that service-based projects can show both + +All tests should follow existing patterns and achieve similar coverage to current codebase (70+ tests, all passing). + +## Future Enhancements + +Potential future additions (not in initial implementation): + +- Filter packages by type (show only Go packages, etc.) +- Search packages by name +- Show package dependencies +- Link to package documentation +- Show package installation date +- Compare packages across projects +- Export package list to file + +## Success Criteria + +- Package scanning discovers all binaries without hardcoded whitelist +- Adaptive layout smoothly shows/hides panes based on terminal width +- Toggle key ('p') works intuitively on narrow terminals +- Package-only projects display useful information +- All existing functionality remains unchanged +- Comprehensive test coverage (similar to existing 70+ tests) +- No performance degradation on project switching diff --git a/docs/plans/2026-01-21-packages-pane-implementation.md b/docs/plans/2026-01-21-packages-pane-implementation.md new file mode 100644 index 0000000..0645713 --- /dev/null +++ b/docs/plans/2026-01-21-packages-pane-implementation.md @@ -0,0 +1,2099 @@ +# Packages Pane Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add packages pane to display installed development packages (Go, Python, Node.js, etc.) with adaptive layout. + +**Architecture:** Dynamic package discovery by parsing Nix store paths in `.devenv/profile/bin/`. Adaptive UI shows both Services and Packages panes when terminal ≥140 columns, otherwise toggles with 'p' key. + +**Tech Stack:** Go, Bubble Tea TUI framework, Nix store path parsing, file system operations + +--- + +## Task 1: Package Types + +Create data structures for package information. + +**Files:** +- Create: `internal/packages/types.go` +- Test: `internal/packages/types_test.go` + +**Step 1: Write the failing test** + +```go +package packages + +import ( + "testing" +) + +func TestPackageStructure(t *testing.T) { + pkg := Package{ + Name: "go", + Version: "1.21.5", + Type: "Go", + Binary: "/nix/store/abc-go-1.21.5/bin/go", + } + + if pkg.Name != "go" { + t.Errorf("expected Name 'go', got %q", pkg.Name) + } + if pkg.Version != "1.21.5" { + t.Errorf("expected Version '1.21.5', got %q", pkg.Version) + } +} + +func TestPackageInfoStructure(t *testing.T) { + info := PackageInfo{ + ProjectPath: "/home/user/project", + Packages: []Package{{Name: "go"}}, + } + + if len(info.Packages) != 1 { + t.Errorf("expected 1 package, got %d", len(info.Packages)) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `CGO_ENABLED=0 go test ./internal/packages/ -v` +Expected: FAIL with "undefined: Package" or package not found + +**Step 3: Write types** + +Create `internal/packages/types.go`: + +```go +package packages + +import "time" + +// Package represents a single installed package. +type Package struct { + Name string // e.g., "go", "python3", "node" + Version string // e.g., "1.21.5", "3.11.7" + Type string // e.g., "Go", "Python", "Node.js" + Binary string // Full path to binary +} + +// PackageInfo holds package information for a project. +type PackageInfo struct { + ProjectPath string + Packages []Package + LastScanned time.Time +} +``` + +**Step 4: Run test to verify it passes** + +Run: `CGO_ENABLED=0 go test ./internal/packages/ -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/packages/types.go internal/packages/types_test.go +git commit -m "feat(packages): add Package and PackageInfo types + +Add data structures for package discovery: +- Package: name, version, type, binary path +- PackageInfo: project path, packages list, scan timestamp + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Task 2: Nix Store Path Parser + +Parse Nix store paths to extract package name and version. + +**Files:** +- Create: `internal/packages/scanner.go` +- Modify: `internal/packages/types_test.go` (add parser tests) + +**Step 1: Write the failing test** + +Add to `internal/packages/types_test.go`: + +```go +func TestParseNixStorePath(t *testing.T) { + tests := []struct { + name string + path string + expectedPkg string + expectedVersion string + expectedHash string + }{ + { + name: "standard format", + path: "/nix/store/abc123-go-1.21.5/bin/go", + expectedPkg: "go", + expectedVersion: "1.21.5", + expectedHash: "abc123", + }, + { + name: "python with dots", + path: "/nix/store/xyz789-python3-3.11.7/bin/python3", + expectedPkg: "python3", + expectedVersion: "3.11.7", + expectedHash: "xyz789", + }, + { + name: "package with complex name", + path: "/nix/store/def456-gopls-0.14.0-unstable/bin/gopls", + expectedPkg: "gopls", + expectedVersion: "0.14.0-unstable", + expectedHash: "def456", + }, + { + name: "no version", + path: "/nix/store/ghi789-bash/bin/bash", + expectedPkg: "bash", + expectedVersion: "", + expectedHash: "ghi789", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkg, version, hash := parseNixStorePath(tt.path) + if pkg != tt.expectedPkg { + t.Errorf("expected package %q, got %q", tt.expectedPkg, pkg) + } + if version != tt.expectedVersion { + t.Errorf("expected version %q, got %q", tt.expectedVersion, version) + } + if hash != tt.expectedHash { + t.Errorf("expected hash %q, got %q", tt.expectedHash, hash) + } + }) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `CGO_ENABLED=0 go test ./internal/packages/ -v -run TestParseNixStorePath` +Expected: FAIL with "undefined: parseNixStorePath" + +**Step 3: Write minimal parser** + +Create `internal/packages/scanner.go`: + +```go +package packages + +import ( + "path/filepath" + "regexp" + "strings" +) + +// parseNixStorePath extracts package name, version, and hash from a Nix store path. +// Format: /nix/store/--/... +// Returns: (name, version, hash) +func parseNixStorePath(path string) (string, string, string) { + // Get the store directory component + // e.g., /nix/store/abc123-go-1.21.5/bin/go -> abc123-go-1.21.5 + parts := strings.Split(filepath.Clean(path), string(filepath.Separator)) + + var storeComponent string + for i, part := range parts { + if part == "store" && i+1 < len(parts) { + storeComponent = parts[i+1] + break + } + } + + if storeComponent == "" { + return "", "", "" + } + + // Parse: -- + // Regex: ()-()-() where version is optional + re := regexp.MustCompile(`^([a-z0-9]+)-(.+?)(?:-([0-9].*))?$`) + matches := re.FindStringSubmatch(storeComponent) + + if len(matches) < 3 { + // Fallback: just hash-name format + dashIdx := strings.Index(storeComponent, "-") + if dashIdx > 0 { + return storeComponent[dashIdx+1:], "", storeComponent[:dashIdx] + } + return storeComponent, "", "" + } + + hash := matches[1] + name := matches[2] + version := "" + if len(matches) > 3 { + version = matches[3] + } + + return name, version, hash +} +``` + +**Step 4: Run test to verify it passes** + +Run: `CGO_ENABLED=0 go test ./internal/packages/ -v -run TestParseNixStorePath` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/packages/scanner.go internal/packages/types_test.go +git commit -m "feat(packages): add Nix store path parser + +Parse Nix store paths to extract: +- Package name +- Version (if present) +- Store hash for grouping + +Handles various formats including versioned and unversioned packages. + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Task 3: Package Type Categorization + +Categorize packages by type (Go, Python, Node.js, etc.) based on name patterns. + +**Files:** +- Modify: `internal/packages/scanner.go` +- Modify: `internal/packages/types_test.go` + +**Step 1: Write the failing test** + +Add to `internal/packages/types_test.go`: + +```go +func TestCategorizePackage(t *testing.T) { + tests := []struct { + name string + packageName string + expectedType string + }{ + {"go", "go", "Go"}, + {"gofmt", "gofmt", "Go"}, + {"gopls", "gopls", "Go"}, + {"python3", "python3", "Python"}, + {"python", "python", "Python"}, + {"pip", "pip", "Python"}, + {"pytest", "pytest", "Python"}, + {"node", "node", "Node.js"}, + {"npm", "npm", "Node.js"}, + {"nodejs", "nodejs", "Node.js"}, + {"cargo", "cargo", "Rust"}, + {"rustc", "rustc", "Rust"}, + {"gcc", "gcc", "C/C++"}, + {"clang", "clang", "C/C++"}, + {"unknown", "foobar", "Other"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + typ := categorizePackage(tt.packageName) + if typ != tt.expectedType { + t.Errorf("expected type %q for %q, got %q", tt.expectedType, tt.packageName, typ) + } + }) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `CGO_ENABLED=0 go test ./internal/packages/ -v -run TestCategorizePackage` +Expected: FAIL with "undefined: categorizePackage" + +**Step 3: Write categorization function** + +Add to `internal/packages/scanner.go`: + +```go +// categorizePackage determines the package type based on name patterns. +func categorizePackage(name string) string { + lowerName := strings.ToLower(name) + + // Go packages + if strings.HasPrefix(lowerName, "go") || + lowerName == "gopls" || + lowerName == "gofmt" || + lowerName == "godoc" { + return "Go" + } + + // Python packages + if strings.Contains(lowerName, "python") || + lowerName == "pip" || + lowerName == "pytest" || + lowerName == "poetry" { + return "Python" + } + + // Node.js packages + if lowerName == "node" || + lowerName == "nodejs" || + lowerName == "npm" || + lowerName == "npx" || + lowerName == "yarn" { + return "Node.js" + } + + // Rust packages + if lowerName == "cargo" || + lowerName == "rustc" || + lowerName == "rustup" { + return "Rust" + } + + // C/C++ compilers + if lowerName == "gcc" || + lowerName == "g++" || + lowerName == "clang" || + lowerName == "clang++" { + return "C/C++" + } + + // Ruby + if lowerName == "ruby" || + lowerName == "gem" || + lowerName == "bundle" { + return "Ruby" + } + + // Java + if lowerName == "java" || + lowerName == "javac" || + lowerName == "maven" || + lowerName == "gradle" { + return "Java" + } + + return "Other" +} +``` + +**Step 4: Run test to verify it passes** + +Run: `CGO_ENABLED=0 go test ./internal/packages/ -v -run TestCategorizePackage` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/packages/scanner.go internal/packages/types_test.go +git commit -m "feat(packages): add package type categorization + +Categorize packages by name patterns: +- Go (go, gopls, gofmt) +- Python (python, pip, pytest) +- Node.js (node, npm, yarn) +- Rust (cargo, rustc) +- C/C++ (gcc, clang) +- Ruby, Java, and Other + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Task 4: Package Scanner - Binary Discovery + +Scan `.devenv/profile/bin/` to discover all binaries and resolve symlinks. + +**Files:** +- Modify: `internal/packages/scanner.go` +- Modify: `internal/packages/types_test.go` + +**Step 1: Write the failing test** + +Add to `internal/packages/types_test.go`: + +```go +import ( + "os" + "path/filepath" +) + +func TestScan(t *testing.T) { + // Create temporary test directory structure + tmpDir := t.TempDir() + projectDir := filepath.Join(tmpDir, "testproject") + binDir := filepath.Join(projectDir, ".devenv", "profile", "bin") + + err := os.MkdirAll(binDir, 0755) + if err != nil { + t.Fatalf("failed to create test directory: %v", err) + } + + // Create test binary files (regular files for testing, not actual symlinks) + testBinaries := []string{"go", "python3", "node"} + for _, bin := range testBinaries { + binPath := filepath.Join(binDir, bin) + err := os.WriteFile(binPath, []byte("#!/bin/sh\necho test"), 0755) + if err != nil { + t.Fatalf("failed to create test binary %s: %v", bin, err) + } + } + + // Run scan + packages, err := Scan(projectDir) + if err != nil { + t.Fatalf("Scan failed: %v", err) + } + + // Should find at least some packages + if len(packages) == 0 { + t.Error("expected to find packages, got none") + } +} + +func TestScanMissingDirectory(t *testing.T) { + packages, err := Scan("/nonexistent/path") + + // Should not error, just return empty list + if err != nil { + t.Errorf("expected no error for missing directory, got %v", err) + } + if len(packages) != 0 { + t.Errorf("expected 0 packages for missing directory, got %d", len(packages)) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `CGO_ENABLED=0 go test ./internal/packages/ -v -run TestScan` +Expected: FAIL with "undefined: Scan" + +**Step 3: Write Scan function** + +Add to `internal/packages/scanner.go`: + +```go +import ( + "io/fs" + "os" + "path/filepath" +) + +// Scan discovers packages in a devenv project. +// Returns list of packages found, or empty list if directory doesn't exist. +func Scan(projectPath string) ([]Package, error) { + binDir := filepath.Join(projectPath, ".devenv", "profile", "bin") + + // Check if bin directory exists + if _, err := os.Stat(binDir); os.IsNotExist(err) { + return []Package{}, nil + } + + var binaries []binaryInfo + + // Read all files in bin directory + err := filepath.WalkDir(binDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil // Skip errors + } + + // Skip directories and the bin directory itself + if d.IsDir() { + return nil + } + + // Resolve symlink to find Nix store path + target, err := os.Readlink(path) + if err != nil { + // Not a symlink, use the file path itself + target = path + } + + // Parse Nix store path + name, version, hash := parseNixStorePath(target) + if name == "" { + // Couldn't parse, skip + return nil + } + + binaries = append(binaries, binaryInfo{ + name: name, + version: version, + hash: hash, + path: path, + }) + + return nil + }) + + if err != nil { + return []Package{}, err + } + + // Group binaries by package + packages := groupByPackage(binaries) + + return packages, nil +} + +// binaryInfo holds parsed information about a binary. +type binaryInfo struct { + name string + version string + hash string + path string +} +``` + +**Step 4: Run test to verify it passes** + +Run: `CGO_ENABLED=0 go test ./internal/packages/ -v -run TestScan` +Expected: PASS (may need to implement groupByPackage stub first) + +**Step 5: Commit** + +```bash +git add internal/packages/scanner.go internal/packages/types_test.go +git commit -m "feat(packages): add binary discovery scanner + +Scan .devenv/profile/bin/ to discover installed packages: +- Walk bin directory +- Resolve symlinks to Nix store paths +- Parse package information from paths +- Handle missing directories gracefully + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Task 5: Package Grouping + +Group binaries from the same Nix package into single Package entries. + +**Files:** +- Modify: `internal/packages/scanner.go` +- Modify: `internal/packages/types_test.go` + +**Step 1: Write the failing test** + +Add to `internal/packages/types_test.go`: + +```go +func TestGroupByPackage(t *testing.T) { + binaries := []binaryInfo{ + {name: "go", version: "1.21.5", hash: "abc123", path: "/path/to/go"}, + {name: "gofmt", version: "1.21.5", hash: "abc123", path: "/path/to/gofmt"}, + {name: "gopls", version: "0.14.0", hash: "xyz789", path: "/path/to/gopls"}, + {name: "python3", version: "3.11.7", hash: "def456", path: "/path/to/python3"}, + } + + packages := groupByPackage(binaries) + + // Should have 3 packages (go+gofmt grouped, gopls separate, python separate) + if len(packages) != 3 { + t.Errorf("expected 3 packages, got %d", len(packages)) + } + + // Find the Go package + var goPackage *Package + for i := range packages { + if packages[i].Name == "go" { + goPackage = &packages[i] + break + } + } + + if goPackage == nil { + t.Fatal("expected to find 'go' package") + } + + if goPackage.Version != "1.21.5" { + t.Errorf("expected go version 1.21.5, got %q", goPackage.Version) + } + + if goPackage.Type != "Go" { + t.Errorf("expected go type 'Go', got %q", goPackage.Type) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `CGO_ENABLED=0 go test ./internal/packages/ -v -run TestGroupByPackage` +Expected: FAIL or incorrect grouping + +**Step 3: Write grouping function** + +Add to `internal/packages/scanner.go`: + +```go +// groupByPackage groups binaries from the same Nix package. +// Binaries with the same hash+name+version belong to the same package. +func groupByPackage(binaries []binaryInfo) []Package { + // Use map to group by package identifier (hash-name-version) + packageMap := make(map[string]*Package) + + for _, bin := range binaries { + // Create unique package identifier + pkgID := bin.hash + "-" + bin.name + "-" + bin.version + + if pkg, exists := packageMap[pkgID]; exists { + // Package already seen, skip this binary + continue + } + + // New package + packageMap[pkgID] = &Package{ + Name: bin.name, + Version: bin.version, + Type: categorizePackage(bin.name), + Binary: bin.path, + } + } + + // Convert map to slice + packages := make([]Package, 0, len(packageMap)) + for _, pkg := range packageMap { + packages = append(packages, *pkg) + } + + return packages +} +``` + +**Step 4: Run test to verify it passes** + +Run: `CGO_ENABLED=0 go test ./internal/packages/ -v -run TestGroupByPackage` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/packages/scanner.go internal/packages/types_test.go +git commit -m "feat(packages): add package grouping logic + +Group binaries from same Nix package: +- Use hash+name+version as unique identifier +- Merge multiple binaries into single Package entry +- Prevents duplicates (go, gofmt, godoc → one 'go' package) + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Task 6: PackagesView Component Structure + +Create the PackagesView component for rendering package lists. + +**Files:** +- Create: `internal/ui/packages.go` +- Create: `internal/ui/packages_test.go` + +**Step 1: Write the failing test** + +Create `internal/ui/packages_test.go`: + +```go +package ui + +import ( + "testing" + + "github.com/infktd/devdash/internal/packages" +) + +func TestPackagesViewCreate(t *testing.T) { + styles := NewStyles(GetTheme("matrix")) + pv := NewPackagesView(styles) + + if pv == nil { + t.Fatal("NewPackagesView returned nil") + } + + if pv.styles != styles { + t.Error("styles not set correctly") + } +} + +func TestPackagesViewSetPackages(t *testing.T) { + styles := NewStyles(GetTheme("matrix")) + pv := NewPackagesView(styles) + + pkgs := []packages.Package{ + {Name: "go", Version: "1.21.5", Type: "Go"}, + {Name: "python3", Version: "3.11.7", Type: "Python"}, + } + + pv.SetPackages(pkgs) + + if len(pv.packages) != 2 { + t.Errorf("expected 2 packages, got %d", len(pv.packages)) + } +} + +func TestPackagesViewEmptyState(t *testing.T) { + styles := NewStyles(GetTheme("matrix")) + pv := NewPackagesView(styles) + pv.SetSize(80, 20) + + view := pv.View() + + if view == "" { + t.Error("View should not be empty") + } +} + +func TestPackagesViewWithPackages(t *testing.T) { + styles := NewStyles(GetTheme("matrix")) + pv := NewPackagesView(styles) + pv.SetSize(80, 20) + + pkgs := []packages.Package{ + {Name: "go", Version: "1.21.5", Type: "Go"}, + } + pv.SetPackages(pkgs) + + view := pv.View() + + if view == "" { + t.Error("View should not be empty with packages") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `CGO_ENABLED=0 go test ./internal/ui/ -v -run TestPackagesView` +Expected: FAIL with "undefined: NewPackagesView" + +**Step 3: Write PackagesView structure** + +Create `internal/ui/packages.go`: + +```go +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/infktd/devdash/internal/packages" +) + +// PackagesView displays installed packages for a project. +type PackagesView struct { + styles *Styles + packages []packages.Package + width int + height int + focused bool +} + +// NewPackagesView creates a new packages view. +func NewPackagesView(styles *Styles) *PackagesView { + return &PackagesView{ + styles: styles, + packages: []packages.Package{}, + width: 0, + height: 0, + } +} + +// SetPackages updates the package list. +func (pv *PackagesView) SetPackages(pkgs []packages.Package) { + pv.packages = pkgs +} + +// SetSize updates dimensions. +func (pv *PackagesView) SetSize(width, height int) { + pv.width = width + pv.height = height +} + +// SetFocused sets focus state. +func (pv *PackagesView) SetFocused(focused bool) { + pv.focused = focused +} + +// View renders the packages view. +func (pv *PackagesView) View() string { + if pv.width == 0 || pv.height == 0 { + return "" + } + + // Header + title := fmt.Sprintf("PACKAGES (%d)", len(pv.packages)) + headerStyle := pv.styles.PaneTitle + if pv.focused { + headerStyle = pv.styles.PaneTitleFocused + } + header := headerStyle.Render(title) + + // Empty state + if len(pv.packages) == 0 { + emptyMsg := pv.styles.Muted.Render("No packages detected") + content := lipgloss.NewStyle(). + Width(pv.width). + Height(pv.height - 1). + Align(lipgloss.Center, lipgloss.Center). + Render(emptyMsg) + + return lipgloss.JoinVertical(lipgloss.Left, header, content) + } + + // Column headers + colHeaders := pv.renderColumnHeaders() + + // Package rows + rows := pv.renderPackageRows() + + // Combine + var lines []string + lines = append(lines, header) + lines = append(lines, colHeaders) + lines = append(lines, rows...) + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +// renderColumnHeaders renders the column header row. +func (pv *PackagesView) renderColumnHeaders() string { + nameCol := pv.styles.ColumnHeader.Width(20).Render("NAME") + versionCol := pv.styles.ColumnHeader.Width(15).Render("VERSION") + typeCol := pv.styles.ColumnHeader.Width(15).Render("TYPE") + + return lipgloss.JoinHorizontal(lipgloss.Top, nameCol, versionCol, typeCol) +} + +// renderPackageRows renders package data rows. +func (pv *PackagesView) renderPackageRows() []string { + var rows []string + + // Limit to visible height (header + column headers take 2 lines) + maxRows := pv.height - 2 + if maxRows < 0 { + maxRows = 0 + } + + for i := 0; i < len(pv.packages) && i < maxRows; i++ { + pkg := pv.packages[i] + + // Truncate long names + name := pkg.Name + if len(name) > 18 { + name = name[:15] + "..." + } + + version := pkg.Version + if version == "" { + version = "unknown" + } + if len(version) > 13 { + version = version[:10] + "..." + } + + typ := pkg.Type + if len(typ) > 13 { + typ = typ[:10] + "..." + } + + nameCell := lipgloss.NewStyle().Width(20).Render(name) + versionCell := lipgloss.NewStyle().Width(15).Render(version) + typeCell := lipgloss.NewStyle().Width(15).Render(typ) + + row := lipgloss.JoinHorizontal(lipgloss.Top, nameCell, versionCell, typeCell) + rows = append(rows, row) + } + + return rows +} +``` + +**Step 4: Run test to verify it passes** + +Run: `CGO_ENABLED=0 go test ./internal/ui/ -v -run TestPackagesView` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/ui/packages.go internal/ui/packages_test.go +git commit -m "feat(ui): add PackagesView component + +New component to display package list: +- Header with package count +- Column headers: NAME, VERSION, TYPE +- Package rows with truncation +- Empty state message +- Focus highlighting support + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Task 7: Model Integration - Add PackagesView + +Integrate PackagesView into the main Model. + +**Files:** +- Modify: `internal/ui/model.go:40-80` (add packagesView field and initialization) +- Modify: `internal/ui/model_test.go` + +**Step 1: Write the failing test** + +Add to `internal/ui/model_test.go`: + +```go +func TestModelPackagesViewInitialized(t *testing.T) { + cfg := config.Default() + reg := ®istry.Registry{} + m := New(cfg, reg) + + if m.packagesView == nil { + t.Error("packagesView should be initialized") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `CGO_ENABLED=0 go test ./internal/ui/ -v -run TestModelPackagesViewInitialized` +Expected: FAIL with "m.packagesView undefined" + +**Step 3: Add packagesView to Model** + +In `internal/ui/model.go`, find the Model struct definition and add: + +```go +// Add to Model struct (around line 40-50) +packagesView *PackagesView +``` + +Then in the `New` function, add initialization: + +```go +// In New function (around line 80-100), add: +packagesView: NewPackagesView(styles), +``` + +**Step 4: Run test to verify it passes** + +Run: `CGO_ENABLED=0 go test ./internal/ui/ -v -run TestModelPackagesViewInitialized` +Expected: PASS + +**Step 5: Run all tests** + +Run: `CGO_ENABLED=0 go test ./... -v` +Expected: All tests PASS + +**Step 6: Commit** + +```bash +git add internal/ui/model.go internal/ui/model_test.go +git commit -m "feat(ui): integrate PackagesView into Model + +Add PackagesView to main Model: +- Initialize in New() +- Add test for initialization + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Task 8: Adaptive Layout Logic + +Add logic to determine when to show both panes vs single pane based on terminal width. + +**Files:** +- Modify: `internal/ui/model.go` (add shouldShowBothPanes method) +- Modify: `internal/ui/model_test.go` + +**Step 1: Write the failing test** + +Add to `internal/ui/model_test.go`: + +```go +func TestShouldShowBothPanes(t *testing.T) { + cfg := config.Default() + reg := ®istry.Registry{} + m := New(cfg, reg) + + // Narrow terminal - should show single pane + m.width = 139 + if m.shouldShowBothPanes() { + t.Error("expected false for width 139") + } + + // Wide terminal - should show both panes + m.width = 140 + if !m.shouldShowBothPanes() { + t.Error("expected true for width 140") + } + + m.width = 200 + if !m.shouldShowBothPanes() { + t.Error("expected true for width 200") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `CGO_ENABLED=0 go test ./internal/ui/ -v -run TestShouldShowBothPanes` +Expected: FAIL with "m.shouldShowBothPanes undefined" + +**Step 3: Add shouldShowBothPanes method** + +Add to `internal/ui/model.go`: + +```go +const ( + minWidthForBothPanes = 140 +) + +// shouldShowBothPanes determines if there's enough space to show both Services and Packages panes. +func (m *Model) shouldShowBothPanes() bool { + return m.width >= minWidthForBothPanes +} +``` + +**Step 4: Run test to verify it passes** + +Run: `CGO_ENABLED=0 go test ./internal/ui/ -v -run TestShouldShowBothPanes` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/ui/model.go internal/ui/model_test.go +git commit -m "feat(ui): add adaptive layout threshold logic + +Determine when to show both Services and Packages panes: +- Threshold: 140 columns +- Below threshold: show single pane with toggle +- At/above threshold: show both panes + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Task 9: Toggle State Management + +Add state for tracking which pane to show when in single-pane mode. + +**Files:** +- Modify: `internal/ui/model.go` (add showPackages field) +- Modify: `internal/ui/model_test.go` + +**Step 1: Write the failing test** + +Add to `internal/ui/model_test.go`: + +```go +func TestTogglePackagesView(t *testing.T) { + cfg := config.Default() + reg := ®istry.Registry{} + m := New(cfg, reg) + m.width = 100 // Narrow terminal + + // Default should show services (showPackages = false) + if m.showPackages { + t.Error("expected showPackages to be false by default") + } + + // Toggle to packages + m.togglePackagesView() + if !m.showPackages { + t.Error("expected showPackages to be true after toggle") + } + + // Toggle back to services + m.togglePackagesView() + if m.showPackages { + t.Error("expected showPackages to be false after second toggle") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `CGO_ENABLED=0 go test ./internal/ui/ -v -run TestTogglePackagesView` +Expected: FAIL with "m.showPackages undefined" or "m.togglePackagesView undefined" + +**Step 3: Add toggle state** + +In `internal/ui/model.go`, add to Model struct: + +```go +// Add to Model struct +showPackages bool // When true, show packages pane instead of services (narrow terminals) +``` + +Add toggle method: + +```go +// togglePackagesView switches between Services and Packages pane (for narrow terminals). +func (m *Model) togglePackagesView() tea.Cmd { + m.showPackages = !m.showPackages + return nil +} +``` + +**Step 4: Run test to verify it passes** + +Run: `CGO_ENABLED=0 go test ./internal/ui/ -v -run TestTogglePackagesView` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/ui/model.go internal/ui/model_test.go +git commit -m "feat(ui): add packages view toggle state + +Add state management for single-pane mode: +- showPackages flag to track current pane +- togglePackagesView() to switch between panes +- Defaults to showing services pane + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Task 10: Key Binding for 'p' Toggle + +Add 'p' key binding to toggle between Services and Packages view. + +**Files:** +- Modify: `internal/ui/model.go` (Update method to handle 'p' key) +- Modify: `internal/ui/model_test.go` + +**Step 1: Write the failing test** + +Add to `internal/ui/model_test.go`: + +```go +import ( + tea "github.com/charmbracelet/bubbletea" +) + +func TestPressP_TogglesPackagesView(t *testing.T) { + cfg := config.Default() + reg := ®istry.Registry{} + m := New(cfg, reg) + m.width = 100 // Narrow terminal + m.showSplash = false + + initialState := m.showPackages + + // Press 'p' key + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}} + newModel, _ := m.Update(msg) + model := newModel.(*Model) + + if model.showPackages == initialState { + t.Error("pressing 'p' should toggle showPackages") + } +} + +func TestPressP_NoEffectOnWideTerminal(t *testing.T) { + cfg := config.Default() + reg := ®istry.Registry{} + m := New(cfg, reg) + m.width = 150 // Wide terminal - both panes visible + m.showSplash = false + + // Press 'p' key shouldn't have effect + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}} + newModel, _ := m.Update(msg) + model := newModel.(*Model) + + // On wide terminals, 'p' could still toggle but doesn't affect display + // Just verify it doesn't crash + _ = model +} +``` + +**Step 2: Run test to verify it fails** + +Run: `CGO_ENABLED=0 go test ./internal/ui/ -v -run "TestPressP_"` +Expected: FAIL or no toggle behavior + +**Step 3: Add 'p' key handling** + +In `internal/ui/model.go`, find the `Update` method's key handling section and add: + +```go +// In Update method, in the tea.KeyMsg case, add: +case "p": + // Toggle between packages and services view + return m, m.togglePackagesView() +``` + +**Step 4: Run test to verify it passes** + +Run: `CGO_ENABLED=0 go test ./internal/ui/ -v -run "TestPressP_"` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/ui/model.go internal/ui/model_test.go +git commit -m "feat(ui): add 'p' key binding to toggle panes + +Handle 'p' key press to toggle between Services and Packages: +- Calls togglePackagesView() +- Works in both narrow and wide terminals +- Updates display immediately + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Task 11: Package Scanning on Project Selection + +Trigger package scanning when a project is selected and update PackagesView. + +**Files:** +- Modify: `internal/ui/model.go` (update switchToCurrentProject method) +- Modify: `internal/ui/model_test.go` + +**Step 1: Write the failing test** + +Add to `internal/ui/model_test.go`: + +```go +func TestProjectSwitch_ScansPackages(t *testing.T) { + cfg := config.Default() + reg := ®istry.Registry{ + Projects: []*registry.Project{ + {Path: "/tmp/testproject", Name: "test"}, + }, + } + m := New(cfg, reg) + m.selectedProject = 0 + + // Switch to project (this will try to scan packages) + m.switchToCurrentProject() + + // PackagesView should have been updated (even if empty) + // This is a basic test - in real usage, packages would be populated + // if .devenv/profile/bin/ exists + if m.packagesView == nil { + t.Error("packagesView should not be nil after project switch") + } +} +``` + +**Step 2: Run test to verify it fails or passes** + +Run: `CGO_ENABLED=0 go test ./internal/ui/ -v -run TestProjectSwitch_ScansPackages` +Expected: May pass but needs implementation verification + +**Step 3: Add package scanning to switchToCurrentProject** + +In `internal/ui/model.go`, find `switchToCurrentProject` method and add package scanning: + +```go +import ( + "github.com/infktd/devdash/internal/packages" +) + +// In switchToCurrentProject method, add after clearing services: +func (m *Model) switchToCurrentProject() tea.Cmd { + // ... existing code ... + + // Scan packages for new project + project := m.currentProject() + if project != nil { + pkgs, err := packages.Scan(project.Path) + if err != nil { + // Log error but don't block + pkgs = []packages.Package{} + } + m.packagesView.SetPackages(pkgs) + } else { + m.packagesView.SetPackages([]packages.Package{}) + } + + // ... existing code ... +} +``` + +**Step 4: Run test to verify it passes** + +Run: `CGO_ENABLED=0 go test ./internal/ui/ -v -run TestProjectSwitch_ScansPackages` +Expected: PASS + +**Step 5: Run all tests** + +Run: `CGO_ENABLED=0 go test ./... -v` +Expected: All tests PASS + +**Step 6: Commit** + +```bash +git add internal/ui/model.go internal/ui/model_test.go +git commit -m "feat(ui): scan packages on project selection + +Trigger package scanning when project is switched: +- Call packages.Scan() in switchToCurrentProject() +- Update PackagesView with results +- Handle errors gracefully (empty list on error) + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Task 12: Render Logic - Wide Terminal Layout + +Update View() method to render both Services and Packages panes when terminal is wide enough. + +**Files:** +- Modify: `internal/ui/model.go` (update View method) +- Modify: `internal/ui/model_test.go` + +**Step 1: Write the failing test** + +Add to `internal/ui/model_test.go`: + +```go +func TestView_WideTerminal_ShowsBothPanes(t *testing.T) { + cfg := config.Default() + reg := ®istry.Registry{} + m := New(cfg, reg) + m.width = 150 + m.height = 40 + m.showSplash = false + + view := m.View() + + // Both "SERVICES" and "PACKAGES" should appear in view + if !strings.Contains(view, "SERVICES") { + t.Error("wide terminal view should contain SERVICES") + } + if !strings.Contains(view, "PACKAGES") { + t.Error("wide terminal view should contain PACKAGES") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `CGO_ENABLED=0 go test ./internal/ui/ -v -run TestView_WideTerminal_ShowsBothPanes` +Expected: FAIL - packages not shown yet + +**Step 3: Update View method** + +In `internal/ui/model.go`, find the `View` method and update the layout rendering logic: + +```go +// In View method, in the main layout section: +// Find where servicesView is rendered and update to: + + // Determine layout based on width + var mainContent string + if m.shouldShowBothPanes() { + // Wide terminal: show both Services and Packages + // Split vertical space: Services top, Packages bottom + servicesHeight := (m.height - 4) / 2 // Split roughly in half + packagesHeight := (m.height - 4) - servicesHeight + + m.servicesView.SetSize(contentWidth, servicesHeight) + m.packagesView.SetSize(contentWidth, packagesHeight) + + servicesContent := m.servicesView.View() + packagesContent := m.packagesView.View() + + mainContent = lipgloss.JoinVertical( + lipgloss.Left, + servicesContent, + packagesContent, + ) + } else { + // Narrow terminal: show either Services or Packages based on toggle + if m.showPackages { + // Show packages pane + m.packagesView.SetSize(contentWidth, m.height-4) + mainContent = m.packagesView.View() + } else { + // Show services pane + m.servicesView.SetSize(contentWidth, m.height-4) + mainContent = m.servicesView.View() + } + } +``` + +Note: This is pseudocode - the actual implementation should integrate with existing View logic. Look for where `m.servicesView.View()` is called and adapt accordingly. + +**Step 4: Run test to verify it passes** + +Run: `CGO_ENABLED=0 go test ./internal/ui/ -v -run TestView_WideTerminal_ShowsBothPanes` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/ui/model.go internal/ui/model_test.go +git commit -m "feat(ui): render both panes on wide terminals + +Update View() to show Services and Packages together: +- Split vertical space when width >= 140 +- Services pane on top +- Packages pane on bottom +- Both visible simultaneously + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Task 13: Render Logic - Narrow Terminal with Indicator + +Update View() to show indicator when a pane is hidden on narrow terminals. + +**Files:** +- Modify: `internal/ui/model.go` (update View method to add indicator) +- Modify: `internal/ui/model_test.go` + +**Step 1: Write the failing test** + +Add to `internal/ui/model_test.go`: + +```go +func TestView_NarrowTerminal_ShowsIndicator(t *testing.T) { + cfg := config.Default() + reg := ®istry.Registry{} + m := New(cfg, reg) + m.width = 100 + m.height = 40 + m.showSplash = false + + // Showing services - should have packages indicator + m.showPackages = false + view := m.View() + if !strings.Contains(view, "[p:packages]") { + t.Error("narrow terminal showing services should contain '[p:packages]' indicator") + } + + // Showing packages - should have services indicator + m.showPackages = true + view = m.View() + if !strings.Contains(view, "[p:services]") { + t.Error("narrow terminal showing packages should contain '[p:services]' indicator") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `CGO_ENABLED=0 go test ./internal/ui/ -v -run TestView_NarrowTerminal_ShowsIndicator` +Expected: FAIL - indicator not shown + +**Step 3: Add indicator to pane headers** + +In `internal/ui/model.go`, update the narrow terminal rendering to append indicator to title: + +```go +// In the narrow terminal section of View(): + +if m.showPackages { + // Showing packages, add services indicator + title := "PACKAGES" + if !m.shouldShowBothPanes() { + title += " [p:services]" + } + // Update packagesView header rendering with title + // (May need to add method to PackagesView to accept custom title) + m.packagesView.SetSize(contentWidth, m.height-4) + mainContent = m.packagesView.View() + // Append indicator to header +} else { + // Showing services, add packages indicator + title := "SERVICES" + if !m.shouldShowBothPanes() { + title += " [p:packages]" + } + // Similar for servicesView +} +``` + +Note: This may require updating ServicesView and PackagesView to accept custom title overrides, or post-process the View() output to inject the indicator. + +**Simpler approach:** Add indicator text to the rendered header using string replacement or custom rendering. + +**Step 4: Run test to verify it passes** + +Run: `CGO_ENABLED=0 go test ./internal/ui/ -v -run TestView_NarrowTerminal_ShowsIndicator` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/ui/model.go internal/ui/model_test.go +git commit -m "feat(ui): add pane indicators for narrow terminals + +Show which pane is hidden on narrow terminals: +- '[p:packages]' when showing services +- '[p:services]' when showing packages +- Appears in header for visibility + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Task 14: Integration Test - Full Workflow + +Add comprehensive integration test covering project selection, package scanning, and rendering. + +**Files:** +- Modify: `internal/ui/model_test.go` + +**Step 1: Write the integration test** + +Add to `internal/ui/model_test.go`: + +```go +func TestPackagesPaneIntegration(t *testing.T) { + // Create test project directory with packages + tmpDir := t.TempDir() + projectPath := filepath.Join(tmpDir, "testproject") + binDir := filepath.Join(projectPath, ".devenv", "profile", "bin") + + err := os.MkdirAll(binDir, 0755) + if err != nil { + t.Fatalf("failed to create test directory: %v", err) + } + + // Create test binaries + testBins := map[string]string{ + "go": "#!/bin/sh\necho go1.21", + "python3": "#!/bin/sh\necho python3.11", + "node": "#!/bin/sh\necho node20", + } + + for name, content := range testBins { + binPath := filepath.Join(binDir, name) + err := os.WriteFile(binPath, []byte(content), 0755) + if err != nil { + t.Fatalf("failed to create binary %s: %v", name, err) + } + } + + // Create model with test project + cfg := config.Default() + reg := ®istry.Registry{ + Projects: []*registry.Project{ + {Path: projectPath, Name: "testproject"}, + }, + } + m := New(cfg, reg) + m.width = 150 // Wide terminal + m.height = 50 + m.showSplash = false + m.selectedProject = 0 + + // Trigger project switch (should scan packages) + m.switchToCurrentProject() + + // Verify packages were scanned + view := m.View() + if !strings.Contains(view, "PACKAGES") { + t.Error("view should contain PACKAGES pane") + } + + // Switch to narrow terminal + m.width = 100 + msg := tea.WindowSizeMsg{Width: 100, Height: 50} + newModel, _ := m.Update(msg) + m = newModel.(*Model) + + // Should show services by default + view = m.View() + if !strings.Contains(view, "[p:packages]") { + t.Error("narrow terminal should show packages indicator") + } + + // Toggle to packages + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}} + newModel, _ = m.Update(keyMsg) + m = newModel.(*Model) + + view = m.View() + if !strings.Contains(view, "[p:services]") { + t.Error("narrow terminal showing packages should show services indicator") + } +} +``` + +**Step 2: Run test** + +Run: `CGO_ENABLED=0 go test ./internal/ui/ -v -run TestPackagesPaneIntegration` +Expected: PASS + +**Step 3: Run all tests** + +Run: `CGO_ENABLED=0 go test ./... -v` +Expected: All tests PASS (should be 80+ tests now) + +**Step 4: Commit** + +```bash +git add internal/ui/model_test.go +git commit -m "test(ui): add packages pane integration test + +Comprehensive test covering full workflow: +- Project with test packages +- Package scanning on selection +- Wide terminal layout (both panes) +- Narrow terminal toggle behavior +- Indicator display + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Task 15: Focus Cycling with Packages Pane + +Update focus cycling (Tab key) to include packages pane when visible. + +**Files:** +- Modify: `internal/ui/model.go` (update cycleFocus method) +- Modify: `internal/ui/model_test.go` + +**Step 1: Write the failing test** + +Add to `internal/ui/model_test.go`: + +```go +func TestCycleFocus_IncludesPackagesPane(t *testing.T) { + cfg := config.Default() + reg := ®istry.Registry{} + m := New(cfg, reg) + m.width = 150 // Wide terminal - both panes visible + m.showSplash = false + + // Cycle: Sidebar -> Services -> Packages -> Logs -> Sidebar + // Note: May need to add PanePackages constant + + if m.focused != PaneSidebar { + t.Errorf("expected PaneSidebar, got %v", m.focused) + } + + m.cycleFocus() + if m.focused != PaneServices { + t.Errorf("expected PaneServices after first cycle, got %v", m.focused) + } + + // On wide terminals, packages should be in cycle + if m.shouldShowBothPanes() { + m.cycleFocus() + // Should cycle to packages if both panes visible + // This may require adding PanePackages constant + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `CGO_ENABLED=0 go test ./internal/ui/ -v -run TestCycleFocus_IncludesPackagesPane` +Expected: FAIL or incorrect behavior + +**Step 3: Update cycleFocus** + +In `internal/ui/model.go`: + +Add PanePackages constant if not exists: + +```go +const ( + PaneSidebar Pane = iota + PaneServices + PanePackages // Add this + PaneLogs +) +``` + +Update cycleFocus method: + +```go +func (m *Model) cycleFocus() { + switch m.focused { + case PaneSidebar: + m.focused = PaneServices + case PaneServices: + // If both panes visible, go to packages next + // Otherwise skip to logs + if m.shouldShowBothPanes() { + m.focused = PanePackages + } else { + m.focused = PaneLogs + } + case PanePackages: + m.focused = PaneLogs + case PaneLogs: + m.focused = PaneSidebar + default: + m.focused = PaneSidebar + } +} +``` + +**Step 4: Update SetFocused for packages** + +Make sure packagesView focus state is updated when focused: + +```go +// In Update or View method where focus is handled: +m.packagesView.SetFocused(m.focused == PanePackages) +``` + +**Step 5: Run test to verify it passes** + +Run: `CGO_ENABLED=0 go test ./internal/ui/ -v -run TestCycleFocus_IncludesPackagesPane` +Expected: PASS + +**Step 6: Commit** + +```bash +git add internal/ui/model.go internal/ui/model_test.go +git commit -m "feat(ui): include packages pane in focus cycle + +Update Tab key focus cycling: +- Add PanePackages constant +- Include in cycleFocus() when both panes visible +- Skip packages on narrow terminals +- Update packagesView focus state + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Task 16: Mouse Hover Support for Packages Pane + +Update mouse hover logic to focus packages pane when mouse is in that region. + +**Files:** +- Modify: `internal/ui/model.go` (update mouse handling in Update) +- Modify: `internal/ui/model_test.go` + +**Step 1: Write the failing test** + +Add to `internal/ui/model_test.go`: + +```go +func TestMouse_HoverPackagesPane(t *testing.T) { + cfg := config.Default() + cfg.UI.SidebarWidth = 30 + reg := ®istry.Registry{} + m := New(cfg, reg) + m.width = 150 + m.height = 60 + m.showSplash = false + + // Mouse in packages area (x >= 30, y in packages region) + // Services roughly at top half, packages at bottom half + packagesY := 35 // Bottom half + + msg := tea.MouseMsg{ + Type: tea.MouseMotion, + X: 50, + Y: packagesY, + } + + newModel, _ := m.Update(msg) + model := newModel.(*Model) + + if model.focused != PanePackages { + t.Errorf("expected focus on packages, got %v", model.focused) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `CGO_ENABLED=0 go test ./internal/ui/ -v -run TestMouse_HoverPackagesPane` +Expected: FAIL + +**Step 3: Update mouse handling** + +In `internal/ui/model.go`, find the mouse handling in Update method and add packages region: + +```go +// In Update, tea.MouseMsg case, tea.MouseMotion: +case tea.MouseMotion: + if m.showHelp || m.settingsPanel.IsVisible() || m.alertsPanel.IsVisible() { + return m, nil + } + + x, y := msg.X, msg.Y + sidebarWidth := m.cfg.UI.SidebarWidth + + if x < sidebarWidth { + m.focused = PaneSidebar + } else if m.shouldShowBothPanes() { + // Both panes visible - determine based on Y position + servicesHeight := (m.height - 4) / 2 + if y < servicesHeight { + m.focused = PaneServices + } else if y < servicesHeight*2 { + m.focused = PanePackages + } else { + m.focused = PaneLogs + } + } else { + // Single pane mode + if m.showPackages { + // Packages pane is shown + if y < m.height/2 { + m.focused = PanePackages + } else { + m.focused = PaneLogs + } + } else { + // Services pane is shown + if y < m.height/2 { + m.focused = PaneServices + } else { + m.focused = PaneLogs + } + } + } +``` + +**Step 4: Run test to verify it passes** + +Run: `CGO_ENABLED=0 go test ./internal/ui/ -v -run TestMouse_HoverPackagesPane` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/ui/model.go internal/ui/model_test.go +git commit -m "feat(ui): add mouse hover support for packages pane + +Update mouse motion handling: +- Detect mouse position in packages region +- Update focus to PanePackages when hovering +- Handle both wide and narrow terminal layouts + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Task 17: Final Testing and Cleanup + +Run all tests, verify coverage, and ensure no regressions. + +**Step 1: Run complete test suite** + +```bash +CGO_ENABLED=0 go test ./... -v -race -coverprofile=coverage.out +``` + +Expected: All tests PASS, no race conditions, good coverage + +**Step 2: Check coverage** + +```bash +go tool cover -func=coverage.out | grep total +``` + +Expected: Coverage similar to or better than baseline (should be >50%) + +**Step 3: Run tests multiple times to catch flaky tests** + +```bash +for i in {1..5}; do + echo "Run $i" + CGO_ENABLED=0 go test ./... -v || break +done +``` + +Expected: All runs PASS consistently + +**Step 4: Build binary** + +```bash +CGO_ENABLED=0 go build -v -o devdash ./... +``` + +Expected: Build succeeds, no errors + +**Step 5: Manual smoke test (optional)** + +```bash +./devdash --version +``` + +Expected: Version displayed without crash + +**Step 6: Final commit** + +```bash +git add -A +git commit -m "chore: final cleanup and testing for packages pane + +All tests passing: +- 80+ test cases +- No race conditions +- Coverage maintained +- Binary builds successfully + +Feature complete: +- Package scanning from Nix store +- Adaptive layout (140 column threshold) +- Toggle with 'p' key +- Focus cycling and mouse support +- Comprehensive test coverage + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Task 18: Update Documentation + +Update help text and documentation to mention packages pane and 'p' key. + +**Files:** +- Modify: `internal/ui/help.go` (add 'p' key to help text) +- Create: `docs/PACKAGES_PANE.md` (user-facing documentation) + +**Step 1: Update help text** + +Edit `internal/ui/help.go` to add: + +```go +// In the help text content, add: +"p toggle packages/services view (narrow terminals)" +``` + +**Step 2: Create user documentation** + +Create `docs/PACKAGES_PANE.md`: + +```markdown +# Packages Pane + +devdash displays installed development packages (Go, Python, Node.js, etc.) alongside services. + +## Features + +- **Automatic Discovery**: Scans `.devenv/profile/bin/` to find all installed packages +- **Version Information**: Shows package versions from Nix store paths +- **Adaptive Layout**: Automatically adjusts based on terminal size +- **Toggle View**: Press `p` to switch between services and packages on narrow terminals + +## Layout Modes + +### Wide Terminals (≥140 columns) + +Both Services and Packages panes are visible simultaneously: +- Services pane in top half +- Packages pane in bottom half +- Full information always visible + +### Narrow Terminals (<140 columns) + +One pane at a time with toggle: +- Default: Services pane with `[p:packages]` indicator +- Press `p`: Packages pane with `[p:services]` indicator +- Press `p` again: Back to Services pane + +## Package Information + +Each package shows: +- **NAME**: Package binary name (e.g., "go", "python3") +- **VERSION**: Version extracted from Nix store path +- **TYPE**: Categorized type (Go, Python, Node.js, Rust, etc.) + +## Keyboard Shortcuts + +- `p`: Toggle between Services and Packages view (narrow terminals) +- `Tab`: Cycle focus through panes (includes Packages when visible) +- Mouse hover: Move focus to pane under cursor + +## Project Types + +- **Service-based projects**: Have both services and packages +- **Package-only projects**: Development environments without long-running services + - Example: CLI tools, libraries, static sites + - Only use `devenv shell`, never run `devenv up` + - Still show useful package information + +## Technical Details + +Package discovery: +1. Scans `.devenv/profile/bin/` directory +2. Resolves symlinks to Nix store paths +3. Parses package name and version from path format: `/nix/store/--/...` +4. Groups multiple binaries from same package (e.g., go, gofmt, godoc) +5. Categorizes by detected language/tool type +``` + +**Step 3: Commit documentation** + +```bash +git add internal/ui/help.go docs/PACKAGES_PANE.md +git commit -m "docs: add packages pane documentation + +Update help text with 'p' key: +- Toggle between packages and services view +- Shows in narrow terminal mode + +Add comprehensive user documentation: +- Features and layout modes +- Package information displayed +- Keyboard shortcuts +- Project type detection + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## Completion + +**Summary of changes:** + +- Created `internal/packages/` package for package discovery +- Added `PackagesView` UI component +- Integrated adaptive layout based on terminal width (140 column threshold) +- Added 'p' key toggle for narrow terminals +- Updated focus cycling and mouse support +- Comprehensive test coverage (80+ tests) +- Documentation for users + +**Testing:** + +Run: `CGO_ENABLED=0 go test ./... -v` +Expected: 80+ tests, all passing + +**Build:** + +Run: `CGO_ENABLED=0 go build -o devdash ./...` +Expected: Successful build + +**Next Steps:** + +1. Merge feature branch to main +2. Manual testing with real devenv projects +3. Gather user feedback +4. Consider future enhancements from design doc diff --git a/internal/packages/scanner.go b/internal/packages/scanner.go new file mode 100644 index 0000000..4adfb84 --- /dev/null +++ b/internal/packages/scanner.go @@ -0,0 +1,248 @@ +package packages + +import ( + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" +) + +// nixStoreRegex parses Nix store path components. +// Pattern explanation: +// - ^([a-z0-9]+) - matches the hash at the start (group 1) +// - -(.+?) - matches the package name, non-greedy to allow backtracking (group 2) +// - (?:-([0-9]+[0-9.\-a-z]*))? - optionally matches version starting with a digit (group 3) +// * Must start with a digit to distinguish from hyphenated package names +// * Can contain digits, dots, hyphens, and letters (e.g., "1.21.5", "0.14.0-unstable") +// * Optional group allows packages without versions +// +// Examples: +// - "abc123-go-1.21.5" → hash="abc123", name="go", version="1.21.5" +// - "xyz789-gcc-arm-embedded-13.2.1" → hash="xyz789", name="gcc-arm-embedded", version="13.2.1" +// - "def456-bash" → hash="def456", name="bash", version="" +var nixStoreRegex = regexp.MustCompile(`^([a-z0-9]+)-(.+?)(?:-([0-9]+[0-9.\-a-z]*))?$`) + +// parseNixStorePath extracts package name, version, and hash from a Nix store path. +// Format: /nix/store/--/... +// Returns: (name, version, hash) +func parseNixStorePath(path string) (string, string, string) { + // Get the store directory component + // e.g., /nix/store/abc123-go-1.21.5/bin/go -> abc123-go-1.21.5 + parts := strings.Split(filepath.Clean(path), string(filepath.Separator)) + + var storeComponent string + for i, part := range parts { + if part == "store" && i+1 < len(parts) { + storeComponent = parts[i+1] + break + } + } + + if storeComponent == "" { + return "", "", "" + } + + // Parse: -- + matches := nixStoreRegex.FindStringSubmatch(storeComponent) + + if len(matches) < 3 { + // Fallback: just hash-name format + firstDashIndex := strings.Index(storeComponent, "-") + if firstDashIndex > 0 { + return storeComponent[firstDashIndex+1:], "", storeComponent[:firstDashIndex] + } + return storeComponent, "", "" + } + + hash := matches[1] + name := matches[2] + version := matches[3] // Will be empty string if group didn't match + + return name, version, hash +} + +// categorizePackage determines the package type based on name patterns. +// Uses exact matches for core tools and explicit checks for known variants +// to avoid false positives (e.g., "golang" or "google-chrome" shouldn't match "go"). +// +// Examples: +// - categorizePackage("go") → "Go" +// - categorizePackage("python3") → "Python" +// - categorizePackage("node") → "Node.js" +// - categorizePackage("golang") → "Other" (not the Go toolchain) +// - categorizePackage("python-dotenv") → "Other" (Python package, not Python itself) +func categorizePackage(name string) string { + lowerName := strings.ToLower(name) + + // Go packages - exact match for "go" + known Go toolchain binaries + if lowerName == "go" || + lowerName == "gopls" || + lowerName == "gofmt" || + lowerName == "godoc" || + lowerName == "goimports" || + lowerName == "golangci-lint" { + return "Go" + } + + // Python packages - exact match for python/python2/python3 + known Python tools + if lowerName == "python" || + lowerName == "python2" || + lowerName == "python3" || + lowerName == "pip" || + lowerName == "pip2" || + lowerName == "pip3" || + lowerName == "pytest" || + lowerName == "poetry" || + lowerName == "pipenv" { + return "Python" + } + + // Node.js packages - exact matches for Node ecosystem tools + if lowerName == "node" || + lowerName == "nodejs" || + lowerName == "npm" || + lowerName == "npx" || + lowerName == "yarn" || + lowerName == "pnpm" { + return "Node.js" + } + + // Rust packages - exact matches for Rust toolchain + if lowerName == "cargo" || + lowerName == "rustc" || + lowerName == "rustup" || + lowerName == "rustfmt" { + return "Rust" + } + + // C/C++ compilers - exact matches + if lowerName == "gcc" || + lowerName == "g++" || + lowerName == "clang" || + lowerName == "clang++" || + lowerName == "make" || + lowerName == "cmake" { + return "C/C++" + } + + // Ruby - exact matches + if lowerName == "ruby" || + lowerName == "gem" || + lowerName == "bundle" || + lowerName == "bundler" || + lowerName == "rake" { + return "Ruby" + } + + // Java - exact matches + if lowerName == "java" || + lowerName == "javac" || + lowerName == "maven" || + lowerName == "mvn" || + lowerName == "gradle" { + return "Java" + } + + return "Other" +} + +// binaryInfo holds parsed information about a binary. +type binaryInfo struct { + name string + version string + hash string + path string +} + +// Scan discovers packages in a devenv project. +// Returns list of packages found, or empty list if directory doesn't exist. +func Scan(projectPath string) ([]Package, error) { + binDir := filepath.Join(projectPath, ".devenv", "profile", "bin") + + // Check if bin directory exists + if _, err := os.Stat(binDir); os.IsNotExist(err) { + return []Package{}, nil + } + + var binaries []binaryInfo + + // Read all files in bin directory + err := filepath.WalkDir(binDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil // Skip errors + } + + // Skip directories and the bin directory itself + if d.IsDir() { + return nil + } + + // Resolve symlink to find Nix store path + target, err := os.Readlink(path) + if err != nil { + // Not a symlink, use the file path itself + target = path + } + + // Parse Nix store path + name, version, hash := parseNixStorePath(target) + if name == "" { + // Couldn't parse, skip + return nil + } + + binaries = append(binaries, binaryInfo{ + name: name, + version: version, + hash: hash, + path: path, + }) + + return nil + }) + + if err != nil { + return []Package{}, err + } + + // Group binaries by package + packages := groupByPackage(binaries) + + return packages, nil +} + +// groupByPackage groups binaries from the same Nix package. +// Binaries with the same hash+version belong to the same package. +func groupByPackage(binaries []binaryInfo) []Package { + // Use map to group by package identifier (hash-version) + // Same hash+version = same Nix package, even if binary names differ + packageMap := make(map[string]*Package) + + for _, bin := range binaries { + // Create unique package identifier based on hash and version only + // This groups go, gofmt, godoc etc. from the same Go installation + pkgID := bin.hash + "-" + bin.version + + if _, exists := packageMap[pkgID]; exists { + // Package already seen, skip this binary + continue + } + + // New package - use the binary name as the package name + packageMap[pkgID] = &Package{ + Name: bin.name, + Version: bin.version, + Type: categorizePackage(bin.name), + Binary: bin.path, + } + } + + // Convert map to slice + packages := make([]Package, 0, len(packageMap)) + for _, pkg := range packageMap { + packages = append(packages, *pkg) + } + + return packages +} diff --git a/internal/packages/types.go b/internal/packages/types.go new file mode 100644 index 0000000..37b885f --- /dev/null +++ b/internal/packages/types.go @@ -0,0 +1,18 @@ +package packages + +import "time" + +// Package represents a single installed package. +type Package struct { + Name string // e.g., "go", "python3", "node" + Version string // e.g., "1.21.5", "3.11.7" + Type string // e.g., "Go", "Python", "Node.js" + Binary string // Full path to binary +} + +// PackageInfo holds package information for a project. +type PackageInfo struct { + ProjectPath string // Absolute path to the project + Packages []Package // List of detected packages + LastScanned time.Time // When the project was last scanned +} diff --git a/internal/packages/types_test.go b/internal/packages/types_test.go new file mode 100644 index 0000000..65b2c0f --- /dev/null +++ b/internal/packages/types_test.go @@ -0,0 +1,326 @@ +package packages + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseNixStorePath(t *testing.T) { + tests := []struct { + name string + path string + expectedPkg string + expectedVersion string + expectedHash string + }{ + { + name: "standard format", + path: "/nix/store/abc123-go-1.21.5/bin/go", + expectedPkg: "go", + expectedVersion: "1.21.5", + expectedHash: "abc123", + }, + { + name: "python with dots", + path: "/nix/store/xyz789-python3-3.11.7/bin/python3", + expectedPkg: "python3", + expectedVersion: "3.11.7", + expectedHash: "xyz789", + }, + { + name: "package with complex name", + path: "/nix/store/def456-gopls-0.14.0-unstable/bin/gopls", + expectedPkg: "gopls", + expectedVersion: "0.14.0-unstable", + expectedHash: "def456", + }, + { + name: "no version", + path: "/nix/store/ghi789-bash/bin/bash", + expectedPkg: "bash", + expectedVersion: "", + expectedHash: "ghi789", + }, + { + name: "hyphenated package name with version", + path: "/nix/store/abc123-gcc-arm-embedded-13.2.1/bin/arm-none-eabi-gcc", + expectedPkg: "gcc-arm-embedded", + expectedVersion: "13.2.1", + expectedHash: "abc123", + }, + { + name: "hyphenated package name without version", + path: "/nix/store/def456-some-cool-tool/bin/tool", + expectedPkg: "some-cool-tool", + expectedVersion: "", + expectedHash: "def456", + }, + { + name: "multiple hyphens in name and version", + path: "/nix/store/xyz789-node-packages-1.2.3-alpha/bin/npm", + expectedPkg: "node-packages", + expectedVersion: "1.2.3-alpha", + expectedHash: "xyz789", + }, + { + name: "empty string", + path: "", + expectedPkg: "", + expectedVersion: "", + expectedHash: "", + }, + { + name: "path without store directory", + path: "/usr/bin/go", + expectedPkg: "", + expectedVersion: "", + expectedHash: "", + }, + { + name: "path with store but no following component", + path: "/nix/store/", + expectedPkg: "", + expectedVersion: "", + expectedHash: "", + }, + { + name: "malformed store component - only hash", + path: "/nix/store/abc123/bin/tool", + expectedPkg: "abc123", + expectedVersion: "", + expectedHash: "", + }, + { + name: "malformed store component - no dashes", + path: "/nix/store/invalidformat/bin/tool", + expectedPkg: "invalidformat", + expectedVersion: "", + expectedHash: "", + }, + { + name: "version starting with zero", + path: "/nix/store/abc123-package-0.1.2/bin/pkg", + expectedPkg: "package", + expectedVersion: "0.1.2", + expectedHash: "abc123", + }, + // Regex fallback tests (exercises lines 40-45) + { + name: "fallback_just_hash_and_name", + path: "/nix/store/abc123-tool/bin/tool", + expectedPkg: "tool", + expectedVersion: "", + expectedHash: "abc123", + }, + { + name: "fallback_special_chars_in_name", + path: "/nix/store/xyz789-my_special.tool/bin/tool", + expectedPkg: "my_special.tool", + expectedVersion: "", + expectedHash: "xyz789", + }, + { + name: "fallback_version_like_but_not_version", + path: "/nix/store/abc-tool-v2/bin/tool", + expectedPkg: "tool-v2", + expectedVersion: "", + expectedHash: "abc", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkg, version, hash := parseNixStorePath(tt.path) + if pkg != tt.expectedPkg { + t.Errorf("expected package %q, got %q", tt.expectedPkg, pkg) + } + if version != tt.expectedVersion { + t.Errorf("expected version %q, got %q", tt.expectedVersion, version) + } + if hash != tt.expectedHash { + t.Errorf("expected hash %q, got %q", tt.expectedHash, hash) + } + }) + } +} + +func TestCategorizePackage(t *testing.T) { + tests := []struct { + name string + packageName string + expectedType string + }{ + // Go toolchain + {"go_lowercase", "go", "Go"}, + {"go_uppercase", "GO", "Go"}, + {"go_mixedcase", "Go", "Go"}, + {"gofmt", "gofmt", "Go"}, + {"gopls", "gopls", "Go"}, + {"godoc", "godoc", "Go"}, + {"goimports", "goimports", "Go"}, + {"golangci_lint", "golangci-lint", "Go"}, + + // Go false positives (should be Other) + {"golang_not_go_toolchain", "golang", "Other"}, + {"go_tools_prefix", "go-tools", "Other"}, + {"google_chrome_prefix", "google-chrome", "Other"}, + {"gopher_prefix", "gopher", "Other"}, + + // Python toolchain + {"python", "python", "Python"}, + {"python2", "python2", "Python"}, + {"python3", "python3", "Python"}, + {"python3_uppercase", "PYTHON3", "Python"}, + {"pip", "pip", "Python"}, + {"pip2", "pip2", "Python"}, + {"pip3", "pip3", "Python"}, + {"pytest", "pytest", "Python"}, + {"poetry", "poetry", "Python"}, + {"pipenv", "pipenv", "Python"}, + + // Python false positives (should be Other) + {"python_dotenv_package", "python-dotenv", "Other"}, + {"python_requests_package", "python-requests", "Other"}, + {"pythonista", "pythonista", "Other"}, + + // Node.js toolchain + {"node", "node", "Node.js"}, + {"nodejs", "nodejs", "Node.js"}, + {"node_uppercase", "NODE", "Node.js"}, + {"npm", "npm", "Node.js"}, + {"npx", "npx", "Node.js"}, + {"yarn", "yarn", "Node.js"}, + {"pnpm", "pnpm", "Node.js"}, + + // Node.js false positives (should be Other) + {"node_fetch_package", "node-fetch", "Other"}, + {"node_sass_package", "node-sass", "Other"}, + {"nodemon", "nodemon", "Other"}, + + // Rust toolchain + {"cargo", "cargo", "Rust"}, + {"rustc", "rustc", "Rust"}, + {"rustup", "rustup", "Rust"}, + {"rustfmt", "rustfmt", "Rust"}, + + // C/C++ toolchain + {"gcc", "gcc", "C/C++"}, + {"g++", "g++", "C/C++"}, + {"clang", "clang", "C/C++"}, + {"clang++", "clang++", "C/C++"}, + {"make", "make", "C/C++"}, + {"cmake", "cmake", "C/C++"}, + + // Ruby toolchain + {"ruby", "ruby", "Ruby"}, + {"gem", "gem", "Ruby"}, + {"bundle", "bundle", "Ruby"}, + {"bundler", "bundler", "Ruby"}, + {"rake", "rake", "Ruby"}, + + // Java toolchain + {"java", "java", "Java"}, + {"javac", "javac", "Java"}, + {"maven", "maven", "Java"}, + {"mvn", "mvn", "Java"}, + {"gradle", "gradle", "Java"}, + + // Other/Unknown packages + {"unknown_package", "foobar", "Other"}, + {"bash", "bash", "Other"}, + {"zsh", "zsh", "Other"}, + {"git", "git", "Other"}, + {"docker", "docker", "Other"}, + {"kubectl", "kubectl", "Other"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + typ := categorizePackage(tt.packageName) + if typ != tt.expectedType { + t.Errorf("categorizePackage(%q) = %q, want %q", tt.packageName, typ, tt.expectedType) + } + }) + } +} + +func TestScan(t *testing.T) { + // Create temporary test directory structure + tmpDir := t.TempDir() + projectDir := filepath.Join(tmpDir, "testproject") + binDir := filepath.Join(projectDir, ".devenv", "profile", "bin") + + err := os.MkdirAll(binDir, 0755) + if err != nil { + t.Fatalf("failed to create test directory: %v", err) + } + + // Create test binary files (regular files for testing, not actual symlinks) + testBinaries := []string{"go", "python3", "node"} + for _, bin := range testBinaries { + binPath := filepath.Join(binDir, bin) + err := os.WriteFile(binPath, []byte("#!/bin/sh\necho test"), 0755) + if err != nil { + t.Fatalf("failed to create test binary %s: %v", bin, err) + } + } + + // Run scan + packages, err := Scan(projectDir) + if err != nil { + t.Fatalf("Scan failed: %v", err) + } + + // Should not error - package count validation will be in Task 5 when grouping is implemented + _ = packages +} + +func TestScanMissingDirectory(t *testing.T) { + packages, err := Scan("/nonexistent/path") + + // Should not error, just return empty list + if err != nil { + t.Errorf("expected no error for missing directory, got %v", err) + } + if len(packages) != 0 { + t.Errorf("expected 0 packages for missing directory, got %d", len(packages)) + } +} + +func TestGroupByPackage(t *testing.T) { + binaries := []binaryInfo{ + {name: "go", version: "1.21.5", hash: "abc123", path: "/path/to/go"}, + {name: "gofmt", version: "1.21.5", hash: "abc123", path: "/path/to/gofmt"}, + {name: "gopls", version: "0.14.0", hash: "xyz789", path: "/path/to/gopls"}, + {name: "python3", version: "3.11.7", hash: "def456", path: "/path/to/python3"}, + } + + packages := groupByPackage(binaries) + + // Should have 3 packages (go+gofmt grouped, gopls separate, python separate) + if len(packages) != 3 { + t.Errorf("expected 3 packages, got %d", len(packages)) + } + + // Find the Go package + var goPackage *Package + for i := range packages { + if packages[i].Name == "go" { + goPackage = &packages[i] + break + } + } + + if goPackage == nil { + t.Fatal("expected to find 'go' package") + } + + if goPackage.Version != "1.21.5" { + t.Errorf("expected go version 1.21.5, got %q", goPackage.Version) + } + + if goPackage.Type != "Go" { + t.Errorf("expected go type 'Go', got %q", goPackage.Type) + } +} diff --git a/internal/ui/help.go b/internal/ui/help.go index ed2d215..121dcae 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -117,8 +117,9 @@ func (h *HelpPanel) View() string { rightCol += " " + kb("↑/k") + " Up\n" rightCol += " " + kb("↓/j") + " Down\n" rightCol += " " + kb("Tab") + " Switch pane\n" + rightCol += " " + k("p") + " Toggle packages/services\n" rightCol += " " + kb("Enter") + " Select/Confirm\n" - rightCol += " " + kb("Esc") + " Back/Cancel\n\n\n" + rightCol += " " + kb("Esc") + " Back/Cancel\n\n" // SIDEBAR leftCol += h.styles.Title.Render("SIDEBAR") + "\n" diff --git a/internal/ui/model.go b/internal/ui/model.go index f7b1ac8..8dfc4ca 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -23,6 +23,7 @@ import ( "github.com/infktd/devdash/internal/config" "github.com/infktd/devdash/internal/health" "github.com/infktd/devdash/internal/notify" + "github.com/infktd/devdash/internal/packages" "github.com/infktd/devdash/internal/registry" ) @@ -32,6 +33,7 @@ type FocusedPane int const ( PaneSidebar FocusedPane = iota PaneServices + PanePackages PaneLogs ) @@ -91,6 +93,7 @@ type Model struct { showHelp bool showSettings bool showSplash bool + showPackages bool // When true, show packages pane instead of services (narrow terminals) // Search state (for logs) searchMode bool @@ -103,6 +106,7 @@ type Model struct { // Components logView *LogView + packagesView *PackagesView toast *ToastManager alerts *AlertHistory alertsPanel *AlertsPanel @@ -267,6 +271,7 @@ func New(cfg *config.Config, reg *registry.Registry) *Model { // Initialize components logView: NewLogView(styles, 80, 20), + packagesView: NewPackagesView(styles), toast: NewToastManager(styles, 60), alerts: alertHistory, alertsPanel: NewAlertsPanel(styles, alertHistory, 80, 24), @@ -319,7 +324,7 @@ func (m *Model) handleMouseEvent(msg tea.MouseMsg) (tea.Model, tea.Cmd) { // Calculate pane boundaries sidebarWidth := m.config.UI.SidebarWidth - servicesHeight := (m.height - 4) / 3 // Approximate services pane height + bodyHeight := m.height - 4 // header + footer // Determine which pane the mouse is over x := msg.X @@ -330,10 +335,29 @@ func (m *Model) handleMouseEvent(msg tea.MouseMsg) (tea.Model, tea.Cmd) { if x < sidebarWidth { // Mouse is in sidebar (projects) newFocus = PaneSidebar - } else { - // Mouse is in main area (services or logs) + } else if m.shouldShowBothPanes() { + // Wide terminal: Services, Packages, Logs stacked vertically + servicesHeight := bodyHeight / 4 + packagesHeight := bodyHeight / 4 + if y < servicesHeight+2 { // +2 for header newFocus = PaneServices + } else if y < servicesHeight+packagesHeight+4 { // +4 for header and borders + newFocus = PanePackages + } else { + newFocus = PaneLogs + } + } else { + // Narrow terminal: either Services or Packages, plus Logs + mainPaneHeight := bodyHeight / 3 + + if y < mainPaneHeight+2 { // +2 for header + // In main pane area - could be services or packages + if m.showPackages { + newFocus = PanePackages + } else { + newFocus = PaneServices + } } else { newFocus = PaneLogs } @@ -1158,6 +1182,9 @@ func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.alertsPanel.Show() } return m, nil + case msg.String() == "p": + // Toggle between packages and services view + return m, m.togglePackagesView() case key.Matches(msg, m.keys.Back): // Don't handle Esc globally if sidebar is filtering or logs has active search if m.focused == PaneSidebar && m.projectFilterMode { @@ -1495,6 +1522,21 @@ func (m *Model) currentProject() *registry.Project { return m.displayedProjects[m.selectedProject] } +const ( + minWidthForBothPanes = 140 +) + +// shouldShowBothPanes determines if there's enough space to show both Services and Packages panes. +func (m *Model) shouldShowBothPanes() bool { + return m.width >= minWidthForBothPanes +} + +// togglePackagesView switches between Services and Packages pane (for narrow terminals). +func (m *Model) togglePackagesView() tea.Cmd { + m.showPackages = !m.showPackages + return nil +} + // updateDisplayedProjects rebuilds the sidebar display order (active first, then idle, alphabetical within each). // It also caches the detected states to avoid inconsistent state during rendering. func (m *Model) updateDisplayedProjects() { @@ -1620,11 +1662,45 @@ func (m *Model) listIndexToProjectIndex(listIndex int) int { } func (m *Model) cycleFocus() { - m.focused = (m.focused + 1) % 3 + switch m.focused { + case PaneSidebar: + m.focused = PaneServices + case PaneServices: + // If both panes visible, go to packages next + // Otherwise skip to logs + if m.shouldShowBothPanes() { + m.focused = PanePackages + } else { + m.focused = PaneLogs + } + case PanePackages: + m.focused = PaneLogs + case PaneLogs: + m.focused = PaneSidebar + default: + m.focused = PaneSidebar + } } func (m *Model) cycleFocusReverse() { - m.focused = (m.focused + 2) % 3 // +2 is same as -1 mod 3 + switch m.focused { + case PaneSidebar: + m.focused = PaneLogs + case PaneServices: + m.focused = PaneSidebar + case PanePackages: + m.focused = PaneServices + case PaneLogs: + // If both panes visible, go to packages previous + // Otherwise skip to services + if m.shouldShowBothPanes() { + m.focused = PanePackages + } else { + m.focused = PaneServices + } + default: + m.focused = PaneSidebar + } } // switchToCurrentProject updates the services display for the currently selected project @@ -1640,6 +1716,19 @@ func (m *Model) switchToCurrentProject() { m.memHistory = make(map[string][]int64) // Reset memory history m.logView.SetService("") // Clear service filter m.logView.buffer.Clear() // Clear old logs + + // Scan packages for new project + project := m.currentProject() + if project != nil { + pkgs, err := packages.Scan(project.Path) + if err != nil { + // Log error but don't block + pkgs = []packages.Package{} + } + m.packagesView.SetPackages(pkgs) + } else { + m.packagesView.SetPackages([]packages.Package{}) + } } func (m *Model) moveUp() tea.Cmd { @@ -1951,21 +2040,45 @@ func (m *Model) renderProjectItem(idx int, p *registry.Project) string { } func (m *Model) renderMain(width, height int) string { - servicesHeight := height / 3 - logsHeight := height - servicesHeight - 2 + // Determine layout based on width + if m.shouldShowBothPanes() { + // Wide terminal: show Services, Packages, and Logs stacked vertically + servicesHeight := height / 4 + packagesHeight := height / 4 + logsHeight := height - servicesHeight - packagesHeight - 4 + + services := m.renderServices(width, servicesHeight) + packages := m.renderPackages(width, packagesHeight) + logs := m.renderLogs(width, logsHeight) + + return lipgloss.JoinVertical(lipgloss.Left, services, packages, logs) + } else { + // Narrow terminal: show either Services or Packages, plus Logs + mainPaneHeight := height / 3 + logsHeight := height - mainPaneHeight - 2 + + var mainPane string + if m.showPackages { + mainPane = m.renderPackages(width, mainPaneHeight) + } else { + mainPane = m.renderServices(width, mainPaneHeight) + } - services := m.renderServices(width, servicesHeight) - logs := m.renderLogs(width, logsHeight) + logs := m.renderLogs(width, logsHeight) - return lipgloss.JoinVertical(lipgloss.Left, services, logs) + return lipgloss.JoinVertical(lipgloss.Left, mainPane, logs) + } } func (m *Model) renderServices(width, height int) string { var content string if p := m.currentProject(); p != nil { - // Title with focus indicator + // Title with focus indicator and toggle hint on narrow terminals title := fmt.Sprintf("SERVICES [%s]", p.Name) + if !m.shouldShowBothPanes() { + title += " [p:packages]" + } content = m.renderSectionTitle(title, m.focused == PaneServices, width-4) + "\n" if len(m.services) == 0 { @@ -1996,7 +2109,11 @@ func (m *Model) renderServices(width, height int) string { content += m.servicesTable.View() } } else { - content = m.renderSectionTitle("SERVICES", m.focused == PaneServices, width-4) + "\n" + title := "SERVICES" + if !m.shouldShowBothPanes() { + title += " [p:packages]" + } + content = m.renderSectionTitle(title, m.focused == PaneServices, width-4) + "\n" emptyStateHeight := height - 8 emptyContent := m.renderEmptyState( "No Project Selected", @@ -2020,6 +2137,30 @@ func (m *Model) renderServices(width, height int) string { return style.Width(width).Height(height).Render(content) } +func (m *Model) renderPackages(width, height int) string { + // Update packagesView size and focus state + m.packagesView.SetSize(width-4, height-4) + m.packagesView.SetFocused(m.focused == PanePackages) + + // Add toggle indicator on narrow terminals + if !m.shouldShowBothPanes() { + m.packagesView.SetTitleSuffix("[p:services]") + } else { + m.packagesView.SetTitleSuffix("") + } + + // Get packages view content + packagesContent := m.packagesView.View() + + // Wrap in border + style := m.styles.BlurredBorder + if m.focused == PanePackages { + style = m.styles.FocusedBorder + } + + return style.Width(width).Height(height).Render(packagesContent) +} + func (m *Model) renderLogs(width, height int) string { m.logView.SetSize(width-4, height-4) diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 70a1b54..f8bd080 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -1,6 +1,9 @@ package ui import ( + "os" + "path/filepath" + "strings" "testing" tea "github.com/charmbracelet/bubbletea" @@ -416,3 +419,257 @@ func TestSwitchToCurrentProjectResetsState(t *testing.T) { t.Error("cpuHistory should be cleared") } } + +func TestModelPackagesViewInitialized(t *testing.T) { + cfg := config.Default() + reg := ®istry.Registry{} + m := New(cfg, reg) + + if m.packagesView == nil { + t.Error("packagesView should be initialized") + } +} + +func TestShouldShowBothPanes(t *testing.T) { + cfg := config.Default() + reg := ®istry.Registry{} + m := New(cfg, reg) + + // Narrow terminal - should show single pane + m.width = 139 + if m.shouldShowBothPanes() { + t.Error("expected false for width 139") + } + + // Wide terminal - should show both panes + m.width = 140 + if !m.shouldShowBothPanes() { + t.Error("expected true for width 140") + } + + m.width = 200 + if !m.shouldShowBothPanes() { + t.Error("expected true for width 200") + } +} + +func TestTogglePackagesView(t *testing.T) { + cfg := config.Default() + reg := ®istry.Registry{} + m := New(cfg, reg) + m.width = 100 // Narrow terminal + + // Default should show services (showPackages = false) + if m.showPackages { + t.Error("expected showPackages to be false by default") + } + + // Toggle to packages + m.togglePackagesView() + if !m.showPackages { + t.Error("expected showPackages to be true after toggle") + } + + // Toggle back to services + m.togglePackagesView() + if m.showPackages { + t.Error("expected showPackages to be false after second toggle") + } +} + +func TestPressP_TogglesPackagesView(t *testing.T) { + cfg := config.Default() + reg := ®istry.Registry{} + m := New(cfg, reg) + m.width = 100 // Narrow terminal + m.showSplash = false + + initialState := m.showPackages + + // Press 'p' key + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}} + newModel, _ := m.Update(msg) + model := newModel.(*Model) + + if model.showPackages == initialState { + t.Error("pressing 'p' should toggle showPackages") + } +} + +func TestPressP_NoEffectOnWideTerminal(t *testing.T) { + cfg := config.Default() + reg := ®istry.Registry{} + m := New(cfg, reg) + m.width = 150 // Wide terminal - both panes visible + m.showSplash = false + + // Press 'p' key shouldn't have effect + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}} + newModel, _ := m.Update(msg) + model := newModel.(*Model) + + // On wide terminals, 'p' could still toggle but doesn't affect display + // Just verify it doesn't crash + _ = model +} + +func TestProjectSwitch_ScansPackages(t *testing.T) { + cfg := config.Default() + reg := ®istry.Registry{ + Projects: []*registry.Project{ + {Path: "/tmp/testproject", Name: "test"}, + }, + } + m := New(cfg, reg) + m.selectedProject = 0 + + // Switch to project (this will try to scan packages) + m.switchToCurrentProject() + + // PackagesView should have been updated (even if empty) + // This is a basic test - in real usage, packages would be populated + // if .devenv/profile/bin/ exists + if m.packagesView == nil { + t.Error("packagesView should not be nil after project switch") + } +} + +func TestView_WideTerminal_ShowsBothPanes(t *testing.T) { + cfg := config.Default() + reg := ®istry.Registry{} + m := New(cfg, reg) + m.width = 150 + m.height = 40 + m.showSplash = false + + view := m.View() + + // Both "SERVICES" and "PACKAGES" should appear in view + if !strings.Contains(view, "SERVICES") { + t.Error("wide terminal view should contain SERVICES") + } + if !strings.Contains(view, "PACKAGES") { + t.Error("wide terminal view should contain PACKAGES") + } +} + +func TestView_NarrowTerminal_ShowsIndicator(t *testing.T) { + cfg := config.Default() + reg := ®istry.Registry{} + m := New(cfg, reg) + m.width = 100 + m.height = 40 + m.showSplash = false + + // Showing services - should have packages indicator + m.showPackages = false + view := m.View() + if !strings.Contains(view, "[p:packages]") { + t.Error("narrow terminal showing services should contain '[p:packages]' indicator") + } + + // Showing packages - should have services indicator + m.showPackages = true + view = m.View() + if !strings.Contains(view, "[p:services]") { + t.Error("narrow terminal showing packages should contain '[p:services]' indicator") + } +} + +func TestMouse_HoverPackagesPane(t *testing.T) { + cfg := config.Default() + cfg.UI.SidebarWidth = 30 + reg := ®istry.Registry{} + m := New(cfg, reg) + m.width = 150 + m.height = 60 + m.showSplash = false + + // Mouse in packages area (x >= 30, y in packages region) + // Services roughly at top quarter, packages at second quarter + packagesY := 20 // In packages region + + msg := tea.MouseMsg{ + Type: tea.MouseMotion, + X: 50, + Y: packagesY, + } + + newModel, _ := m.Update(msg) + model := newModel.(*Model) + + if model.focused != PanePackages { + t.Errorf("expected focus on packages, got %v", model.focused) + } +} + +func TestPackagesPaneIntegration(t *testing.T) { + // Create test project directory with packages + tmpDir := t.TempDir() + projectPath := filepath.Join(tmpDir, "testproject") + binDir := filepath.Join(projectPath, ".devenv", "profile", "bin") + + err := os.MkdirAll(binDir, 0755) + if err != nil { + t.Fatalf("failed to create test directory: %v", err) + } + + // Create test binaries + testBins := map[string]string{ + "go": "#!/bin/sh\necho go1.21", + "python3": "#!/bin/sh\necho python3.11", + "node": "#!/bin/sh\necho node20", + } + + for name, content := range testBins { + binPath := filepath.Join(binDir, name) + err := os.WriteFile(binPath, []byte(content), 0755) + if err != nil { + t.Fatalf("failed to create binary %s: %v", name, err) + } + } + + // Create model with test project + cfg := config.Default() + reg := ®istry.Registry{ + Projects: []*registry.Project{ + {Path: projectPath, Name: "testproject"}, + }, + } + m := New(cfg, reg) + m.width = 150 // Wide terminal + m.height = 50 + m.showSplash = false + m.selectedProject = 0 + + // Trigger project switch (should scan packages) + m.switchToCurrentProject() + + // Verify packages were scanned + view := m.View() + if !strings.Contains(view, "PACKAGES") { + t.Error("view should contain PACKAGES pane") + } + + // Switch to narrow terminal + m.width = 100 + msg := tea.WindowSizeMsg{Width: 100, Height: 50} + newModel, _ := m.Update(msg) + m = newModel.(*Model) + + // Should show services by default + view = m.View() + if !strings.Contains(view, "[p:packages]") { + t.Error("narrow terminal should show packages indicator") + } + + // Toggle to packages + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}} + newModel, _ = m.Update(keyMsg) + m = newModel.(*Model) + + view = m.View() + if !strings.Contains(view, "[p:services]") { + t.Error("narrow terminal showing packages should show services indicator") + } +} diff --git a/internal/ui/packages.go b/internal/ui/packages.go new file mode 100644 index 0000000..5c71207 --- /dev/null +++ b/internal/ui/packages.go @@ -0,0 +1,156 @@ +package ui + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" + "github.com/infktd/devdash/internal/packages" +) + +// PackagesView displays installed packages for a project. +type PackagesView struct { + styles *Styles + packages []packages.Package + width int + height int + focused bool + titleSuffix string // Optional suffix to append to title (e.g., "[p:services]") +} + +// NewPackagesView creates a new packages view. +func NewPackagesView(styles *Styles) *PackagesView { + return &PackagesView{ + styles: styles, + packages: []packages.Package{}, + width: 0, + height: 0, + } +} + +// SetPackages updates the package list. +func (pv *PackagesView) SetPackages(pkgs []packages.Package) { + pv.packages = pkgs +} + +// SetSize updates dimensions. +func (pv *PackagesView) SetSize(width, height int) { + pv.width = width + pv.height = height +} + +// SetFocused sets focus state. +func (pv *PackagesView) SetFocused(focused bool) { + pv.focused = focused +} + +// SetTitleSuffix sets an optional suffix to append to the title. +func (pv *PackagesView) SetTitleSuffix(suffix string) { + pv.titleSuffix = suffix +} + +// View renders the packages view. +func (pv *PackagesView) View() string { + if pv.width == 0 || pv.height == 0 { + return "" + } + + // Header with focus styling + title := fmt.Sprintf("PACKAGES (%d)", len(pv.packages)) + if pv.titleSuffix != "" { + title += " " + pv.titleSuffix + } + var headerStyle lipgloss.Style + if pv.focused { + headerStyle = lipgloss.NewStyle(). + Foreground(pv.styles.theme.Primary). + Bold(true) + } else { + headerStyle = lipgloss.NewStyle(). + Foreground(pv.styles.theme.Muted) + } + header := headerStyle.Render(title) + + // Empty state + if len(pv.packages) == 0 { + emptyMsg := lipgloss.NewStyle(). + Foreground(pv.styles.theme.Muted). + Render("No packages detected") + content := lipgloss.NewStyle(). + Width(pv.width). + Height(pv.height - 1). + Align(lipgloss.Center, lipgloss.Center). + Render(emptyMsg) + + return lipgloss.JoinVertical(lipgloss.Left, header, content) + } + + // Column headers + colHeaders := pv.renderColumnHeaders() + + // Package rows + rows := pv.renderPackageRows() + + // Combine + var lines []string + lines = append(lines, header) + lines = append(lines, colHeaders) + lines = append(lines, rows...) + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +// renderColumnHeaders renders the column header row. +func (pv *PackagesView) renderColumnHeaders() string { + headerStyle := lipgloss.NewStyle(). + Foreground(pv.styles.theme.Muted). + Bold(true) + + nameCol := headerStyle.Width(20).Render("NAME") + versionCol := headerStyle.Width(15).Render("VERSION") + typeCol := headerStyle.Width(15).Render("TYPE") + + return lipgloss.JoinHorizontal(lipgloss.Top, nameCol, versionCol, typeCol) +} + +// renderPackageRows renders package data rows. +func (pv *PackagesView) renderPackageRows() []string { + var rows []string + + // Limit to visible height (header + column headers take 2 lines) + maxRows := pv.height - 2 + if maxRows < 0 { + maxRows = 0 + } + + for i := 0; i < len(pv.packages) && i < maxRows; i++ { + pkg := pv.packages[i] + + // Truncate long names + name := pkg.Name + if len(name) > 18 { + name = name[:15] + "..." + } + + version := pkg.Version + if version == "" { + version = "unknown" + } + if len(version) > 13 { + version = version[:10] + "..." + } + + typ := pkg.Type + if len(typ) > 13 { + typ = typ[:10] + "..." + } + + nameCell := lipgloss.NewStyle().Width(20).Render(name) + versionCell := lipgloss.NewStyle().Width(15).Render(version) + typeCell := lipgloss.NewStyle().Width(15).Render(typ) + + row := lipgloss.JoinHorizontal(lipgloss.Top, nameCell, versionCell, typeCell) + rows = append(rows, row) + } + + return rows +} diff --git a/internal/ui/packages_test.go b/internal/ui/packages_test.go new file mode 100644 index 0000000..f6aa5db --- /dev/null +++ b/internal/ui/packages_test.go @@ -0,0 +1,65 @@ +package ui + +import ( + "testing" + + "github.com/infktd/devdash/internal/packages" +) + +func TestPackagesViewCreate(t *testing.T) { + styles := NewStyles(GetTheme("matrix")) + pv := NewPackagesView(styles) + + if pv == nil { + t.Fatal("NewPackagesView returned nil") + } + + if pv.styles != styles { + t.Error("styles not set correctly") + } +} + +func TestPackagesViewSetPackages(t *testing.T) { + styles := NewStyles(GetTheme("matrix")) + pv := NewPackagesView(styles) + + pkgs := []packages.Package{ + {Name: "go", Version: "1.21.5", Type: "Go"}, + {Name: "python3", Version: "3.11.7", Type: "Python"}, + } + + pv.SetPackages(pkgs) + + if len(pv.packages) != 2 { + t.Errorf("expected 2 packages, got %d", len(pv.packages)) + } +} + +func TestPackagesViewEmptyState(t *testing.T) { + styles := NewStyles(GetTheme("matrix")) + pv := NewPackagesView(styles) + pv.SetSize(80, 20) + + view := pv.View() + + if view == "" { + t.Error("View should not be empty") + } +} + +func TestPackagesViewWithPackages(t *testing.T) { + styles := NewStyles(GetTheme("matrix")) + pv := NewPackagesView(styles) + pv.SetSize(80, 20) + + pkgs := []packages.Package{ + {Name: "go", Version: "1.21.5", Type: "Go"}, + } + pv.SetPackages(pkgs) + + view := pv.View() + + if view == "" { + t.Error("View should not be empty with packages") + } +}