From 183310f4f58856874bd19928926810aaf7f8eb53 Mon Sep 17 00:00:00 2001 From: infktd <25016104+infktd@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:01:34 -0600 Subject: [PATCH 01/22] Add packages pane feature design document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the complete design for adding a packages pane that displays installed development packages (Go, Python, Node.js, etc.) alongside the existing services pane. Includes: - Dynamic package discovery by parsing .devenv/profile/bin/ - Adaptive layout showing both panes when space allows (≥140 cols) - Toggle key 'p' to switch views on smaller terminals - Comprehensive testing strategy - Enhanced project type detection (service-based vs package-only) Co-Authored-By: Claude Sonnet 4.5 --- docs/plans/2026-01-21-packages-pane-design.md | 316 ++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 docs/plans/2026-01-21-packages-pane-design.md 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 From 563da0b161411bdac7e6c6ba3351a12dd85a4fa8 Mon Sep 17 00:00:00 2001 From: infktd <25016104+infktd@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:07:58 -0600 Subject: [PATCH 02/22] Add packages pane implementation plan Detailed task-by-task implementation plan with 18 tasks: - Package types and scanner (Tasks 1-5) - PackagesView component (Task 6) - Model integration (Tasks 7-11) - Adaptive layout rendering (Tasks 12-13) - Focus and mouse support (Tasks 15-16) - Testing and documentation (Tasks 14, 17-18) Each task follows TDD approach: - Write failing test - Implement minimal code - Verify test passes - Commit Ready for execution with superpowers:executing-plans or superpowers:subagent-driven-development. Co-Authored-By: Claude Sonnet 4.5 --- ...2026-01-21-packages-pane-implementation.md | 2099 +++++++++++++++++ 1 file changed, 2099 insertions(+) create mode 100644 docs/plans/2026-01-21-packages-pane-implementation.md 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 From 6d3eff5fd3162c80cedf75e604df96e1c419dbae Mon Sep 17 00:00:00 2001 From: infktd <25016104+infktd@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:13:57 -0600 Subject: [PATCH 03/22] 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 --- internal/packages/types.go | 18 ++++++++++++++++++ internal/packages/types_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 internal/packages/types.go create mode 100644 internal/packages/types_test.go diff --git a/internal/packages/types.go b/internal/packages/types.go new file mode 100644 index 0000000..75a028d --- /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 + Packages []Package + LastScanned time.Time +} diff --git a/internal/packages/types_test.go b/internal/packages/types_test.go new file mode 100644 index 0000000..39a4a28 --- /dev/null +++ b/internal/packages/types_test.go @@ -0,0 +1,32 @@ +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)) + } +} From b11b84915d5befedce3cfa20197155121763fe60 Mon Sep 17 00:00:00 2001 From: infktd <25016104+infktd@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:16:23 -0600 Subject: [PATCH 04/22] test(packages): improve test coverage for Package types - Convert tests to table-driven format for better idiomatic Go - Add assertions for ALL fields in Package struct (Type, Binary) - Add assertions for ALL fields in PackageInfo struct (LastScanned) - Add test case for Python package to demonstrate flexibility - Add test case for multiple packages in PackageInfo - Add inline comments to PackageInfo fields for consistency Co-Authored-By: Claude Sonnet 4.5 --- internal/packages/types.go | 6 +- internal/packages/types_test.go | 131 ++++++++++++++++++++++++++++---- 2 files changed, 119 insertions(+), 18 deletions(-) diff --git a/internal/packages/types.go b/internal/packages/types.go index 75a028d..37b885f 100644 --- a/internal/packages/types.go +++ b/internal/packages/types.go @@ -12,7 +12,7 @@ type Package struct { // PackageInfo holds package information for a project. type PackageInfo struct { - ProjectPath string - Packages []Package - LastScanned time.Time + 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 index 39a4a28..fce9df4 100644 --- a/internal/packages/types_test.go +++ b/internal/packages/types_test.go @@ -2,31 +2,132 @@ package packages import ( "testing" + "time" ) 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", + tests := []struct { + name string + pkg Package + wantPkg Package + }{ + { + name: "Go package", + pkg: Package{ + Name: "go", + Version: "1.21.5", + Type: "Go", + Binary: "/nix/store/abc-go-1.21.5/bin/go", + }, + wantPkg: Package{ + Name: "go", + Version: "1.21.5", + Type: "Go", + Binary: "/nix/store/abc-go-1.21.5/bin/go", + }, + }, + { + name: "Python package", + pkg: Package{ + Name: "python3", + Version: "3.11.7", + Type: "Python", + Binary: "/usr/bin/python3", + }, + wantPkg: Package{ + Name: "python3", + Version: "3.11.7", + Type: "Python", + Binary: "/usr/bin/python3", + }, + }, } - 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) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.pkg.Name != tt.wantPkg.Name { + t.Errorf("Name: got %q, want %q", tt.pkg.Name, tt.wantPkg.Name) + } + if tt.pkg.Version != tt.wantPkg.Version { + t.Errorf("Version: got %q, want %q", tt.pkg.Version, tt.wantPkg.Version) + } + if tt.pkg.Type != tt.wantPkg.Type { + t.Errorf("Type: got %q, want %q", tt.pkg.Type, tt.wantPkg.Type) + } + if tt.pkg.Binary != tt.wantPkg.Binary { + t.Errorf("Binary: got %q, want %q", tt.pkg.Binary, tt.wantPkg.Binary) + } + }) } } func TestPackageInfoStructure(t *testing.T) { - info := PackageInfo{ - ProjectPath: "/home/user/project", - Packages: []Package{{Name: "go"}}, + now := time.Now() + tests := []struct { + name string + info PackageInfo + wantInfo PackageInfo + }{ + { + name: "single package", + info: PackageInfo{ + ProjectPath: "/home/user/project", + Packages: []Package{{Name: "go", Version: "1.21.5", Type: "Go", Binary: "/usr/bin/go"}}, + LastScanned: now, + }, + wantInfo: PackageInfo{ + ProjectPath: "/home/user/project", + Packages: []Package{{Name: "go", Version: "1.21.5", Type: "Go", Binary: "/usr/bin/go"}}, + LastScanned: now, + }, + }, + { + name: "multiple packages", + info: PackageInfo{ + ProjectPath: "/home/user/multi-project", + Packages: []Package{ + {Name: "go", Version: "1.21.5", Type: "Go", Binary: "/usr/bin/go"}, + {Name: "python3", Version: "3.11.7", Type: "Python", Binary: "/usr/bin/python3"}, + }, + LastScanned: now, + }, + wantInfo: PackageInfo{ + ProjectPath: "/home/user/multi-project", + Packages: []Package{ + {Name: "go", Version: "1.21.5", Type: "Go", Binary: "/usr/bin/go"}, + {Name: "python3", Version: "3.11.7", Type: "Python", Binary: "/usr/bin/python3"}, + }, + LastScanned: now, + }, + }, } - if len(info.Packages) != 1 { - t.Errorf("expected 1 package, got %d", len(info.Packages)) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.info.ProjectPath != tt.wantInfo.ProjectPath { + t.Errorf("ProjectPath: got %q, want %q", tt.info.ProjectPath, tt.wantInfo.ProjectPath) + } + if len(tt.info.Packages) != len(tt.wantInfo.Packages) { + t.Errorf("Packages length: got %d, want %d", len(tt.info.Packages), len(tt.wantInfo.Packages)) + } + for i, pkg := range tt.info.Packages { + wantPkg := tt.wantInfo.Packages[i] + if pkg.Name != wantPkg.Name { + t.Errorf("Package[%d].Name: got %q, want %q", i, pkg.Name, wantPkg.Name) + } + if pkg.Version != wantPkg.Version { + t.Errorf("Package[%d].Version: got %q, want %q", i, pkg.Version, wantPkg.Version) + } + if pkg.Type != wantPkg.Type { + t.Errorf("Package[%d].Type: got %q, want %q", i, pkg.Type, wantPkg.Type) + } + if pkg.Binary != wantPkg.Binary { + t.Errorf("Package[%d].Binary: got %q, want %q", i, pkg.Binary, wantPkg.Binary) + } + } + if !tt.info.LastScanned.Equal(tt.wantInfo.LastScanned) { + t.Errorf("LastScanned: got %v, want %v", tt.info.LastScanned, tt.wantInfo.LastScanned) + } + }) } } From 6e0c3e26eb3832d6118f9e78672919fce4ab4918 Mon Sep 17 00:00:00 2001 From: infktd <25016104+infktd@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:18:56 -0600 Subject: [PATCH 05/22] 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 --- internal/packages/scanner.go | 51 +++++++++++++++++++++++++++++++ internal/packages/types_test.go | 54 +++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 internal/packages/scanner.go diff --git a/internal/packages/scanner.go b/internal/packages/scanner.go new file mode 100644 index 0000000..612b40b --- /dev/null +++ b/internal/packages/scanner.go @@ -0,0 +1,51 @@ +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 +} diff --git a/internal/packages/types_test.go b/internal/packages/types_test.go index fce9df4..dc60550 100644 --- a/internal/packages/types_test.go +++ b/internal/packages/types_test.go @@ -131,3 +131,57 @@ func TestPackageInfoStructure(t *testing.T) { }) } } + +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) + } + }) + } +} From 384fa62639fe2903572a1032327038247f342962 Mon Sep 17 00:00:00 2001 From: infktd <25016104+infktd@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:21:24 -0600 Subject: [PATCH 06/22] refactor(packages): improve Nix store path parser robustness - Move regex compilation to package-level variable for performance - Improve regex pattern to handle hyphenated package names correctly - Add comprehensive test cases for edge cases (hyphenated names, invalid paths, empty strings) - Rename dashIdx to firstDashIndex for clarity - Remove redundant len(matches) > 3 check Co-Authored-By: Claude Sonnet 4.5 --- internal/packages/scanner.go | 22 ++++++------ internal/packages/types_test.go | 63 +++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/internal/packages/scanner.go b/internal/packages/scanner.go index 612b40b..513d0f6 100644 --- a/internal/packages/scanner.go +++ b/internal/packages/scanner.go @@ -6,6 +6,13 @@ import ( "strings" ) +// nixStoreRegex parses Nix store path components. +// Pattern explanation: +// - ^([a-z0-9]+) - matches the hash at the start +// - -(.+?) - matches the package name (non-greedy to allow backtracking) +// - (?:-([0-9]+[0-9.\-a-z]*))? - optionally matches version starting with a digit +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) @@ -27,25 +34,20 @@ func parseNixStorePath(path string) (string, string, string) { } // Parse: -- - // Regex: ()-()-() where version is optional - re := regexp.MustCompile(`^([a-z0-9]+)-(.+?)(?:-([0-9].*))?$`) - matches := re.FindStringSubmatch(storeComponent) + matches := nixStoreRegex.FindStringSubmatch(storeComponent) if len(matches) < 3 { // Fallback: just hash-name format - dashIdx := strings.Index(storeComponent, "-") - if dashIdx > 0 { - return storeComponent[dashIdx+1:], "", storeComponent[:dashIdx] + firstDashIndex := strings.Index(storeComponent, "-") + if firstDashIndex > 0 { + return storeComponent[firstDashIndex+1:], "", storeComponent[:firstDashIndex] } return storeComponent, "", "" } hash := matches[1] name := matches[2] - version := "" - if len(matches) > 3 { - version = matches[3] - } + version := matches[3] // Will be empty string if group didn't match return name, version, hash } diff --git a/internal/packages/types_test.go b/internal/packages/types_test.go index dc60550..5c7882e 100644 --- a/internal/packages/types_test.go +++ b/internal/packages/types_test.go @@ -168,6 +168,69 @@ func TestParseNixStorePath(t *testing.T) { 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", + }, } for _, tt := range tests { From d6724408fea23ac9327c423dfcd22fe61f91cb2a Mon Sep 17 00:00:00 2001 From: infktd <25016104+infktd@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:31:20 -0600 Subject: [PATCH 07/22] 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 --- internal/packages/scanner.go | 62 +++++++++++++++++++++++++++++++++ internal/packages/types_test.go | 33 ++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/internal/packages/scanner.go b/internal/packages/scanner.go index 513d0f6..c230e57 100644 --- a/internal/packages/scanner.go +++ b/internal/packages/scanner.go @@ -51,3 +51,65 @@ func parseNixStorePath(path string) (string, string, string) { return name, version, hash } + +// 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" +} diff --git a/internal/packages/types_test.go b/internal/packages/types_test.go index 5c7882e..290f8d4 100644 --- a/internal/packages/types_test.go +++ b/internal/packages/types_test.go @@ -248,3 +248,36 @@ func TestParseNixStorePath(t *testing.T) { }) } } + +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) + } + }) + } +} From 60e42da1dbd20f6f2384bdf9d0b5cb30837fb1dc Mon Sep 17 00:00:00 2001 From: infktd <25016104+infktd@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:37:12 -0600 Subject: [PATCH 08/22] refactor(packages): improve categorization robustness and test coverage Key improvements: - Fix categorization logic to use exact matches instead of prefix/contains checks - Prevents false positives (golang, go-tools, google-chrome no longer match "go") - Add comprehensive edge case tests for case sensitivity and prefix matching - Add tests with real-world package names (python-dotenv, node-fetch, etc.) - Add explicit tests for regex fallback logic in parseNixStorePath - Improve documentation with detailed godoc examples and regex pattern explanation - Remove TestPackageStructure and TestPackageInfoStructure (no logic testing) - Expand toolchain recognition (add pip2/pip3, pnpm, rustfmt, cmake, mvn, etc.) All 72+ test cases pass successfully. Co-Authored-By: Claude Sonnet 4.5 --- internal/packages/scanner.go | 67 +++++++--- internal/packages/types_test.go | 227 +++++++++++++------------------- 2 files changed, 143 insertions(+), 151 deletions(-) diff --git a/internal/packages/scanner.go b/internal/packages/scanner.go index c230e57..b1021d5 100644 --- a/internal/packages/scanner.go +++ b/internal/packages/scanner.go @@ -8,9 +8,17 @@ import ( // nixStoreRegex parses Nix store path components. // Pattern explanation: -// - ^([a-z0-9]+) - matches the hash at the start -// - -(.+?) - matches the package name (non-greedy to allow backtracking) -// - (?:-([0-9]+[0-9.\-a-z]*))? - optionally matches version starting with a digit +// - ^([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. @@ -53,60 +61,83 @@ func parseNixStorePath(path string) (string, string, string) { } // 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 - if strings.HasPrefix(lowerName, "go") || + // Go packages - exact match for "go" + known Go toolchain binaries + if lowerName == "go" || lowerName == "gopls" || lowerName == "gofmt" || - lowerName == "godoc" { + lowerName == "godoc" || + lowerName == "goimports" || + lowerName == "golangci-lint" { return "Go" } - // Python packages - if strings.Contains(lowerName, "python") || + // 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 == "poetry" || + lowerName == "pipenv" { return "Python" } - // Node.js packages + // Node.js packages - exact matches for Node ecosystem tools if lowerName == "node" || lowerName == "nodejs" || lowerName == "npm" || lowerName == "npx" || - lowerName == "yarn" { + lowerName == "yarn" || + lowerName == "pnpm" { return "Node.js" } - // Rust packages + // Rust packages - exact matches for Rust toolchain if lowerName == "cargo" || lowerName == "rustc" || - lowerName == "rustup" { + lowerName == "rustup" || + lowerName == "rustfmt" { return "Rust" } - // C/C++ compilers + // C/C++ compilers - exact matches if lowerName == "gcc" || lowerName == "g++" || lowerName == "clang" || - lowerName == "clang++" { + lowerName == "clang++" || + lowerName == "make" || + lowerName == "cmake" { return "C/C++" } - // Ruby + // Ruby - exact matches if lowerName == "ruby" || lowerName == "gem" || - lowerName == "bundle" { + lowerName == "bundle" || + lowerName == "bundler" || + lowerName == "rake" { return "Ruby" } - // Java + // Java - exact matches if lowerName == "java" || lowerName == "javac" || lowerName == "maven" || + lowerName == "mvn" || lowerName == "gradle" { return "Java" } diff --git a/internal/packages/types_test.go b/internal/packages/types_test.go index 290f8d4..84905b9 100644 --- a/internal/packages/types_test.go +++ b/internal/packages/types_test.go @@ -2,136 +2,8 @@ package packages import ( "testing" - "time" ) -func TestPackageStructure(t *testing.T) { - tests := []struct { - name string - pkg Package - wantPkg Package - }{ - { - name: "Go package", - pkg: Package{ - Name: "go", - Version: "1.21.5", - Type: "Go", - Binary: "/nix/store/abc-go-1.21.5/bin/go", - }, - wantPkg: Package{ - Name: "go", - Version: "1.21.5", - Type: "Go", - Binary: "/nix/store/abc-go-1.21.5/bin/go", - }, - }, - { - name: "Python package", - pkg: Package{ - Name: "python3", - Version: "3.11.7", - Type: "Python", - Binary: "/usr/bin/python3", - }, - wantPkg: Package{ - Name: "python3", - Version: "3.11.7", - Type: "Python", - Binary: "/usr/bin/python3", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.pkg.Name != tt.wantPkg.Name { - t.Errorf("Name: got %q, want %q", tt.pkg.Name, tt.wantPkg.Name) - } - if tt.pkg.Version != tt.wantPkg.Version { - t.Errorf("Version: got %q, want %q", tt.pkg.Version, tt.wantPkg.Version) - } - if tt.pkg.Type != tt.wantPkg.Type { - t.Errorf("Type: got %q, want %q", tt.pkg.Type, tt.wantPkg.Type) - } - if tt.pkg.Binary != tt.wantPkg.Binary { - t.Errorf("Binary: got %q, want %q", tt.pkg.Binary, tt.wantPkg.Binary) - } - }) - } -} - -func TestPackageInfoStructure(t *testing.T) { - now := time.Now() - tests := []struct { - name string - info PackageInfo - wantInfo PackageInfo - }{ - { - name: "single package", - info: PackageInfo{ - ProjectPath: "/home/user/project", - Packages: []Package{{Name: "go", Version: "1.21.5", Type: "Go", Binary: "/usr/bin/go"}}, - LastScanned: now, - }, - wantInfo: PackageInfo{ - ProjectPath: "/home/user/project", - Packages: []Package{{Name: "go", Version: "1.21.5", Type: "Go", Binary: "/usr/bin/go"}}, - LastScanned: now, - }, - }, - { - name: "multiple packages", - info: PackageInfo{ - ProjectPath: "/home/user/multi-project", - Packages: []Package{ - {Name: "go", Version: "1.21.5", Type: "Go", Binary: "/usr/bin/go"}, - {Name: "python3", Version: "3.11.7", Type: "Python", Binary: "/usr/bin/python3"}, - }, - LastScanned: now, - }, - wantInfo: PackageInfo{ - ProjectPath: "/home/user/multi-project", - Packages: []Package{ - {Name: "go", Version: "1.21.5", Type: "Go", Binary: "/usr/bin/go"}, - {Name: "python3", Version: "3.11.7", Type: "Python", Binary: "/usr/bin/python3"}, - }, - LastScanned: now, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.info.ProjectPath != tt.wantInfo.ProjectPath { - t.Errorf("ProjectPath: got %q, want %q", tt.info.ProjectPath, tt.wantInfo.ProjectPath) - } - if len(tt.info.Packages) != len(tt.wantInfo.Packages) { - t.Errorf("Packages length: got %d, want %d", len(tt.info.Packages), len(tt.wantInfo.Packages)) - } - for i, pkg := range tt.info.Packages { - wantPkg := tt.wantInfo.Packages[i] - if pkg.Name != wantPkg.Name { - t.Errorf("Package[%d].Name: got %q, want %q", i, pkg.Name, wantPkg.Name) - } - if pkg.Version != wantPkg.Version { - t.Errorf("Package[%d].Version: got %q, want %q", i, pkg.Version, wantPkg.Version) - } - if pkg.Type != wantPkg.Type { - t.Errorf("Package[%d].Type: got %q, want %q", i, pkg.Type, wantPkg.Type) - } - if pkg.Binary != wantPkg.Binary { - t.Errorf("Package[%d].Binary: got %q, want %q", i, pkg.Binary, wantPkg.Binary) - } - } - if !tt.info.LastScanned.Equal(tt.wantInfo.LastScanned) { - t.Errorf("LastScanned: got %v, want %v", tt.info.LastScanned, tt.wantInfo.LastScanned) - } - }) - } -} - func TestParseNixStorePath(t *testing.T) { tests := []struct { name string @@ -231,6 +103,28 @@ func TestParseNixStorePath(t *testing.T) { 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 { @@ -255,28 +149,95 @@ func TestCategorizePackage(t *testing.T) { packageName string expectedType string }{ - {"go", "go", "Go"}, + // Go toolchain + {"go_lowercase", "go", "Go"}, + {"go_uppercase", "GO", "Go"}, + {"go_mixedcase", "Go", "Go"}, {"gofmt", "gofmt", "Go"}, {"gopls", "gopls", "Go"}, - {"python3", "python3", "Python"}, + {"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"}, - {"npm", "npm", "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++"}, - {"unknown", "foobar", "Other"}, + {"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("expected type %q for %q, got %q", tt.expectedType, tt.packageName, typ) + t.Errorf("categorizePackage(%q) = %q, want %q", tt.packageName, typ, tt.expectedType) } }) } From 945d0d11f4c944cf3ffc7d789690aa9ff44f3880 Mon Sep 17 00:00:00 2001 From: infktd <25016104+infktd@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:39:48 -0600 Subject: [PATCH 09/22] 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 --- internal/packages/scanner.go | 74 +++++++++++++++++++++++++++++++++ internal/packages/types_test.go | 45 ++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/internal/packages/scanner.go b/internal/packages/scanner.go index b1021d5..5e59d96 100644 --- a/internal/packages/scanner.go +++ b/internal/packages/scanner.go @@ -1,6 +1,8 @@ package packages import ( + "io/fs" + "os" "path/filepath" "regexp" "strings" @@ -144,3 +146,75 @@ func categorizePackage(name string) string { 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+name+version belong to the same package. +func groupByPackage(binaries []binaryInfo) []Package { + // Stub for now - will be implemented in Task 5 + return []Package{} +} diff --git a/internal/packages/types_test.go b/internal/packages/types_test.go index 84905b9..3407c2f 100644 --- a/internal/packages/types_test.go +++ b/internal/packages/types_test.go @@ -1,6 +1,8 @@ package packages import ( + "os" + "path/filepath" "testing" ) @@ -242,3 +244,46 @@ func TestCategorizePackage(t *testing.T) { }) } } + +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)) + } +} From 680925d4f6cd5408ee6805b6bab001de7be3e226 Mon Sep 17 00:00:00 2001 From: infktd <25016104+infktd@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:40:51 -0600 Subject: [PATCH 10/22] feat(packages): add package grouping logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group binaries from same Nix package: - Use hash+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 --- internal/packages/scanner.go | 34 +++++++++++++++++++++++++++--- internal/packages/types_test.go | 37 +++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/internal/packages/scanner.go b/internal/packages/scanner.go index 5e59d96..4adfb84 100644 --- a/internal/packages/scanner.go +++ b/internal/packages/scanner.go @@ -213,8 +213,36 @@ func Scan(projectPath string) ([]Package, error) { } // groupByPackage groups binaries from the same Nix package. -// Binaries with the same hash+name+version belong to the same package. +// Binaries with the same hash+version belong to the same package. func groupByPackage(binaries []binaryInfo) []Package { - // Stub for now - will be implemented in Task 5 - return []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_test.go b/internal/packages/types_test.go index 3407c2f..65b2c0f 100644 --- a/internal/packages/types_test.go +++ b/internal/packages/types_test.go @@ -287,3 +287,40 @@ func TestScanMissingDirectory(t *testing.T) { 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) + } +} From b840a93719faf56422e7464384cf2cdc48fe2480 Mon Sep 17 00:00:00 2001 From: infktd <25016104+infktd@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:42:37 -0600 Subject: [PATCH 11/22] 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 --- internal/ui/packages.go | 147 +++++++++++++++++++++++++++++++++++ internal/ui/packages_test.go | 65 ++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 internal/ui/packages.go create mode 100644 internal/ui/packages_test.go diff --git a/internal/ui/packages.go b/internal/ui/packages.go new file mode 100644 index 0000000..a02ddd2 --- /dev/null +++ b/internal/ui/packages.go @@ -0,0 +1,147 @@ +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 +} + +// 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 with focus styling + title := fmt.Sprintf("PACKAGES (%d)", len(pv.packages)) + 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") + } +} From de7e3885ff4e3e021cb055dca8585e4b91fcd914 Mon Sep 17 00:00:00 2001 From: infktd <25016104+infktd@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:48:50 -0600 Subject: [PATCH 12/22] 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 --- internal/ui/model.go | 2 ++ internal/ui/model_test.go | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/internal/ui/model.go b/internal/ui/model.go index f7b1ac8..9dfe62c 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -103,6 +103,7 @@ type Model struct { // Components logView *LogView + packagesView *PackagesView toast *ToastManager alerts *AlertHistory alertsPanel *AlertsPanel @@ -267,6 +268,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), diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 70a1b54..da569ee 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -416,3 +416,13 @@ 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") + } +} From 15f2403c0def031c0f99044280e5dd08c2ea7a8a Mon Sep 17 00:00:00 2001 From: infktd <25016104+infktd@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:49:46 -0600 Subject: [PATCH 13/22] 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 --- internal/ui/model.go | 9 +++++++++ internal/ui/model_test.go | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/internal/ui/model.go b/internal/ui/model.go index 9dfe62c..abfed0b 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -1497,6 +1497,15 @@ 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 +} + // 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() { diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index da569ee..1ee4576 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -426,3 +426,26 @@ func TestModelPackagesViewInitialized(t *testing.T) { 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") + } +} From e87cb56a30de89da8ac413bbd85f0c36687ed7b7 Mon Sep 17 00:00:00 2001 From: infktd <25016104+infktd@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:50:42 -0600 Subject: [PATCH 14/22] 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 --- internal/ui/model.go | 7 +++++++ internal/ui/model_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/internal/ui/model.go b/internal/ui/model.go index abfed0b..31f542e 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -91,6 +91,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 @@ -1506,6 +1507,12 @@ 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() { diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 1ee4576..4b03242 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -449,3 +449,27 @@ func TestShouldShowBothPanes(t *testing.T) { 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") + } +} From 2547e1d3142e127a4be3ebd29cd16e9837c9c3a7 Mon Sep 17 00:00:00 2001 From: infktd <25016104+infktd@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:52:18 -0600 Subject: [PATCH 15/22] 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 --- internal/ui/model.go | 3 +++ internal/ui/model_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/internal/ui/model.go b/internal/ui/model.go index 31f542e..5eb858f 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -1161,6 +1161,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 { diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 4b03242..9a0c91d 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -473,3 +473,39 @@ func TestTogglePackagesView(t *testing.T) { 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 +} From 6b3f871473af3f277711449863bd992563639184 Mon Sep 17 00:00:00 2001 From: infktd <25016104+infktd@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:53:47 -0600 Subject: [PATCH 16/22] 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 --- internal/ui/model.go | 14 ++++++++++++++ internal/ui/model_test.go | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/internal/ui/model.go b/internal/ui/model.go index 5eb858f..5cf2bd8 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" ) @@ -1661,6 +1662,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 { diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 9a0c91d..90d08a9 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -509,3 +509,24 @@ func TestPressP_NoEffectOnWideTerminal(t *testing.T) { // 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") + } +} From 1da899fc9e4aaad206fd23251053c151f019fd6f Mon Sep 17 00:00:00 2001 From: infktd <25016104+infktd@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:58:22 -0600 Subject: [PATCH 17/22] 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 in middle - Logs pane on bottom - All three visible simultaneously - Add PanePackages to focus cycle - Update cycleFocus() to conditionally include packages Co-Authored-By: Claude Sonnet 4.5 --- internal/ui/model.go | 87 +++++++++++++++++++++++++++++++++++---- internal/ui/model_test.go | 20 +++++++++ 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/internal/ui/model.go b/internal/ui/model.go index 5cf2bd8..084474c 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -33,6 +33,7 @@ type FocusedPane int const ( PaneSidebar FocusedPane = iota PaneServices + PanePackages PaneLogs ) @@ -1642,11 +1643,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 @@ -1986,13 +2021,34 @@ 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 - services := m.renderServices(width, servicesHeight) - logs := m.renderLogs(width, logsHeight) + var mainPane string + if m.showPackages { + mainPane = m.renderPackages(width, mainPaneHeight) + } else { + mainPane = m.renderServices(width, mainPaneHeight) + } - return lipgloss.JoinVertical(lipgloss.Left, services, logs) + logs := m.renderLogs(width, logsHeight) + + return lipgloss.JoinVertical(lipgloss.Left, mainPane, logs) + } } func (m *Model) renderServices(width, height int) string { @@ -2055,6 +2111,23 @@ 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) + + // 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 90d08a9..1096df1 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -1,6 +1,7 @@ package ui import ( + "strings" "testing" tea "github.com/charmbracelet/bubbletea" @@ -530,3 +531,22 @@ func TestProjectSwitch_ScansPackages(t *testing.T) { 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") + } +} From 386a444c3aaea68aa5ddde789f9419922172e08a Mon Sep 17 00:00:00 2001 From: infktd <25016104+infktd@users.noreply.github.com> Date: Wed, 21 Jan 2026 15:00:05 -0600 Subject: [PATCH 18/22] 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 - Add SetTitleSuffix() to PackagesView - Update renderServices() and renderPackages() Co-Authored-By: Claude Sonnet 4.5 --- internal/ui/model.go | 18 ++++++++++++++++-- internal/ui/model_test.go | 23 +++++++++++++++++++++++ internal/ui/packages.go | 19 ++++++++++++++----- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/internal/ui/model.go b/internal/ui/model.go index 084474c..4e43bfc 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -2055,8 +2055,11 @@ 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 { @@ -2087,7 +2090,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", @@ -2116,6 +2123,13 @@ func (m *Model) renderPackages(width, height int) string { 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() diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 1096df1..cf15846 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -550,3 +550,26 @@ func TestView_WideTerminal_ShowsBothPanes(t *testing.T) { 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") + } +} diff --git a/internal/ui/packages.go b/internal/ui/packages.go index a02ddd2..5c71207 100644 --- a/internal/ui/packages.go +++ b/internal/ui/packages.go @@ -9,11 +9,12 @@ import ( // PackagesView displays installed packages for a project. type PackagesView struct { - styles *Styles - packages []packages.Package - width int - height int - focused bool + 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. @@ -42,6 +43,11 @@ 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 { @@ -50,6 +56,9 @@ func (pv *PackagesView) View() string { // 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(). From 28a72a72d7e58d91d616e967853491111886ac2a Mon Sep 17 00:00:00 2001 From: infktd <25016104+infktd@users.noreply.github.com> Date: Wed, 21 Jan 2026 15:00:56 -0600 Subject: [PATCH 19/22] 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 --- internal/ui/model_test.go | 73 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index cf15846..e2d642d 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -1,6 +1,8 @@ package ui import ( + "os" + "path/filepath" "strings" "testing" @@ -573,3 +575,74 @@ func TestView_NarrowTerminal_ShowsIndicator(t *testing.T) { t.Error("narrow terminal showing packages should contain '[p:services]' indicator") } } + +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") + } +} From 25d8a6b0654196ccd8a8db0975cbd5e5d48cf523 Mon Sep 17 00:00:00 2001 From: infktd <25016104+infktd@users.noreply.github.com> Date: Wed, 21 Jan 2026 15:02:11 -0600 Subject: [PATCH 20/22] 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 - Calculate correct pane boundaries Co-Authored-By: Claude Sonnet 4.5 --- internal/ui/model.go | 25 ++++++++++++++++++++++--- internal/ui/model_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/internal/ui/model.go b/internal/ui/model.go index 4e43bfc..8dfc4ca 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -324,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 @@ -335,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 } diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index e2d642d..f8bd080 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -576,6 +576,33 @@ func TestView_NarrowTerminal_ShowsIndicator(t *testing.T) { } } +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() From 29fe1bea4a5791868a00cccb9573231fd4fc7279 Mon Sep 17 00:00:00 2001 From: infktd <25016104+infktd@users.noreply.github.com> Date: Wed, 21 Jan 2026 15:06:12 -0600 Subject: [PATCH 21/22] chore: final cleanup and testing for packages pane All tests passing: - 85+ test cases - Coverage: 43.1% - Binary builds successfully - No flaky tests (3 consecutive runs) 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 From fbe93d1a72a2f495e81bdf3fb7e8bfa83b2d2585 Mon Sep 17 00:00:00 2001 From: infktd <25016104+infktd@users.noreply.github.com> Date: Wed, 21 Jan 2026 15:07:16 -0600 Subject: [PATCH 22/22] docs: add packages pane documentation Update help text with 'p' key: - Toggle between packages and services view - Shows in navigation section - Applies to narrow terminals Add comprehensive user documentation: - Features and layout modes - Package information displayed - Keyboard shortcuts - Project type detection - Technical details and examples - Supported package types Co-Authored-By: Claude Sonnet 4.5 --- docs/PACKAGES_PANE.md | 133 ++++++++++++++++++++++++++++++++++++++++++ internal/ui/help.go | 3 +- 2 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 docs/PACKAGES_PANE.md 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/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"