diff --git a/.agents/skills/devpt-release/SKILL.md b/.agents/skills/devpt-release/SKILL.md new file mode 100644 index 0000000..46c0e68 --- /dev/null +++ b/.agents/skills/devpt-release/SKILL.md @@ -0,0 +1,68 @@ +--- +name: devpt-release +description: Increment version and update CHANGELOG.md from commits since last update. Use when making a release, bumping version, or updating changelog for dev-process-tracker. +--- + +# DevPT Release Skill + +## Usage + +``` + or "bump minor version" or "devpt release major" +``` + +## Workflow + +1. **Read CHANGELOG.md** — extract current version from first `## X.Y.Z` header +2. **Find last update** — get SHA of the commit that last modified CHANGELOG.md +3. **Get commits since** — `git log ..HEAD --oneline --no-merges` +4. **Group & classify**: + - Parse commit messages for intent (add/fix/change/remove/refactor/docs) + - **Group related commits**: if a "fix" or "polish" follows a feature in time/subject, fold it into that feature line + - Prioritize user-facing changes over internal polish +5. **Determine bump**: + - `major` (0.x → 1.0 or breaking) / `minor` (features) / `patch` (fixes) — use user-specified if provided +6. **Generate entries** — write concise imperative-mood bullets: + - "Added X so Y" for features + - "Fixed Z so W" for bugs + - Group related fixes with their feature when they're clearly connected +7. **Update CHANGELOG.md** — prepend new version section +8. **Set version** — run `./scripts/set-version.sh ` to update version.go, commit, and tag +9. **Push** — `git push && git push origin v` + +## Version Management + +- **Version file**: `pkg/buildinfo/version.go` (`const Version = "X.Y.Z"`) +- **Set version script**: `./scripts/set-version.sh ` — updates version.go, commits, creates tag +- **Tags use `v` prefix**: `v0.2.1` +- **Pre-push hook**: validates version.go matches latest tag (via lefthook) + +## Grouping Heuristics + +When classifying commits, apply these rules: + +1. **Time proximity**: Fixes within 1-3 commits of a feature likely belong to it +2. **Subject overlap**: "fix search" after "add search input" → same entry +3. **Keyword clues**: "polish", "tweak", "adjust", "follow-up" often indicate related work +4. **When uncertain**: Keep separate rather than over-grouping + +## Flags + +- `--review` — show grouped commits and proposed entries before writing +- `--dry-run` — output the new section without modifying the file + +## Example Output + +```markdown +## 0.3.0 + +- Added dark mode toggle so users can switch themes without reloading +- Fixed theme persistence so preference survives across sessions +- Removed deprecated `/legacy` endpoint +``` + +## Edge Cases + +- **No commits since last update**: Report "no changes since last release" and exit +- **Uncommitted changes**: Warn but proceed (commits are the source of truth) +- **Version is 0.x**: Treat as pre-release; minor bumps for features, patch for fixes diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a1b6731..f88d0d5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -140,6 +140,25 @@ Cache can be invalidated selectively. Important for performance (lsof calls are - Exit conditions: user presses 'q', or explicit quit() command - Key handlers prioritized: modal state (logs/input) takes precedence over list navigation +## Before Submitting Changes + +Always run these checks before considering work complete: + +```bash +# 1. Build succeeds +go build ./... + +# 2. All tests pass +go test ./... + +# 3. CLI runs without error +go build -o devpt ./cmd/devpt && ./devpt ls +``` + +If adding user-facing features, also update README.md and QUICKSTART.md. + +## Common Tasks + ## Common Tasks ### Add a New CLI Command @@ -175,6 +194,7 @@ Cache can be invalidated selectively. Important for performance (lsof calls are - **QUICKSTART.md** - Getting started guide for new users - **IMPLEMENTATION_SUMMARY.md** - Architecture and feature overview (reference only) - **techspec.md** - Original technical specification +- **.agents/skills/devpt-release/SKILL.md** - Release workflow (changelog + version bump) Update README and QUICKSTART when adding user-facing features or commands. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..66f4bde --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,46 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Build binaries + run: | + mkdir -p dist + + # Linux + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o dist/devpt-linux-x64 ./cmd/devpt + GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-s -w" -o dist/devpt-linux-arm64 ./cmd/devpt + + # macOS + GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o dist/devpt-macos-x64 ./cmd/devpt + GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-s -w" -o dist/devpt-macos-arm64 ./cmd/devpt + + # Windows + GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o dist/devpt-windows-x64.exe ./cmd/devpt + + - name: Generate checksums + run: | + cd dist + sha256sum * > checksums.txt + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: true diff --git a/.gitignore b/.gitignore index 542d28e..64feca1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,26 @@ /.tmp-home/ /.tmp-home*/ - # Local draft/working docs -/docs \ No newline at end of file +/docs +/coverage.out + +# Go +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +go.work +vendor/ + +# Coverage +*.coverprofile +coverage.html + +# Test fixture binaries (no extension on macOS) +/sandbox/servers/*/go-basic +/sandbox/servers/*/*/node +/sandbox/servers/*/*/server.js diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..30f7bd3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +@.github/copilot-instructions.md + +@DEBUG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4e8ef30 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +## 0.3.0 + +- Added a managed-services split view in the TUI so selection and navigation stay clear when browsing running and registered services +- Fixed TUI selection behavior so focus, row targeting, and split-pane navigation stay aligned while moving between running and managed services + +## 0.2.2 + +- Added a Shift+S sort direction toggle in the TUI so sort order can be reversed without changing the active column +- Fixed managed service PID validation so stop and restart only act on processes that still match the registered service +- Fixed cross-platform builds by separating Unix and Windows process control paths + +## 0.2.1 + +- Added table sorting controls with mouse support and reverse sort in the TUI + +## 0.2.0 + +- Added multi-service `start`, `stop`, and `restart` commands with quoted glob pattern support so multiple managed services can be controlled in one invocation +- Added `name:port` targeting for managed services so ambiguous service names can be disambiguated from the CLI +- Extracted the Bubble Tea UI into `pkg/cli/tui` so the TUI logic is isolated from the main CLI package +- Added mouse row selection, mouse wheel scrolling, and viewport-focused navigation so table and log interaction works without keyboard-only control +- Added centered modal overlays for help and confirmation dialogs so help and destructive actions no longer replace the main table view +- Replaced the ad hoc search field with Bubbles text input so filter editing behaves like a real input control and updates inline in the footer +- Simplified the table chrome by moving counts into headers, bolding the active sort column, and removing redundant status text from the top of the screen +- Fixed `Enter` handling so the top section opens logs and the bottom section starts the selected managed service without being swallowed by confirm bindings +- Fixed log rendering so the header is separated from the first log line and the viewport uses the actual remaining terminal height +- Fixed stale table layout offsets so footer spacing, viewport sizing, and mouse hit-testing stay aligned after the filter moved into the footer +- Added shared keymap-driven help text with Bubble components so visible shortcuts and actual bindings stay in sync +- Added clearer TUI and quickstart documentation so the current footer filter, modal help, mouse controls, batch commands, and logs header behavior are documented +- Bumped the application version to `0.2.0` and rendered the version in the TUI header in muted gray diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..80a633c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,2 @@ +@AGENTS.md +@.github/copilot-instructions.md \ No newline at end of file diff --git a/DEBUG.md b/DEBUG.md new file mode 100644 index 0000000..00e0a6d --- /dev/null +++ b/DEBUG.md @@ -0,0 +1,189 @@ +# DevPortTrack Debug Protocol + +> Runtime coverage index: 2 runtimes (devpt-cli, sandbox fixtures) + +--- + +## Runtime: `devpt-cli` + +| Field | Value | +|------------|--------------------------------------------| +| `id` | devpt-cli | +| `class` | backend / CLI | +| `entry` | `cmd/devpt/main.go` | +| `owner` | root | +| `observe` | stdout/stderr, `~/.config/devpt/logs/` | +| `control` | `./devpt {start\|stop\|restart} ` | +| `inject` | `go run ./cmd/devpt` | +| `rollout` | `go build && ./devpt ` | +| `test` | `go test ./...` | + +--- + +### devpt-cli / OBSERVE / VERIFIED + +- Action: `./devpt ls` +- Signal: Tabular output showing Name, Port, PID, Project, Source, Status +- Constraints: Requires `lsof` and `ps` system utilities (macOS only) + +### devpt-cli / CONTROL / VERIFIED + +- Action: + ```bash + ./devpt add test-svc /path/to/cwd "command" 3400 + ./devpt start test-svc + ./devpt stop test-svc + ./devpt restart test-svc + ./devpt start 'test-*' + ./devpt stop test-svc:3400 + ``` +- Signal: + - `start`: start/status lines for each targeted service + - `stop`: stop/status lines for each targeted service + - `restart`: restart/status lines for each targeted service +- Constraints: + - Registry stored at `~/.config/devpt/registry.json` + - Logs written to `~/.config/devpt/logs//.log` + - Processes spawn in separate process groups (setpgid) + - Quote glob patterns to avoid shell expansion before `devpt` sees them + - `name:port` can be used to target a specific managed service identifier + +### devpt-cli / ROLLOUT / VERIFIED + +- Action: Build and verify version output +- Signal: `devpt version 0.2.2` (via `./devpt --version`) +- Constraints: No hot reload; requires full rebuild +- See: `.github/copilot-instructions.md` → Quick Reference for build commands + +### devpt-cli / TEST / VERIFIED + +- Action: Run test suite +- Signal: `ok` for each package; coverage 39.3% (cli), 59.1% (tui) +- Constraints: Tests in `pkg/cli/*_test.go`, `pkg/cli/tui/*_test.go`, `pkg/process/*_test.go` + - `tui_state_test.go`: Model state transitions (5 tests) + - `tui_ui_test.go`: UI rendering verification (23 tests, 51 subtests) + - `tui_key_input_test.go`: Key input handling + - `tui_viewport_test.go`: Viewport scrolling tests + - `app_batch_test.go`: Batch operations + - `app_matching_test.go`: Pattern matching + - `command_validation_test.go`: Command validation + - `manager_parse_test.go`: Process command parsing (2 tests) +- See: `.github/copilot-instructions.md` → Testing section for commands + +### devpt-cli / TEST / UI VERIFICATION + +- Action: Run UI rendering tests +- Signal: `PASS` for all 23 tests covering: + - Escape sequences (screen clear, ANSI codes) + - Layout structure (table headers, columns, dividers, footer-based filter state) + - Responsive design (widths 40-200 chars, heights 10-100 lines) + - All view modes (table, logs, command, search, help, confirm) + - Footer content (keybindings, live filter rendering, status) +- Constraints: + - Tests verify rendered content, not specific ANSI colors + - Footer assertions tolerate wrapping + - No external deps beyond `testify/assert` + - Focused command for current UI work: `go test -mod=mod ./pkg/cli/tui ./pkg/cli` + +### devpt-cli / OBSERVE / TUI INTERACTIONS / VERIFIED + +- Action: `./devpt` +- Signal: + - top table shows running services + - lower section shows `Managed Services ()` + - `/` activates inline footer filter editing + - `?` opens a centered help modal + - logs view header is `Logs: | Port: | PID: ` +- Constraints: + - mouse click selects rows + - mouse wheel and page keys scroll the active viewport + - help and confirmation dialogs are overlay modals, not separate screens + +### devpt-cli / INJECT / VERIFIED + +- Action: `go run ./cmd/devpt ` +- Signal: Immediate execution without explicit build step +- Constraints: Slower than compiled binary + +### devpt-cli / EGRESS / N/A + +- Rationale: CLI outputs directly to stdout/stderr; no sandboxed context + +### devpt-cli / STATE / VERIFIED + +- Action: + ```bash + # Add managed service to registry + ./devpt add my-app /path/to/project "npm run dev" 3000 + + # Verify registry state + cat ~/.config/devpt/registry.json | jq '.services["my-app"]' + ``` +- Signal: JSON entry created in registry with name, cwd, command, ports, timestamps +- Constraints: Registry is file-based JSON; thread-safe via RWMutex + +--- + +## Runtime: `sandbox/servers/*` (Test Fixtures) + +| Field | Value | +|------------|-----------------------------------------------------------------------------| +| `id` | go-basic, node-basic, node-crash, node-warnings, node-port-fallback, python-basic | +| `class` | test fixtures | +| `entry` | `sandbox/servers//main.go` or `server.js` or `dev.js` | +| `owner` | devpt-cli (managed) | +| `observe` | `~/.config/devpt/logs//*.log` | +| `control` | Via devpt-cli: `./devpt {start\|stop} ` | +| `inject` | `go run .` (Go) or `node server.js` (Node) | +| `rollout` | Rebuild + restart via devpt | +| `test` | No dedicated tests (fixtures for manual testing) | + +### go-basic / OBSERVE / VERIFIED + +- Action: `./devpt logs test-go-basic --lines 5` +- Signal: `2026/03/12 14:59:04 [go-basic] listening on http://localhost:3400` +- Constraints: Logs captured only for managed services started via `devpt start` + +### go-basic / INJECT / VERIFIED + +- Action: + ```bash + cd sandbox/servers/go-basic + go run . + ``` +- Signal: `[go-basic] listening on http://localhost:3400` +- Constraints: Runs in foreground; use with `&` for background execution + +--- + +## Debug Helper Commands + +```bash +# Quick rebuild and test +go build -o devpt ./cmd/devpt && ./devpt ls + +# Run all CLI tests with coverage +go test ./pkg/cli/... -cover + +# Run the focused TUI and CLI package suite used for current UI work +go test -mod=mod ./pkg/cli/tui ./pkg/cli + +# Run specific test with verbose output +go test -v ./pkg/cli -run TestWarnLegacyManagedCommands + +# Run UI rendering tests (visual regression checks) +go test -v ./pkg/cli/tui -run TestView + +# Run state transition tests +go test -v ./pkg/cli/tui -run TestTUI + +# View registry state +cat ~/.config/devpt/registry.json | jq '.' + +# Check logs for a service +ls ~/.config/devpt/logs// +cat ~/.config/devpt/logs//*.log | tail -20 + +# Quick health check on a running service +curl -s http://localhost:/health +``` diff --git a/QUICKSTART.md b/QUICKSTART.md index 03c1c8b..1e04bd6 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -1,13 +1,5 @@ # Dev Process Tracker - Quick Start Guide -## What is Dev Process Tracker? - -Dev Process Tracker is a macOS CLI tool that helps you discover, track, and manage local development servers and ports. It answers three key questions: - -1. **What servers are running?** - Lists all TCP listening ports on your machine -2. **Which project owns each server?** - Associates ports with their project roots -3. **Who started each server?** - Detects if an AI agent started the server - ## Installation Build from source: @@ -25,41 +17,34 @@ Then use from anywhere: ```bash devpt ls ``` +## First steps -## First Steps - -### See what's currently running +### See running services ```bash devpt ls ``` -Shows all discovered listening ports with their PID, project, and source. +Shows listening ports with PID, project, and source. -### Register a service you manage +### Register a managed service ```bash devpt add myapp ~/myapp "npm start" 3000 ``` -This stores `myapp` in your registry so you can control it with devpt. - ### List with details ```bash devpt ls --details ``` -Shows the full command that each process is running. - ### Check your registered services ```bash cat ~/.config/devpt/registry.json ``` -Your services are stored here and can be edited manually. - ## Common Workflows ### Start a managed service @@ -68,7 +53,25 @@ Your services are stored here and can be edited manually. devpt start myapp ``` -Logs are captured to: `~/.config/devpt/logs/myapp/.log` +Logs are written to `~/.config/devpt/logs/myapp/.log` + +### Start multiple services at once + +```bash +# Start multiple specific services +devpt start api frontend worker + +# Use glob patterns to match services (quote to prevent shell expansion) +devpt start 'web-*' # Starts all services matching 'web-*' +devpt start '*-test' # Starts all services ending with '-test' + +# Target a specific service by name:port +devpt start web-api:3000 # Start web-api on port 3000 only +devpt stop "some:thing" # Literal service name containing a colon + +# Mix patterns and specific names +devpt start api 'web-*' worker +``` ### Stop a service by name @@ -76,6 +79,20 @@ Logs are captured to: `~/.config/devpt/logs/myapp/.log` devpt stop myapp ``` +### Stop multiple services at once + +```bash +# Stop multiple specific services +devpt stop api frontend + +# Use glob patterns (quote to prevent shell expansion) +devpt stop 'web-*' # Stops all services matching 'web-*' + +# Target a specific service by name:port +devpt stop web-api:3000 # Stop web-api on port 3000 only +devpt stop *-test # Stops all services ending with '-test' +``` + ### Stop a service by port ```bash @@ -88,6 +105,17 @@ devpt stop --port 3000 devpt restart myapp ``` +### Restart multiple services at once + +```bash +# Restart multiple specific services +devpt restart api frontend worker + +# Use glob patterns +devpt restart web-* # Restarts all services matching 'web-*' +devpt restart claude-* # Restarts all services starting with 'claude-' +``` + ### View logs ```bash @@ -95,36 +123,19 @@ devpt logs myapp devpt logs myapp --lines 100 ``` -## Key Concepts - -### Server Sources - -Each server is tagged with a source: - -- **manual** - Running but not in your managed registry -- **managed** - In your registry (may or may not be running) -- **agent:xxx** - Started by an AI coding agent - -### Project Detection +### Use the TUI -Dev Process Tracker walks up the directory tree looking for: -- `.git` (Git repos) -- `package.json` (Node.js) -- `go.mod` (Go) -- `Gemfile` (Ruby) -- `composer.json` (PHP) -- And more... - -### Agent Detection - -Detects servers likely started by: -- OpenCode -- Cursor -- Claude -- Gemini -- Copilot +```bash +devpt +``` -Uses heuristics like parent process name, TTY attachment, and environment variables. +Key interactions: +- `Tab` switches between the running-services table and the managed-services list +- `Enter` opens logs from the top table and starts the selected service from the bottom list +- `/` opens inline filter editing in the footer +- `?` opens the help modal +- mouse click selects rows and mouse wheel scrolls the active pane +- logs header shows `Logs: | Port: | PID: ` ## File Locations @@ -139,12 +150,13 @@ Uses heuristics like parent process name, TTY attachment, and environment variab └── 2026-02-09T16-10-00.log ``` -## Tips & Tricks +## Notes 1. **Edit registry manually** - `~/.config/devpt/registry.json` is just JSON 2. **Check what's using a port** - `devpt ls --details | grep :3000` 3. **Find projects** - `devpt ls | grep "my-project"` 4. **See processes without names** - `devpt ls --details | grep -v "^-"` +5. **Quote glob patterns** - use `'web-*'` instead of `web-*` to avoid shell expansion ## Troubleshooting @@ -168,25 +180,8 @@ devpt ls | grep myapp kill -9 ``` -## Performance - -- `devpt ls` typically completes in 1-2 seconds -- No background daemon (everything is on-demand) -- Results are fresh on each run - -## What's Next? - -- Register your frequently-used dev servers -- Check the `README.md` for full documentation -- Explore the `--details` flag to see more info -- Set up the servers you manage with `devpt add` - -## Need Help? +## Help ```bash devpt help -devpt ls --help -devpt add --help ``` - -Or see the full README.md for detailed documentation. diff --git a/README.md b/README.md index fff5378..e406e06 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![Dev Process Tracker hero](devpttitle.png) -Dev Process Tracker (`devpt`) helps you track and control local dev services from one place. +Dev Process Tracker (`devpt`) tracks and controls local dev services. ## What it does @@ -27,7 +27,7 @@ go test ./... ## Challenge smoke test -Run a full checklist-oriented smoke flow in an isolated temp home: +Run a smoke flow in an isolated temp home: ```bash ./scripts/challenge_smoke_test.sh @@ -51,6 +51,11 @@ devpt restart my-app # Logs devpt logs my-app --lines 100 + +# Batch operations +devpt start api frontend worker +devpt restart 'web-*' +devpt stop web-api:3000 ``` ## CLI commands @@ -61,19 +66,42 @@ devpt logs my-app --lines 100 devpt ``` -Opens the interactive monitor. +Opens the TUI. ### Manage services ```bash devpt add "" [ports...] -devpt start -devpt stop +devpt start [...] # Start one or more services +devpt stop [...] # Stop one or more services devpt stop --port -devpt restart +devpt restart [...] # Restart one or more services devpt logs [--lines N] ``` +### Batch operations + +Start, stop, or restart multiple services at once: + +```bash +# Start multiple specific services +devpt start api frontend worker + +# Use glob patterns to match service names +devpt start 'web-*' # Starts all services matching 'web-*' +devpt stop '*-test' # Stops all services ending with '-test' +devpt restart 'claude-*' # Restarts all services starting with 'claude-*' + +# Target specific service by name:port +devpt start web-api:3000 # Start web-api on port 3000 only +devpt stop "some:thing" # Service with colon in literal name + +# Mix patterns and specific names +devpt start api 'web-*' worker +``` + +Batch operations run sequentially, print per-service status, continue on failure, and return exit code `1` if any service fails. + ### Inspect ```bash @@ -81,7 +109,7 @@ devpt ls [--details] devpt status ``` -`devpt status ` now includes a `CRASH DETAILS` section for crashed managed services, including an inferred reason and recent log lines. +`devpt status ` includes `CRASH DETAILS` for crashed managed services with an inferred reason and recent log lines. ### Meta @@ -96,22 +124,35 @@ devpt --version - `Enter`: - running list: open logs - managed list: start selected service +- mouse click: select rows in either list +- mouse wheel / page keys: scroll the active viewport - `Ctrl+E`: stop selected running service (with confirm) - `Ctrl+R`: restart selected running managed service - `Ctrl+A`: open command input (`add ...` prefilled) - `x` / `Delete` / `Ctrl+D`: remove selected managed service (with confirm) -- `/`: open filter input +- `/`: edit the inline filter in the footer - `Ctrl+L`: clear filter - `s`: cycle sort mode - `h`: toggle health detail -- `?`: open help +- `?`: open help modal - `b`: back from logs/command - `f`: toggle log follow mode (in logs view) - `q`: quit +## TUI layout + +- Running services are shown in the top table. The active sort column header is bold. +- Managed services are shown in a separate section below with the total count in the section title. +- Filter state lives in the footer help row: + - default: `/ filter` + - editing: `/ >query` + - applied: `/ query` +- Help and confirmation are rendered as centered modals over the table. +- Logs view header is rendered as `Logs: | Port: | PID: `. + ## TUI command input -Inside TUI command mode (`:` or `Ctrl+A`), supported commands: +TUI command mode (`:` or `Ctrl+A`) supports: ```text add "" [ports...] @@ -125,16 +166,16 @@ help ## AI Agent Detection -Dev Process Tracker can identify servers started by AI agents (Claude, Cursor, Copilot, etc.). Detected servers show `agent:name` in the source column instead of `manual`. +Detected AI-started servers show `agent:name` in the source column instead of `manual`. ### Detection methods -1. **Parent process name** - If parent process is named `claude`, `cursor`, `copilot`, etc., it's detected as AI-started -2. **Environment variables** - Detects `CLAUDE_*`, `CURSOR_*`, `COPILOT_*` env var prefixes (Linux only; macOS uses parent process check only) +1. **Parent process name**: `claude`, `cursor`, `copilot`, and similar names +2. **Environment variables**: `CLAUDE_*`, `CURSOR_*`, `COPILOT_*` prefixes on platforms where available -### Naming convention for AI-managed services +### Naming convention -When registering managed services with `devpt add`, use a naming prefix to indicate ownership: +Use a naming prefix if you want ownership to be obvious in the registry: ```bash # Services started by Claude @@ -148,11 +189,7 @@ devpt add cursor-worker ~/projects/worker "npm start" 4000 devpt add copilot-service ~/projects/service "python app.py" 5000 ``` -When you use `devpt start` on these services, the naming makes it clear which AI agent manages them in the registry. - -### Example: Testing with built-in test servers - -The `sandbox/servers/` directory includes test servers for experimenting: +### Example with built-in test servers ```bash # From repo root, register test servers with AI owner names @@ -175,12 +212,14 @@ devpt start cursor-node-warnings devpt ``` -Each test server exposes `/health` (JSON) and `/` (plain text) endpoints. +Each test server exposes `/health` and `/`. ## Notes - Managed services are registry entries you control via `devpt`. - Running list is process-driven. Managed services can appear even before a port is bound. +- `name:port` is supported for CLI targeting where multiple services share a base name. +- Quote glob patterns like `'web-*'` so your shell does not expand them first. - If stop needs elevated permissions, TUI asks for confirmation to run `sudo kill -9 `. - Service names can include a prefix (e.g., `claude-`, `cursor-`, `copilot-`) to indicate AI agent ownership in your registry. - No login or API credentials are required for judges to run this project locally. diff --git a/cmd/devpt/main.go b/cmd/devpt/main.go index 9d552d2..237d425 100644 --- a/cmd/devpt/main.go +++ b/cmd/devpt/main.go @@ -6,6 +6,7 @@ import ( "os" "strconv" + "github.com/devports/devpt/pkg/buildinfo" "github.com/devports/devpt/pkg/cli" ) @@ -44,7 +45,7 @@ func main() { printUsage() os.Exit(0) case "--version", "-v": - fmt.Println("devpt version 0.1.0") + fmt.Printf("devpt version %s\n", buildinfo.Version) os.Exit(0) default: fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command) @@ -92,36 +93,40 @@ func handleAdd(app *cli.App, args []string) error { func handleStart(app *cli.App, args []string) error { if len(args) < 1 { - fmt.Println("Usage: devpt start ") + fmt.Println("Usage: devpt start [name...]") return fmt.Errorf("service name required") } - return app.StartCmd(args[0]) + return app.BatchStartCmd(args) } func handleStop(app *cli.App, args []string) error { if len(args) < 1 { - fmt.Println("Usage: devpt stop ") + fmt.Println("Usage: devpt stop [name...]") return fmt.Errorf("service name or port required") } + // Check if --port flag is used (not supported with batch mode yet) if args[0] == "--port" { + if len(args) > 2 { + return fmt.Errorf("--port flag only supports single service") + } if len(args) < 2 { return fmt.Errorf("port required after --port") } return app.StopCmd(args[1]) } - return app.StopCmd(args[0]) + return app.BatchStopCmd(args) } func handleRestart(app *cli.App, args []string) error { if len(args) < 1 { - fmt.Println("Usage: devpt restart ") + fmt.Println("Usage: devpt restart [name...]") return fmt.Errorf("service name required") } - return app.RestartCmd(args[0]) + return app.BatchRestartCmd(args) } func handleLogs(app *cli.App, args []string) error { @@ -162,12 +167,21 @@ Default: Manage services: devpt add "" [ports...] - devpt start - devpt stop - devpt stop --port - devpt restart + devpt start [name...] + devpt stop [name...] + devpt restart [name...] devpt logs [--lines N] +Patterns (quote to prevent shell expansion): + '*' Match any sequence of characters + 'service*' Match services starting with "service" + '*-api' Match services ending with "-api" + '*web*' Match services containing "web" + +name:port format: + web-api:3000 Target service "web-api" on port 3000 + "some:thing" Literal service name containing a colon + Inspect: devpt ls [--details] devpt status @@ -186,6 +200,12 @@ Quick start: devpt start my-app devpt stop my-app +Batch operations: + devpt start api worker frontend + devpt stop 'web-*' # Quote patterns to prevent shell expansion + devpt restart '*-api' worker + devpt stop web-api:3000 # Target specific port + Top UI tips: Tab switch lists, Enter actions/start, / filter, ? help, ^A add ` diff --git a/go.mod b/go.mod index c3642d0..ec34beb 100644 --- a/go.mod +++ b/go.mod @@ -3,23 +3,30 @@ module github.com/devports/devpt go 1.25.7 require ( - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.2 + charm.land/lipgloss/v2 v2.0.2 + github.com/charmbracelet/x/ansi v0.11.6 + github.com/mattn/go-runewidth v0.0.21 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.3.8 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index cc7a40a..ce24743 100644 --- a/go.sum +++ b/go.sum @@ -1,41 +1,54 @@ -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= +charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..9fb199a --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,26 @@ +# Lefthook configuration for dev-process-tracker +# Install: go install github.com/evilmartians/lefthook@latest && lefthook install + +pre-push: + parallel: false + commands: + validate-version: + name: Validate code version matches git tag + run: | + TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + if [ -n "$TAG" ]; then + # Strip 'v' prefix for comparison + TAG_VERSION="${TAG#v}" + CODE_VERSION=$(sed -n 's/const Version = "\([^"]*\)"/\1/p' pkg/buildinfo/version.go) + if [ "$CODE_VERSION" != "$TAG_VERSION" ]; then + echo "" + echo "❌ Version mismatch!" + echo " pkg/buildinfo/version.go: $CODE_VERSION" + echo " Latest git tag: $TAG" + echo "" + echo "Fix: Either update pkg/buildinfo/version.go to \"$TAG_VERSION\"" + echo " or delete the tag: git tag -d $TAG && git push --delete origin $TAG" + exit 1 + fi + echo "✅ Version matches: $TAG" + fi diff --git a/pkg/buildinfo/version.go b/pkg/buildinfo/version.go new file mode 100644 index 0000000..e159616 --- /dev/null +++ b/pkg/buildinfo/version.go @@ -0,0 +1,3 @@ +package buildinfo + +const Version = "0.3.0" diff --git a/pkg/cli/app.go b/pkg/cli/app.go index b76fd09..b0f3c7f 100644 --- a/pkg/cli/app.go +++ b/pkg/cli/app.go @@ -26,6 +26,8 @@ type App struct { detector *scanner.AgentDetector processManager *process.Manager healthChecker *health.Checker + stdout io.Writer + stderr io.Writer } // NewApp creates and initializes the application @@ -55,9 +57,35 @@ func NewApp() (*App, error) { detector: scanner.NewAgentDetector(), processManager: process.NewManager(config.LogsDir), healthChecker: health.NewChecker(0), + stdout: os.Stdout, + stderr: os.Stderr, }, nil } +func (a *App) outWriter() io.Writer { + if a != nil && a.stdout != nil { + return a.stdout + } + return io.Discard +} + +func (a *App) errWriter() io.Writer { + if a != nil && a.stderr != nil { + return a.stderr + } + return io.Discard +} + +func (a *App) withOutput(stdout, stderr io.Writer) *App { + if a == nil { + return nil + } + clone := *a + clone.stdout = stdout + clone.stderr = stderr + return &clone +} + // discoverServers combines scanning and detection into complete server info func (a *App) discoverServers() ([]*models.ServerInfo, error) { processes, err := a.scanner.ScanListeningPorts() @@ -65,10 +93,8 @@ func (a *App) discoverServers() ([]*models.ServerInfo, error) { return nil, fmt.Errorf("failed to scan processes: %w", err) } - // Filter to keep only development processes + managedServices := a.registry.ListServices() commandMap := a.getCommandMap(processes) - processes = scanner.FilterDevProcesses(processes, commandMap) - for _, proc := range processes { if proc.CWD != "" { proc.ProjectRoot = a.resolver.FindProjectRoot(proc.CWD) @@ -78,25 +104,11 @@ func (a *App) discoverServers() ([]*models.ServerInfo, error) { var servers []*models.ServerInfo - for _, proc := range processes { - source := models.SourceManual - if proc.AgentTag != nil { - source = proc.AgentTag.Source - } - - servers = append(servers, &models.ServerInfo{ - ProcessRecord: proc, - Source: source, - Status: "running", - }) - } - type managedIdentity struct { cwd string root string } - managedServices := a.registry.ListServices() portOwners := make(map[int][]*models.ManagedService) rootOwners := make(map[string]int) cwdOwners := make(map[string]int) @@ -118,82 +130,59 @@ func (a *App) discoverServers() ([]*models.ServerInfo, error) { portOwners[port] = append(portOwners[port], svc) } } + + matchedServices := make(map[*models.ManagedService]*models.ProcessRecord, len(managedServices)) + matchedProcesses := make(map[*models.ProcessRecord]*models.ManagedService, len(managedServices)) for _, svc := range managedServices { - found := false identity := identities[svc] - svcCWD := identity.cwd - svcRoot := identity.root + if proc := findManagedProcessForService(svc, processes, identity.root, identity.cwd, rootOwners, cwdOwners, portOwners); proc != nil { + matchedServices[svc] = proc + matchedProcesses[proc] = svc + } + } - for _, server := range servers { - if server.ProcessRecord == nil || server.ManagedService != nil { - continue - } - procCWD := normalizePath(server.ProcessRecord.CWD) - procRoot := normalizePath(server.ProcessRecord.ProjectRoot) - if canMatchByPath(svcRoot, svcCWD, procRoot, procCWD, rootOwners, cwdOwners) { - server.ManagedService = svc - found = true - break - } + for _, proc := range processes { + if proc == nil { + continue } - if !found && len(svc.Ports) > 0 { - for _, port := range svc.Ports { - if owners := portOwners[port]; len(owners) != 1 { - continue - } - for _, server := range servers { - if server.ProcessRecord != nil && server.ProcessRecord.Port == port && server.ManagedService == nil { - procCWD := normalizePath(server.ProcessRecord.CWD) - procRoot := normalizePath(server.ProcessRecord.ProjectRoot) - if svcRoot != "" && procRoot != "" && svcRoot != procRoot { - continue - } - if svcCWD != "" && procCWD != "" && svcCWD != procCWD { - continue - } - server.ManagedService = svc - found = true - break - } - } - if found { - break - } - } + matchedSvc := matchedProcesses[proc] + if matchedSvc == nil && !scanner.IsDevProcess(proc, commandMap[proc.PID]) { + continue } - if !found && svc.LastPID != nil && *svc.LastPID > 0 { - for _, server := range servers { - if server.ProcessRecord == nil || server.ManagedService != nil || server.ProcessRecord.PID != *svc.LastPID { - continue - } - procCWD := normalizePath(server.ProcessRecord.CWD) - procRoot := normalizePath(server.ProcessRecord.ProjectRoot) - if serviceMatchesProcess(svc, server.ProcessRecord, svcRoot, procRoot, procCWD) { - server.ManagedService = svc - found = true - break - } - } + source := models.SourceManual + if proc.AgentTag != nil { + source = proc.AgentTag.Source } - if !found { - status := "stopped" - crashReason := "" - crashLogTail := []string(nil) - if svc.LastPID != nil && *svc.LastPID > 0 { - status = "crashed" - crashReason, crashLogTail = a.getCrashReport(svc.Name, 12) - } - servers = append(servers, &models.ServerInfo{ - ManagedService: svc, - Source: models.SourceManaged, - Status: status, - CrashReason: crashReason, - CrashLogTail: crashLogTail, - }) + servers = append(servers, &models.ServerInfo{ + ManagedService: matchedSvc, + ProcessRecord: proc, + Source: source, + Status: "running", + }) + } + + for _, svc := range managedServices { + if matchedServices[svc] != nil { + continue } + + status := "stopped" + crashReason := "" + crashLogTail := []string(nil) + if svc.LastPID != nil && *svc.LastPID > 0 { + status = "crashed" + crashReason, crashLogTail = a.getCrashReport(svc.Name, 12) + } + servers = append(servers, &models.ServerInfo{ + ManagedService: svc, + Source: models.SourceManaged, + Status: status, + CrashReason: crashReason, + CrashLogTail: crashLogTail, + }) } return servers, nil @@ -278,6 +267,66 @@ func canMatchByPath(svcRoot, svcCWD, procRoot, procCWD string, rootOwners, cwdOw return false } +func findManagedProcessForService( + svc *models.ManagedService, + processes []*models.ProcessRecord, + svcRoot string, + svcCWD string, + rootOwners map[string]int, + cwdOwners map[string]int, + portOwners map[int][]*models.ManagedService, +) *models.ProcessRecord { + if svc == nil { + return nil + } + + for _, proc := range processes { + if proc == nil { + continue + } + procCWD := normalizePath(proc.CWD) + procRoot := normalizePath(proc.ProjectRoot) + if canMatchByPath(svcRoot, svcCWD, procRoot, procCWD, rootOwners, cwdOwners) { + return proc + } + } + + for _, port := range svc.Ports { + if owners := portOwners[port]; len(owners) != 1 { + continue + } + for _, proc := range processes { + if proc == nil || proc.Port != port { + continue + } + procCWD := normalizePath(proc.CWD) + procRoot := normalizePath(proc.ProjectRoot) + if svcRoot != "" && procRoot != "" && svcRoot != procRoot { + continue + } + if svcCWD != "" && procCWD != "" && svcCWD != procCWD { + continue + } + return proc + } + } + + if svc.LastPID != nil && *svc.LastPID > 0 { + for _, proc := range processes { + if proc == nil || proc.PID != *svc.LastPID { + continue + } + procCWD := normalizePath(proc.CWD) + procRoot := normalizePath(proc.ProjectRoot) + if serviceMatchesProcess(svc, proc, svcRoot, procRoot, procCWD) { + return proc + } + } + } + + return nil +} + func serviceMatchesProcess(svc *models.ManagedService, proc *models.ProcessRecord, svcRoot, procRoot, procCWD string) bool { if svc == nil || proc == nil { return false diff --git a/pkg/cli/app_batch_test.go b/pkg/cli/app_batch_test.go new file mode 100644 index 0000000..286e725 --- /dev/null +++ b/pkg/cli/app_batch_test.go @@ -0,0 +1,129 @@ +package cli + +import ( + "testing" + + _ "github.com/devports/devpt/pkg/models" + _ "github.com/stretchr/testify/assert" +) + +// TestBatchStartCmd_Success starts multiple services successfully +func TestBatchStartCmd_Success(t *testing.T) { + // This test will require setup with a test registry and mock process manager + // For now, it documents the expected behavior + + t.Run("starts all services and returns success", func(t *testing.T) { + // Given: app with test registry containing services + // When: BatchStartCmd is called with multiple service names + // Then: Each service starts in order + // And: Per-service status lines are returned + // And: Exit code is 0 (all success) + + // TODO: Implement with test registry setup + }) +} + +// TestBatchStartCmd_PartialFailure continues with remaining services +func TestBatchStartCmd_PartialFailure(t *testing.T) { + t.Run("one service fails but continues with others", func(t *testing.T) { + // Given: app with services, where one will fail + // When: BatchStartCmd is called + // Then: Other services continue to start + // And: Failure is reported in status + // And: Exit code is 1 (any failure) + }) +} + +// TestBatchStartCmd_UnknownService reports error but continues +func TestBatchStartCmd_UnknownService(t *testing.T) { + t.Run("unknown service name shows error", func(t *testing.T) { + // Given: app with registry + // When: BatchStartCmd includes unknown service name + // Then: Error message 'service "{name}" not found' is returned + // And: Other services continue processing + // And: Exit code is 1 + }) +} + +// TestBatchStartCmd_EmptyArgs returns error +func TestBatchStartCmd_EmptyArgs(t *testing.T) { + t.Run("no service arguments returns error", func(t *testing.T) { + // Given: app + // When: BatchStartCmd is called with no arguments + // Then: Usage error is returned + // And: Exit code is 1 + }) +} + +// TestBatchStartCmd_AlreadyRunning shows warning but continues +func TestBatchStartCmd_AlreadyRunning(t *testing.T) { + t.Run("already running service shows warning", func(t *testing.T) { + // Given: app with a service that is already running + // When: BatchStartCmd is called for that service + // Then: Warning message is displayed + // And: Other services continue processing + }) +} + +// TestBatchStopCmd_Success stops multiple services successfully +func TestBatchStopCmd_Success(t *testing.T) { + t.Run("stops all services and returns success", func(t *testing.T) { + // Given: app with multiple running services + // When: BatchStopCmd is called + // Then: Each service stops in order + // And: Per-service status lines confirm stops + // And: Exit code is 0 + }) +} + +// TestBatchStopCmd_NotRunning shows warning but continues +func TestBatchStopCmd_NotRunning(t *testing.T) { + t.Run("non-running service shows warning", func(t *testing.T) { + // Given: app with a stopped service + // When: BatchStopCmd is called for that service + // Then: Warning message is displayed + // And: Other services continue stopping + }) +} + +// TestBatchRestartCmd_Success restarts multiple services successfully +func TestBatchRestartCmd_Success(t *testing.T) { + t.Run("restarts all services and returns success", func(t *testing.T) { + // Given: app with multiple running services + // When: BatchRestartCmd is called + // Then: Each service restarts in order + // And: Per-service status lines show new PIDs + // And: Exit code is 0 + }) +} + +// TestBatchExecution_Order maintains argument order +func TestBatchExecution_Order(t *testing.T) { + t.Run("services processed in argument order", func(t *testing.T) { + // Given: app with multiple services + // When: Batch operation called with ["svc3", "svc1", "svc2"] + // Then: Services processed in that order (svc3, then svc1, then svc2) + // And: Output appears in same order + }) +} + +// TestBatchExecution_Sequential processes services one at a time +func TestBatchExecution_Sequential(t *testing.T) { + t.Run("services processed sequentially not in parallel", func(t *testing.T) { + // Given: app with multiple services + // When: Batch operation is called + // Then: Services are processed one at a time (no parallelism) + // And: Each service completes before next starts + }) +} + +// TestBatchExecution_WithPatterns expands patterns then executes +func TestBatchExecution_WithPatterns(t *testing.T) { + t.Run("glob patterns are expanded before execution", func(t *testing.T) { + // Given: app with services matching pattern + // When: Batch operation called with glob pattern + // Then: Pattern is expanded against registry + // And: Matching services are processed + // And: Non-matching patterns cause error (no matches) + }) +} diff --git a/pkg/cli/app_matching_test.go b/pkg/cli/app_matching_test.go index 0aa4b97..9e675c8 100644 --- a/pkg/cli/app_matching_test.go +++ b/pkg/cli/app_matching_test.go @@ -72,6 +72,74 @@ func TestServiceMatchesProcessRequiresStrongerSignalThanPID(t *testing.T) { } } +func TestFindManagedProcessForServiceKeepsManagedNonDevProcess(t *testing.T) { + t.Parallel() + + lastPID := 1234 + svc := &models.ManagedService{ + Name: "postgres", + CWD: "/workspace/db", + Ports: []int{5432}, + LastPID: &lastPID, + } + processes := []*models.ProcessRecord{ + { + PID: 1234, + Port: 5432, + Command: "/usr/local/bin/postgres", + CWD: "/workspace/db", + ProjectRoot: "/workspace/db", + }, + } + + got := findManagedProcessForService( + svc, + processes, + "/workspace/db", + "/workspace/db", + map[string]int{"/workspace/db": 1}, + map[string]int{"/workspace/db": 1}, + map[int][]*models.ManagedService{5432: []*models.ManagedService{svc}}, + ) + if got != processes[0] { + t.Fatalf("expected managed process match, got %#v", got) + } +} + +func TestFindManagedProcessForServiceRejectsPIDOnlyMatch(t *testing.T) { + t.Parallel() + + lastPID := 4242 + svc := &models.ManagedService{ + Name: "api", + CWD: "/workspace/api", + Ports: []int{3000}, + LastPID: &lastPID, + } + processes := []*models.ProcessRecord{ + { + PID: 4242, + Port: 9999, + Command: "/usr/sbin/unrelated", + CWD: "/tmp/other", + ProjectRoot: "/tmp/other", + }, + } + + got := findManagedProcessForService( + svc, + processes, + "/workspace/api", + "/workspace/api", + map[string]int{"/workspace/api": 1, "/tmp/other": 1}, + map[string]int{"/workspace/api": 1, "/tmp/other": 1}, + map[int][]*models.ManagedService{3000: []*models.ManagedService{svc}}, + ) + if got != nil { + t.Fatalf("expected PID-only candidate to be rejected, got %#v", got) + } +} + func TestManagedServicePIDReturnsMatchedProcess(t *testing.T) { t.Parallel() @@ -97,3 +165,20 @@ func TestManagedServicePIDReturnsMatchedProcess(t *testing.T) { t.Fatalf("managedServicePID(..., missing) = %d, want 0", got) } } + +func TestValidatedManagedPIDFromServersRejectsUnvalidatedStoredPID(t *testing.T) { + t.Parallel() + + lastPID := 9090 + svc := &models.ManagedService{ + Name: "api", + LastPID: &lastPID, + } + + _, err := validatedManagedPIDFromServers(svc, nil, func(pid int) bool { + return pid == lastPID + }) + if err == nil { + t.Fatal("expected stale running stored PID to be rejected") + } +} diff --git a/pkg/cli/commands.go b/pkg/cli/commands.go index a42affd..9f5f4cc 100644 --- a/pkg/cli/commands.go +++ b/pkg/cli/commands.go @@ -25,7 +25,7 @@ func (a *App) ListCmd(detailed bool) error { // printServerTable prints servers in tabular format func (a *App) printServerTable(servers []*models.ServerInfo, detailed bool) error { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + w := tabwriter.NewWriter(a.outWriter(), 0, 0, 2, ' ', 0) if detailed { fmt.Fprintln(w, "Name\tPort\tPID\tProject\tCommand\tSource\tStatus") @@ -100,7 +100,7 @@ func (a *App) AddCmd(name, cwd, command string, ports []int) error { return err } - fmt.Printf("Service %q registered successfully\n", name) + fmt.Fprintf(a.outWriter(), "Service %q registered successfully\n", name) return nil } @@ -111,23 +111,25 @@ func (a *App) RemoveCmd(name string) error { // StartCmd starts a managed service func (a *App) StartCmd(name string) error { - svc := a.registry.GetService(name) + // Supports name:port format for disambiguation + allServices := a.registry.ListServices() + svc, errs := LookupServiceWithFallback(name, allServices) if svc == nil { - return fmt.Errorf("service %q not found", name) + return fmt.Errorf("service %q not found: %s", name, strings.Join(errs, "; ")) } - fmt.Printf("Starting service %q...\n", name) + fmt.Fprintf(a.outWriter(), "Starting %q...\n", svc.Name) pid, err := a.processManager.Start(svc) if err != nil { return fmt.Errorf("failed to start service: %w", err) } // Update registry with new PID - if err := a.registry.UpdateServicePID(name, pid); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to update registry: %v\n", err) + if err := a.registry.UpdateServicePID(svc.Name, pid); err != nil { + fmt.Fprintf(a.errWriter(), "Warning: failed to update registry: %v\n", err) } - fmt.Printf("Service %q started with PID %d\n", name, pid) + fmt.Fprintf(a.outWriter(), "Started %q\n", svc.Name) return nil } @@ -137,16 +139,13 @@ func (a *App) StopCmd(identifier string) error { targetServiceName := "" // Check if identifier is a service name - if svc := a.registry.GetService(identifier); svc != nil { + if svc, _ := LookupServiceWithFallback(identifier, a.registry.ListServices()); svc != nil { targetServiceName = svc.Name - servers, err := a.discoverServers() + pid, err := a.validatedManagedPID(svc) if err != nil { return err } - targetPID = managedServicePID(servers, svc.Name) - if targetPID == 0 && svc.LastPID != nil && *svc.LastPID > 0 && a.processManager.IsRunning(*svc.LastPID) { - return fmt.Errorf("cannot safely determine PID for service %q; stored PID is no longer validated against a live managed process", identifier) - } + targetPID = pid } else { // Try parsing as port number port, err := strconv.Atoi(identifier) @@ -180,7 +179,7 @@ func (a *App) StopCmd(identifier string) error { } // Stop the process - fmt.Printf("Stopping PID %d...\n", targetPID) + fmt.Fprintf(a.outWriter(), "Stopping PID %d...\n", targetPID) if err := a.processManager.Stop(targetPID, 5000000000); err != nil { // 5 second timeout if errors.Is(err, process.ErrNeedSudo) { return fmt.Errorf("requires sudo to terminate PID %d", targetPID) @@ -188,7 +187,7 @@ func (a *App) StopCmd(identifier string) error { if isProcessFinishedErr(err) { if targetServiceName != "" { if clrErr := a.registry.ClearServicePID(targetServiceName); clrErr != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to clear PID for %q: %v\n", targetServiceName, clrErr) + fmt.Fprintf(a.errWriter(), "Warning: failed to clear PID for %q: %v\n", targetServiceName, clrErr) } } return nil @@ -196,10 +195,10 @@ func (a *App) StopCmd(identifier string) error { return fmt.Errorf("failed to stop process: %w", err) } - fmt.Printf("Process %d stopped\n", targetPID) + fmt.Fprintf(a.outWriter(), "Process %d stopped\n", targetPID) if targetServiceName != "" { if err := a.registry.ClearServicePID(targetServiceName); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to clear PID for %q: %v\n", targetServiceName, err) + fmt.Fprintf(a.errWriter(), "Warning: failed to clear PID for %q: %v\n", targetServiceName, err) } } return nil @@ -207,42 +206,280 @@ func (a *App) StopCmd(identifier string) error { // RestartCmd restarts a managed service func (a *App) RestartCmd(name string) error { - svc := a.registry.GetService(name) + // Supports name:port format for disambiguation + allServices := a.registry.ListServices() + svc, errs := LookupServiceWithFallback(name, allServices) if svc == nil { - return fmt.Errorf("service %q not found", name) + return fmt.Errorf("service %q not found: %s", name, strings.Join(errs, "; ")) } // Stop if running if pid, err := a.validatedManagedPID(svc); err != nil { return err } else if pid > 0 { - fmt.Printf("Stopping service %q...\n", name) + fmt.Fprintf(a.outWriter(), "Stopping service %q...\n", svc.Name) if err := a.processManager.Stop(pid, 5000000000); err != nil { // 5 second timeout - fmt.Fprintf(os.Stderr, "Warning: failed to stop service: %v\n", err) + fmt.Fprintf(a.errWriter(), "Warning: failed to stop service: %v\n", err) } } // Start - fmt.Printf("Starting service %q...\n", name) + fmt.Fprintf(a.outWriter(), "Starting %q...\n", svc.Name) pid, err := a.processManager.Start(svc) if err != nil { return fmt.Errorf("failed to start service: %w", err) } // Update registry - if err := a.registry.UpdateServicePID(name, pid); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to update registry: %v\n", err) + if err := a.registry.UpdateServicePID(svc.Name, pid); err != nil { + fmt.Fprintf(a.errWriter(), "Warning: failed to update registry: %v\n", err) + } + + fmt.Fprintf(a.outWriter(), "Restarted %q\n", svc.Name) + return nil +} + +// BatchStartCmd starts multiple services in sequence. +// Expands glob patterns against service names before execution. +// Continues processing after failures (partial failure handling). +// Returns error if any service fails to start. +func (a *App) BatchStartCmd(names []string) error { + if len(names) == 0 { + return fmt.Errorf("no service names provided") + } + + // Expand glob patterns against registry + services := a.registry.ListServices() + expandedNames := ExpandPatterns(names, services) + + if len(expandedNames) == 0 { + return fmt.Errorf("no services found matching patterns") + } + + var anyFailure bool + var firstErr error + + for _, name := range expandedNames { + // Check if service exists (supports name:port format) + allServices := a.registry.ListServices() + svc, errs := LookupServiceWithFallback(name, allServices) + if svc == nil { + fmt.Fprintf(os.Stderr, "Error: service identifier %q not found: %s\n", name, strings.Join(errs, ", ")) + anyFailure = true + if firstErr == nil { + firstErr = fmt.Errorf("service %q not found: %s", name, strings.Join(errs, "; ")) + } + continue + } + + // Check if already running + runningPID, err := a.validatedManagedPID(svc) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + anyFailure = true + if firstErr == nil { + firstErr = err + } + continue + } + if runningPID > 0 { + fmt.Fprintf(os.Stderr, "Warning: service %q already running (PID %d)\n", name, runningPID) + continue + } + + // Attempt to start + fmt.Printf("Starting %q...\n", name) + pid, err := a.processManager.Start(svc) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to start service %q: %v\n", name, err) + anyFailure = true + if firstErr == nil { + firstErr = fmt.Errorf("failed to start %q: %w", name, err) + } + continue + } + + // Update registry with new PID + if updateErr := a.registry.UpdateServicePID(svc.Name, pid); updateErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to update registry for %q: %v\n", name, updateErr) + } + + fmt.Printf("Started %q\n", name) + } + + if anyFailure { + return firstErr + } + return nil +} + +// BatchStopCmd stops multiple services in sequence. +// Expands glob patterns against service names before execution. +// Continues processing after failures (partial failure handling). +// Returns error if any service fails to stop. +func (a *App) BatchStopCmd(names []string) error { + if len(names) == 0 { + return fmt.Errorf("no service names provided") + } + + // Expand glob patterns against registry + services := a.registry.ListServices() + expandedNames := ExpandPatterns(names, services) + + if len(expandedNames) == 0 { + return fmt.Errorf("no services found matching patterns") + } + + var anyFailure bool + var firstErr error + + for _, name := range expandedNames { + // Check if service exists (supports name:port format) + allServices := a.registry.ListServices() + svc, errs := LookupServiceWithFallback(name, allServices) + if svc == nil { + fmt.Fprintf(os.Stderr, "Error: service identifier %q not found: %s\n", name, strings.Join(errs, ", ")) + anyFailure = true + if firstErr == nil { + firstErr = fmt.Errorf("service %q not found: %s", name, strings.Join(errs, "; ")) + } + continue + } + + // Determine PID to stop + targetPID, err := a.validatedManagedPID(svc) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + anyFailure = true + if firstErr == nil { + firstErr = err + } + continue + } + if targetPID == 0 { + fmt.Fprintf(os.Stderr, "Warning: service %q is not running\n", name) + continue + } + + // Attempt to stop + fmt.Printf("Stopping service %q (PID %d)...\n", name, targetPID) + if err := a.processManager.Stop(targetPID, 5000000000); err != nil { // 5 second timeout + if errors.Is(err, process.ErrNeedSudo) { + fmt.Fprintf(os.Stderr, "Error: requires sudo to terminate service %q (PID %d)\n", name, targetPID) + } else if isProcessFinishedErr(err) { + // Process already finished - clear PID and continue + if clrErr := a.registry.ClearServicePID(svc.Name); clrErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to clear PID for %q: %v\n", name, clrErr) + } + fmt.Printf("Service %q already stopped\n", name) + continue + } else { + fmt.Fprintf(os.Stderr, "Error: failed to stop service %q: %v\n", name, err) + anyFailure = true + if firstErr == nil { + firstErr = fmt.Errorf("failed to stop %q: %w", name, err) + } + continue + } + } + + fmt.Printf("Service %q stopped (PID %d)\n", name, targetPID) + if clrErr := a.registry.ClearServicePID(svc.Name); clrErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to clear PID for %q: %v\n", name, clrErr) + } + } + + if anyFailure { + return firstErr + } + return nil +} + +// BatchRestartCmd restarts multiple services in sequence. +// Expands glob patterns against service names before execution. +// Continues processing after failures (partial failure handling). +// Returns error if any service fails to restart. +func (a *App) BatchRestartCmd(names []string) error { + if len(names) == 0 { + return fmt.Errorf("no service names provided") + } + + // Expand glob patterns against registry + services := a.registry.ListServices() + expandedNames := ExpandPatterns(names, services) + + if len(expandedNames) == 0 { + return fmt.Errorf("no services found matching patterns") } - fmt.Printf("Service %q restarted with PID %d\n", name, pid) + var anyFailure bool + var firstErr error + + for _, name := range expandedNames { + // Check if service exists (supports name:port format) + allServices := a.registry.ListServices() + svc, errs := LookupServiceWithFallback(name, allServices) + if svc == nil { + fmt.Fprintf(os.Stderr, "Error: service identifier %q not found: %s\n", name, strings.Join(errs, ", ")) + anyFailure = true + if firstErr == nil { + firstErr = fmt.Errorf("service %q not found: %s", name, strings.Join(errs, "; ")) + } + continue + } + + // Stop if running + runningPID, err := a.validatedManagedPID(svc) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + anyFailure = true + if firstErr == nil { + firstErr = err + } + continue + } + if runningPID > 0 { + fmt.Printf("Stopping service %q (PID %d)...\n", name, runningPID) + if stopErr := a.processManager.Stop(runningPID, 5000000000); stopErr != nil { + if !errors.Is(stopErr, process.ErrNeedSudo) && !isProcessFinishedErr(stopErr) { + fmt.Fprintf(os.Stderr, "Warning: failed to stop service %q: %v\n", name, stopErr) + } + } + } + + // Start service + fmt.Printf("Starting %q...\n", name) + pid, err := a.processManager.Start(svc) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to start service %q: %v\n", name, err) + anyFailure = true + if firstErr == nil { + firstErr = fmt.Errorf("failed to restart %q: %w", name, err) + } + continue + } + + // Update registry with new PID + if updateErr := a.registry.UpdateServicePID(svc.Name, pid); updateErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to update registry for %q: %v\n", name, updateErr) + } + + fmt.Printf("Restarted %q\n", name) + } + + if anyFailure { + return firstErr + } return nil } // LogsCmd displays recent logs for a service func (a *App) LogsCmd(name string, lines int) error { - svc := a.registry.GetService(name) + // Supports name:port format for disambiguation + allServices := a.registry.ListServices() + svc, errs := LookupServiceWithFallback(name, allServices) if svc == nil { - return fmt.Errorf("service %q not found", name) + return fmt.Errorf("service %q not found: %s", name, strings.Join(errs, "; ")) } logLines, err := a.processManager.Tail(svc.Name, lines) @@ -250,7 +487,7 @@ func (a *App) LogsCmd(name string, lines int) error { return err } - fmt.Printf("Logs for service %q:\n", name) + fmt.Printf("Logs for service %q:\n", svc.Name) for _, line := range logLines { fmt.Println(line) } @@ -278,22 +515,108 @@ func managedServicePID(servers []*models.ServerInfo, serviceName string) int { return 0 } -func (a *App) validatedManagedPID(svc *models.ManagedService) (int, error) { +func validatedManagedPIDFromServers( + svc *models.ManagedService, + servers []*models.ServerInfo, + isRunning func(int) bool, +) (int, error) { if svc == nil { return 0, nil } + + if pid := managedServicePID(servers, svc.Name); pid != 0 { + return pid, nil + } + + if svc.LastPID != nil && *svc.LastPID > 0 && isRunning != nil && isRunning(*svc.LastPID) { + return 0, fmt.Errorf( + "cannot safely determine PID for service %q; stored PID is no longer validated against a live managed process", + svc.Name, + ) + } + + return 0, nil +} + +func (a *App) validatedManagedPID(svc *models.ManagedService) (int, error) { servers, err := a.discoverServers() if err != nil { return 0, err } - pid := managedServicePID(servers, svc.Name) - if pid != 0 { - return pid, nil + return validatedManagedPIDFromServers(svc, servers, a.processManager.IsRunning) +} + +// BatchResult represents the result of a single service operation +type BatchResult struct { + Service string + Action string // "start", "stop", "restart" + Success bool + PID int // For start/restart success + Error string // For failures + Warning string // For warnings (e.g., already running) +} + +// FormatBatchResult formats a single batch operation result +func FormatBatchResult(result BatchResult) { + if result.Success { + if result.PID > 0 { + // Use proper past tense for irregular verbs + action := result.Action + "ed" + if result.Action == "stop" { + action = "stopped" + } + fmt.Printf("%s: %s (PID %d)\n", result.Service, action, result.PID) + } else { + action := result.Action + "ed" + if result.Action == "stop" { + action = "stopped" + } + fmt.Printf("%s: %s\n", result.Service, action) + } + } else if result.Warning != "" { + fmt.Printf("%s: Warning - %s\n", result.Service, result.Warning) + } else { + fmt.Printf("%s: Error - %s\n", result.Service, result.Error) } - if svc.LastPID != nil && *svc.LastPID > 0 && a.processManager.IsRunning(*svc.LastPID) { - return 0, fmt.Errorf("cannot safely determine PID for service %q; stored PID is no longer validated against a live managed process", svc.Name) +} + +// FormatBatchResults formats multiple batch results with summary +func FormatBatchResults(results []BatchResult) { + successCount := 0 + failureCount := 0 + + for _, result := range results { + FormatBatchResult(result) + if result.Success { + successCount++ + } else if result.Warning == "" { + failureCount++ + } } - return 0, nil + + // Print summary + fmt.Println() + if failureCount == 0 && successCount > 0 { + action := "started" + if len(results) > 0 && results[0].Action != "" { + action = results[0].Action + "ed" + if results[0].Action == "stop" { + action = "stopped" + } + } + fmt.Printf("All services %s successfully\n", action) + } else if failureCount > 0 && successCount > 0 { + fmt.Printf("%d of %d services failed\n", failureCount, len(results)) + } else if failureCount > 0 { + fmt.Printf("All %d services failed\n", failureCount) + } +} + +// FormatBatchResultsWithPattern formats multiple batch results with pattern match count +func FormatBatchResultsWithPattern(results []BatchResult, pattern string) { + fmt.Printf("Pattern '%s' matched %d services\n", pattern, len(results)) + fmt.Println() + FormatBatchResults(results) } // StatusCmd shows detailed info for a specific server diff --git a/pkg/cli/commands_batch_test.go b/pkg/cli/commands_batch_test.go new file mode 100644 index 0000000..40c2fd3 --- /dev/null +++ b/pkg/cli/commands_batch_test.go @@ -0,0 +1,197 @@ +package cli + +import ( + "bytes" + "io" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestFormatBatchResult_Success formats successful start result +func TestFormatBatchResult_Success(t *testing.T) { + result := BatchResult{ + Service: "api", + Action: "start", + Success: true, + PID: 12345, + } + + output := captureOutput(func() { + FormatBatchResult(result) + }) + + assert.Contains(t, output, "api", "Should show service name") + assert.Contains(t, output, "started", "Should show action") + assert.Contains(t, output, "12345", "Should show PID") +} + +// TestFormatBatchResult_Stop formats successful stop result +func TestFormatBatchResult_Stop(t *testing.T) { + result := BatchResult{ + Service: "worker", + Action: "stop", + Success: true, + } + + output := captureOutput(func() { + FormatBatchResult(result) + }) + + assert.Contains(t, output, "worker", "Should show service name") + assert.Contains(t, output, "stopped", "Should show action") +} + +// TestFormatBatchResult_Restart formats successful restart result +func TestFormatBatchResult_Restart(t *testing.T) { + result := BatchResult{ + Service: "frontend", + Action: "restart", + Success: true, + PID: 54321, + } + + output := captureOutput(func() { + FormatBatchResult(result) + }) + + assert.Contains(t, output, "frontend", "Should show service name") + assert.Contains(t, output, "restarted", "Should show action") + assert.Contains(t, output, "54321", "Should show new PID") +} + +// TestFormatBatchResult_Failure formats error result +func TestFormatBatchResult_Failure(t *testing.T) { + result := BatchResult{ + Service: "database", + Action: "start", + Success: false, + Error: "service not found", + } + + output := captureOutput(func() { + FormatBatchResult(result) + }) + + assert.Contains(t, output, "database", "Should show service name") + assert.Contains(t, output, "not found", "Should show error message") +} + +// TestFormatBatchResult_Warning formats warning result +func TestFormatBatchResult_Warning(t *testing.T) { + result := BatchResult{ + Service: "api", + Action: "start", + Success: false, + Warning: "already running with PID 12345", + } + + output := captureOutput(func() { + FormatBatchResult(result) + }) + + assert.Contains(t, output, "api", "Should show service name") + assert.Contains(t, output, "Warning", "Should indicate warning") + assert.Contains(t, output, "already running", "Should show warning message") +} + +// TestFormatBatchResults_Multiple formats multiple results in order +func TestFormatBatchResults_Multiple(t *testing.T) { + results := []BatchResult{ + {Service: "api", Action: "start", Success: true, PID: 11111}, + {Service: "worker", Action: "start", Success: true, PID: 22222}, + {Service: "frontend", Action: "start", Success: false, Error: "not found"}, + } + + output := captureOutput(func() { + FormatBatchResults(results) + }) + + // Check that results appear in order + apiPos := findSubstring(output, "api") + workerPos := findSubstring(output, "worker") + frontendPos := findSubstring(output, "frontend") + + assert.Less(t, apiPos, workerPos, "api should appear before worker") + assert.Less(t, workerPos, frontendPos, "worker should appear before frontend") +} + +// TestFormatBatchResults_PatternExpansion shows pattern match count +func TestFormatBatchResults_PatternExpansion(t *testing.T) { + results := []BatchResult{ + {Service: "web-api", Action: "start", Success: true, PID: 11111}, + {Service: "web-frontend", Action: "start", Success: true, PID: 22222}, + } + + output := captureOutput(func() { + FormatBatchResultsWithPattern(results, "web-*") + }) + + assert.Contains(t, output, "Pattern 'web-*' matched 2 services", "Should show pattern match count") + assert.Contains(t, output, "web-api", "Should show first service") + assert.Contains(t, output, "web-frontend", "Should show second service") +} + +// TestFormatBatchResults_AllSuccess shows summary +func TestFormatBatchResults_AllSuccess(t *testing.T) { + results := []BatchResult{ + {Service: "api", Action: "start", Success: true, PID: 11111}, + {Service: "worker", Action: "start", Success: true, PID: 22222}, + } + + output := captureOutput(func() { + FormatBatchResults(results) + }) + + assert.Contains(t, output, "All services started successfully", "Should show success summary") +} + +// TestFormatBatchResults_PartialFailure shows failure count +func TestFormatBatchResults_PartialFailure(t *testing.T) { + results := []BatchResult{ + {Service: "api", Action: "start", Success: true, PID: 11111}, + {Service: "invalid", Action: "start", Success: false, Error: "not found"}, + } + + output := captureOutput(func() { + FormatBatchResults(results) + }) + + assert.Contains(t, output, "1 of 2 services failed", "Should show failure summary") +} + +// TestFormatBatchResults_AllFailure shows error summary +func TestFormatBatchResults_AllFailure(t *testing.T) { + results := []BatchResult{ + {Service: "svc1", Action: "start", Success: false, Error: "error1"}, + {Service: "svc2", Action: "start", Success: false, Error: "error2"}, + } + + output := captureOutput(func() { + FormatBatchResults(results) + }) + + assert.Contains(t, output, "All 2 services failed", "Should show all failed summary") +} + +// Helper function to capture stdout +func captureOutput(fn func()) string { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + w.Close() + os.Stdout = old + + var buf bytes.Buffer + io.Copy(&buf, r) + return buf.String() +} + +// Helper function to find substring position +func findSubstring(s, substr string) int { + return bytes.Index([]byte(s), []byte(substr)) +} diff --git a/pkg/cli/parser.go b/pkg/cli/parser.go new file mode 100644 index 0000000..ee7a77e --- /dev/null +++ b/pkg/cli/parser.go @@ -0,0 +1,85 @@ +package cli + +import ( + "fmt" + "regexp" + "strconv" + + "github.com/devports/devpt/pkg/models" +) + +// ParseNamePortIdentifier parses "name:port" format +// Returns (name, port, hasPort) tuple +// Examples: +// - "web-api:3000" → ("web-api", 3000, true) +// - "some:thing:1234" → ("some:thing", 1234, true) - last colon is port separator +// - "web-api" → ("web-api", 0, false) +func ParseNamePortIdentifier(arg string) (name string, port int, hasPort bool) { + if arg == "" { + return "", 0, false + } + + // Regex to find the last colon followed by digits (port) + // This handles service names with colons in them (e.g., "some:thing") + // Also handles edge case of just ":port" (empty name) + re := regexp.MustCompile(`^(.*):(\d+)$`) + matches := re.FindStringSubmatch(arg) + + if matches == nil { + return arg, 0, false + } + + port, err := strconv.Atoi(matches[2]) + if err != nil { + return arg, 0, false + } + + return matches[1], port, true +} + +// LookupServiceWithFallback tries name+port match, then exact name match +// Returns (service, errorMessages) where errorMessages contains details of failed attempts +// Examples: +// - "web-api:3000" with web-api on port 3000 → (service, nil) +// - "some:thing" with service named "some:thing" → (service, nil) - literal name match +// - "foo:5678" with no matches → (nil, ["tried name=foo port=5678 (not found)", "tried name=foo:5678 (not found)"]) +func LookupServiceWithFallback(identifier string, services []*models.ManagedService) (*models.ManagedService, []string) { + if identifier == "" { + return nil, []string{"empty identifier"} + } + + name, port, hasPort := ParseNamePortIdentifier(identifier) + errors := []string{} + + if hasPort { + // Try: name + port match + for _, svc := range services { + if svc.Name == name { + for _, p := range svc.Ports { + if p == port { + return svc, nil + } + } + } + } + errors = append(errors, fmt.Sprintf("tried name=%s port=%d (not found)", name, port)) + + // Try: exact name match (for services with colons in literal names) + for _, svc := range services { + if svc.Name == identifier { + return svc, nil + } + } + errors = append(errors, fmt.Sprintf("tried name=%s (not found)", identifier)) + return nil, errors + } + + // No port: try exact name match only + for _, svc := range services { + if svc.Name == identifier { + return svc, nil + } + } + errors = append(errors, fmt.Sprintf("tried name=%s (not found)", identifier)) + return nil, errors +} diff --git a/pkg/cli/parser_test.go b/pkg/cli/parser_test.go new file mode 100644 index 0000000..6c39885 --- /dev/null +++ b/pkg/cli/parser_test.go @@ -0,0 +1,222 @@ +package cli + +import ( + "testing" + + "github.com/devports/devpt/pkg/models" +) + +func TestParseNamePortIdentifier(t *testing.T) { + tests := []struct { + name string + input string + wantName string + wantPort int + wantHasPort bool + }{ + { + name: "simple name:port", + input: "web-api:3000", + wantName: "web-api", + wantPort: 3000, + wantHasPort: true, + }, + { + name: "name with colon in it", + input: "some:thing:1234", + wantName: "some:thing", + wantPort: 1234, + wantHasPort: true, + }, + { + name: "name only - no colon", + input: "web-api", + wantName: "web-api", + wantPort: 0, + wantHasPort: false, + }, + { + name: "empty string", + input: "", + wantName: "", + wantPort: 0, + wantHasPort: false, + }, + { + name: "single port number", + input: ":8080", + wantName: "", + wantPort: 8080, + wantHasPort: true, + }, + { + name: "name:port with leading zeros", + input: "web-api:0300", + wantName: "web-api", + wantPort: 300, + wantHasPort: true, + }, + { + name: "invalid port - not a number after colon", + input: "web-api:abc", + wantName: "web-api:abc", + wantPort: 0, + wantHasPort: false, + }, + { + name: "multiple colons but last is not port", + input: "some:thing:else", + wantName: "some:thing:else", + wantPort: 0, + wantHasPort: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotName, gotPort, gotHasPort := ParseNamePortIdentifier(tt.input) + if gotName != tt.wantName { + t.Errorf("ParseNamePortIdentifier() name = %v, want %v", gotName, tt.wantName) + } + if gotPort != tt.wantPort { + t.Errorf("ParseNamePortIdentifier() port = %v, want %v", gotPort, tt.wantPort) + } + if gotHasPort != tt.wantHasPort { + t.Errorf("ParseNamePortIdentifier() hasPort = %v, want %v", gotHasPort, tt.wantHasPort) + } + }) + } +} + +func TestLookupServiceWithFallback(t *testing.T) { + services := []*models.ManagedService{ + {Name: "web-api", Ports: []int{3000, 3001}}, + {Name: "worker", Ports: []int{5000}}, + {Name: "some:thing", Ports: []int{4000}}, // Service with colon in literal name + {Name: "database", Ports: []int{5432}}, + } + + tests := []struct { + name string + identifier string + wantServiceName string + wantErrors bool + errorCount int + }{ + { + name: "name:port exact match", + identifier: "web-api:3000", + wantServiceName: "web-api", + wantErrors: false, + }, + { + name: "name:port second port match", + identifier: "web-api:3001", + wantServiceName: "web-api", + wantErrors: false, + }, + { + name: "literal name with colon", + identifier: "some:thing", + wantServiceName: "some:thing", + wantErrors: false, + }, + { + name: "name:port with literal name fallback", + identifier: "some:thing:4000", + wantServiceName: "some:thing", + wantErrors: false, + }, + { + name: "simple name match", + identifier: "worker", + wantServiceName: "worker", + wantErrors: false, + }, + { + name: "name:port not found - both attempts fail", + identifier: "foo:5678", + wantServiceName: "", + wantErrors: true, + errorCount: 2, // name+port attempt + literal name attempt + }, + { + name: "name only not found", + identifier: "nonexistent", + wantServiceName: "", + wantErrors: true, + errorCount: 1, + }, + { + name: "empty identifier", + identifier: "", + wantServiceName: "", + wantErrors: true, + errorCount: 1, + }, + { + name: "name:port with wrong port number", + identifier: "web-api:9999", + wantServiceName: "", + wantErrors: true, + errorCount: 2, // name+port attempt fails + literal name attempt fails (no service named "web-api:9999") + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotService, gotErrors := LookupServiceWithFallback(tt.identifier, services) + + if tt.wantServiceName != "" { + if gotService == nil { + t.Errorf("LookupServiceWithFallback() returned nil service, want %q", tt.wantServiceName) + return + } + if gotService.Name != tt.wantServiceName { + t.Errorf("LookupServiceWithFallback() service = %q, want %q", gotService.Name, tt.wantServiceName) + } + } else { + if gotService != nil { + t.Errorf("LookupServiceWithFallback() returned service %q, want nil", gotService.Name) + } + } + + if tt.wantErrors { + if len(gotErrors) == 0 { + t.Errorf("LookupServiceWithFallback() returned no errors, expected %d", tt.errorCount) + } + if tt.errorCount > 0 && len(gotErrors) != tt.errorCount { + t.Errorf("LookupServiceWithFallback() error count = %d, want %d", len(gotErrors), tt.errorCount) + } + } else { + if len(gotErrors) != 0 { + t.Errorf("LookupServiceWithFallback() returned errors: %v", gotErrors) + } + } + }) + } +} + +func TestLookupServiceWithFallback_EmptyServices(t *testing.T) { + services := []*models.ManagedService{} + + t.Run("empty service list with name:port", func(t *testing.T) { + gotService, gotErrors := LookupServiceWithFallback("web-api:3000", services) + if gotService != nil { + t.Errorf("expected nil service, got %q", gotService.Name) + } + if len(gotErrors) != 2 { + t.Errorf("expected 2 errors, got %d: %v", len(gotErrors), gotErrors) + } + }) + + t.Run("empty service list with name only", func(t *testing.T) { + gotService, gotErrors := LookupServiceWithFallback("web-api", services) + if gotService != nil { + t.Errorf("expected nil service, got %q", gotService.Name) + } + if len(gotErrors) != 1 { + t.Errorf("expected 1 error, got %d: %v", len(gotErrors), gotErrors) + } + }) +} diff --git a/pkg/cli/pattern.go b/pkg/cli/pattern.go new file mode 100644 index 0000000..b3dadfa --- /dev/null +++ b/pkg/cli/pattern.go @@ -0,0 +1,75 @@ +package cli + +import ( + "path/filepath" + "strings" + + "github.com/devports/devpt/pkg/models" +) + +// ExpandPatterns expands glob patterns against service names. +// Only supports '*' wildcard (no regex or tag patterns). +// Returns patterns with no matches unchanged for error detection. +// Preserves argument order and duplicates. +func ExpandPatterns(args []string, services []*models.ManagedService) []string { + if len(args) == 0 { + return []string{} + } + + // Build a set of all service names for quick lookup + serviceNames := make(map[string]bool) + for _, svc := range services { + serviceNames[svc.Name] = true + } + + var result []string + + for _, arg := range args { + // If no wildcard, treat as literal + if !strings.Contains(arg, "*") { + result = append(result, arg) + continue + } + + // Expand pattern + matches := expandPattern(arg, serviceNames) + if len(matches) == 0 { + // No matches: return original pattern for error detection + result = append(result, arg) + } else { + // Add all matches in sorted order for consistency + result = append(result, matches...) + } + } + + return result +} + +// expandPattern expands a single glob pattern against service names. +// Returns sorted matches for consistent ordering within a pattern. +func expandPattern(pattern string, serviceNames map[string]bool) []string { + var matches []string + + for name := range serviceNames { + matched, err := filepath.Match(pattern, name) + if err != nil { + // Invalid pattern: treat as no match + continue + } + if matched { + matches = append(matches, name) + } + } + + // Sort matches for consistent ordering + // Use simple bubble sort for small lists (most registries have < 100 services) + for i := 0; i < len(matches)-1; i++ { + for j := i + 1; j < len(matches); j++ { + if matches[i] > matches[j] { + matches[i], matches[j] = matches[j], matches[i] + } + } + } + + return matches +} diff --git a/pkg/cli/pattern_test.go b/pkg/cli/pattern_test.go new file mode 100644 index 0000000..d11013e --- /dev/null +++ b/pkg/cli/pattern_test.go @@ -0,0 +1,225 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/devports/devpt/pkg/models" + "github.com/stretchr/testify/assert" +) + +// TestExpandPatterns_NoPattern returns literal arguments unchanged +func TestExpandPatterns_NoPattern(t *testing.T) { + services := []*models.ManagedService{ + {Name: "api"}, + {Name: "worker"}, + {Name: "frontend"}, + } + + args := []string{"api", "worker"} + result := ExpandPatterns(args, services) + + assert.Equal(t, []string{"api", "worker"}, result, "Literal service names should pass through unchanged") +} + +// TestExpandPatterns_SingleWildcard matches prefix pattern +func TestExpandPatterns_SingleWildcard(t *testing.T) { + services := []*models.ManagedService{ + {Name: "web-api"}, + {Name: "web-frontend"}, + {Name: "worker"}, + } + + args := []string{"web-*"} + result := ExpandPatterns(args, services) + + // Should match web-api and web-frontend + assert.Len(t, result, 2, "Pattern 'web-*' should match 2 services") + assert.Contains(t, result, "web-api", "Should match web-api") + assert.Contains(t, result, "web-frontend", "Should match web-frontend") + assert.NotContains(t, result, "worker", "Should not match worker") +} + +// TestExpandPatterns_SuffixWildcard matches suffix pattern +func TestExpandPatterns_SuffixWildcard(t *testing.T) { + services := []*models.ManagedService{ + {Name: "frontend-api"}, + {Name: "backend-api"}, + {Name: "api-gateway"}, + } + + args := []string{"*-api"} + result := ExpandPatterns(args, services) + + assert.Len(t, result, 2, "Pattern '*-api' should match 2 services") + assert.Contains(t, result, "frontend-api", "Should match frontend-api") + assert.Contains(t, result, "backend-api", "Should match backend-api") + assert.NotContains(t, result, "api-gateway", "Should not match api-gateway") +} + +// TestExpandPatterns_ContainsWildcard matches anywhere in string +func TestExpandPatterns_ContainsWildcard(t *testing.T) { + services := []*models.ManagedService{ + {Name: "frontend-api"}, + {Name: "backend-api"}, + {Name: "api-gateway"}, + } + + args := []string{"*api*"} + result := ExpandPatterns(args, services) + + assert.Len(t, result, 3, "Pattern '*api*' should match all 3 services") + assert.Contains(t, result, "frontend-api", "Should match frontend-api") + assert.Contains(t, result, "backend-api", "Should match backend-api") + assert.Contains(t, result, "api-gateway", "Should match api-gateway") +} + +// TestExpandPatterns_WildcardMatchesAll matches everything +func TestExpandPatterns_WildcardMatchesAll(t *testing.T) { + services := []*models.ManagedService{ + {Name: "api"}, + {Name: "worker"}, + {Name: "frontend"}, + } + + args := []string{"*"} + result := ExpandPatterns(args, services) + + assert.Len(t, result, 3, "Pattern '*' should match all services") + assert.Contains(t, result, "api") + assert.Contains(t, result, "worker") + assert.Contains(t, result, "frontend") +} + +// TestExpandPatterns_NoMatches returns original pattern for error handling +func TestExpandPatterns_NoMatches(t *testing.T) { + services := []*models.ManagedService{ + {Name: "api"}, + {Name: "worker"}, + } + + args := []string{"nonexistent-*"} + result := ExpandPatterns(args, services) + + // Pattern with no matches should return original for error detection + assert.Equal(t, []string{"nonexistent-*"}, result, "Pattern with no matches should return original") +} + +// TestExpandPatterns_CombinedPatternsAndLiteral expands patterns then combines with literals +func TestExpandPatterns_CombinedPatternsAndLiteral(t *testing.T) { + services := []*models.ManagedService{ + {Name: "web-api"}, + {Name: "web-frontend"}, + {Name: "worker"}, + {Name: "database"}, + } + + args := []string{"web-*", "worker", "database"} + result := ExpandPatterns(args, services) + + assert.Len(t, result, 4, "Should combine pattern matches with literal names") + assert.Contains(t, result, "web-api") + assert.Contains(t, result, "web-frontend") + assert.Contains(t, result, "worker") + assert.Contains(t, result, "database") +} + +// TestExpandPatterns_EmptyArgs returns empty result +func TestExpandPatterns_EmptyArgs(t *testing.T) { + services := []*models.ManagedService{ + {Name: "api"}, + } + + args := []string{} + result := ExpandPatterns(args, services) + + assert.Empty(t, result, "Empty args should return empty result") +} + +// TestExpandPatterns_MultiplePatterns each expands independently +func TestExpandPatterns_MultiplePatterns(t *testing.T) { + services := []*models.ManagedService{ + {Name: "web-api"}, + {Name: "web-frontend"}, + {Name: "worker-api"}, + {Name: "database"}, + } + + args := []string{"web-*", "*-api"} + result := ExpandPatterns(args, services) + + // Should have: web-api, web-frontend (from web-*) and web-api, worker-api (from *-api) + // Duplicates should be preserved for now (order matters for batch execution) + assert.Contains(t, result, "web-api") + assert.Contains(t, result, "web-frontend") + assert.Contains(t, result, "worker-api") +} + +// TestExpandPatterns_PreservesOrder maintains argument order +func TestExpandPatterns_PreservesOrder(t *testing.T) { + services := []*models.ManagedService{ + {Name: "a-service"}, + {Name: "b-service"}, + {Name: "c-service"}, + } + + args := []string{"b-*", "a-*", "c-*"} + result := ExpandPatterns(args, services) + + // Order should be: b matches first, then a matches, then c matches + firstB := -1 + firstA := -1 + firstC := -1 + + for i, name := range result { + if strings.HasPrefix(name, "b") && firstB == -1 { + firstB = i + } + if strings.HasPrefix(name, "a") && firstA == -1 { + firstA = i + } + if strings.HasPrefix(name, "c") && firstC == -1 { + firstC = i + } + } + + assert.Less(t, firstB, firstA, "b-service should appear before a-service") + assert.Less(t, firstA, firstC, "a-service should appear before c-service") +} + +// TestExpandPatterns_EmptyRegistry returns patterns unchanged when no services exist +func TestExpandPatterns_EmptyRegistry(t *testing.T) { + services := []*models.ManagedService{} + + args := []string{"api", "web-*"} + result := ExpandPatterns(args, services) + + assert.Equal(t, []string{"api", "web-*"}, result, "With empty registry, patterns should return unchanged") +} + +// TestExpandPatterns_DuplicateArgs preserves duplicates +func TestExpandPatterns_DuplicateArgs(t *testing.T) { + services := []*models.ManagedService{ + {Name: "api"}, + } + + args := []string{"api", "api"} + result := ExpandPatterns(args, services) + + assert.Equal(t, []string{"api", "api"}, result, "Duplicate arguments should be preserved") +} + +// TestExpandPatterns_CaseSensitive performs case-sensitive matching +func TestExpandPatterns_CaseSensitive(t *testing.T) { + services := []*models.ManagedService{ + {Name: "API"}, + {Name: "api"}, + {Name: "Api"}, + } + + args := []string{"API"} + result := ExpandPatterns(args, services) + + assert.Len(t, result, 1, "Should match exact case only") + assert.Equal(t, "API", result[0], "Should match only API (uppercase)") +} diff --git a/pkg/cli/tui.go b/pkg/cli/tui.go index 78e386e..8a43772 100644 --- a/pkg/cli/tui.go +++ b/pkg/cli/tui.go @@ -1,1343 +1,8 @@ package cli -import ( - "errors" - "fmt" - "sort" - "strconv" - "strings" - "time" +import tuipkg "github.com/devports/devpt/pkg/cli/tui" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/mattn/go-runewidth" - - "github.com/devports/devpt/pkg/health" - "github.com/devports/devpt/pkg/models" - "github.com/devports/devpt/pkg/process" -) - -// TopCmd starts the interactive TUI mode (like 'top') +// TopCmd starts the interactive TUI mode (like 'top'). func (a *App) TopCmd() error { - model := newTopModel(a) - p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion()) - _, err := p.Run() - return err -} - -type viewMode int -type viewFocus int -type sortMode int -type confirmKind int - -const ( - viewModeTable viewMode = iota - viewModeLogs - viewModeCommand - viewModeSearch - viewModeHelp - viewModeConfirm -) - -const ( - focusRunning viewFocus = iota - focusManaged -) - -const ( - sortRecent sortMode = iota - sortName - sortProject - sortPort - sortHealth - sortModeCount -) - -const ( - confirmStopPID confirmKind = iota - confirmRemoveService - confirmSudoKill -) - -type confirmState struct { - kind confirmKind - prompt string - pid int - name string - serviceName string -} - -// topModel represents the TUI state. -type topModel struct { - app *App - servers []*models.ServerInfo - width int - height int - lastUpdate time.Time - lastInput time.Time - err error - - selected int - managedSel int - focus viewFocus - mode viewMode - - logLines []string - logErr error - logSvc *models.ManagedService - logPID int - followLogs bool - - cmdInput string - searchQuery string - cmdStatus string - - health map[int]string - healthDetails map[int]*health.HealthCheck - showHealthDetail bool - healthBusy bool - healthLast time.Time - healthChk *health.Checker - - sortBy sortMode - - starting map[string]time.Time - removed map[string]*models.ManagedService - - confirm *confirmState -} - -func newTopModel(app *App) topModel { - m := topModel{ - app: app, - lastUpdate: time.Now(), - lastInput: time.Now(), - mode: viewModeTable, - focus: focusRunning, - followLogs: true, - health: make(map[int]string), - healthDetails: make(map[int]*health.HealthCheck), - healthChk: health.NewChecker(800 * time.Millisecond), - sortBy: sortRecent, - starting: make(map[string]time.Time), - removed: make(map[string]*models.ManagedService), - } - if servers, err := app.discoverServers(); err == nil { - m.servers = servers - } - return m -} - -func (m topModel) Init() tea.Cmd { - return tickCmd() -} - -func (m topModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - m.lastInput = time.Now() - if m.mode == viewModeCommand { - switch msg.String() { - case "esc": - m.mode = viewModeTable - m.cmdInput = "" - return m, nil - case "enter": - m.cmdStatus = m.runCommand(strings.TrimSpace(m.cmdInput)) - m.cmdInput = "" - m.mode = viewModeTable - m.refresh() - return m, nil - case "backspace": - if len(m.cmdInput) > 0 { - m.cmdInput = m.cmdInput[:len(m.cmdInput)-1] - } - return m, nil - } - for _, r := range msg.Runes { - if r >= 32 && r != 127 { - m.cmdInput += string(r) - } - } - return m, nil - } - if m.mode == viewModeSearch { - switch msg.String() { - case "esc": - m.mode = viewModeTable - m.searchQuery = "" - return m, nil - case "enter": - m.mode = viewModeTable - return m, nil - case "backspace": - if len(m.searchQuery) > 0 { - m.searchQuery = m.searchQuery[:len(m.searchQuery)-1] - } - return m, nil - } - for _, r := range msg.Runes { - if r >= 32 && r != 127 { - m.searchQuery += string(r) - } - } - return m, nil - } - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - case "tab": - if m.mode == viewModeTable { - if m.focus == focusRunning { - m.focus = focusManaged - } else { - m.focus = focusRunning - } - } - return m, nil - case "?", "f1": - if m.mode == viewModeTable { - m.mode = viewModeHelp - } - return m, nil - case "/": - if m.mode == viewModeTable { - m.mode = viewModeSearch - } - return m, nil - case "ctrl+l": - if m.mode == viewModeTable { - m.searchQuery = "" - m.cmdStatus = "Filter cleared" - } - return m, nil - case "s": - if m.mode == viewModeTable { - m.sortBy = (m.sortBy + 1) % sortModeCount - } - return m, nil - case "h": - if m.mode == viewModeTable { - m.showHealthDetail = !m.showHealthDetail - } - return m, nil - case "f": - if m.mode == viewModeLogs { - m.followLogs = !m.followLogs - } - return m, nil - case "ctrl+a": - if m.mode == viewModeTable { - m.mode = viewModeCommand - m.cmdInput = "add " - } - return m, nil - case "ctrl+r": - if m.mode == viewModeTable { - m.cmdStatus = m.restartSelected() - m.refresh() - } - return m, nil - case "ctrl+e": - if m.mode == viewModeTable { - m.prepareStopConfirm() - } - return m, nil - case "x", "delete", "ctrl+d": - if m.mode == viewModeTable && m.focus == focusManaged { - managed := m.managedServices() - if m.managedSel >= 0 && m.managedSel < len(managed) { - name := managed[m.managedSel].Name - m.confirm = &confirmState{ - kind: confirmRemoveService, - prompt: fmt.Sprintf("Remove %q from registry?", name), - name: name, - } - m.mode = viewModeConfirm - } else { - m.cmdStatus = "No managed service selected" - } - } - return m, nil - case ":", "shift+;", ";", "c": - if m.mode == viewModeTable { - m.mode = viewModeCommand - m.cmdInput = "" - } - return m, nil - case "esc": - switch m.mode { - case viewModeLogs: - m.mode = viewModeTable - m.logLines = nil - m.logErr = nil - m.logSvc = nil - m.logPID = 0 - case viewModeHelp, viewModeConfirm: - m.mode = viewModeTable - m.confirm = nil - } - return m, nil - case "b": - if m.mode == viewModeLogs { - m.mode = viewModeTable - m.logLines = nil - m.logErr = nil - m.logSvc = nil - m.logPID = 0 - return m, nil - } - return m, nil - case "backspace": - return m, nil - case "up", "k": - if m.mode == viewModeTable { - if m.focus == focusRunning && m.selected > 0 { - m.selected-- - } - if m.focus == focusManaged && m.managedSel > 0 { - m.managedSel-- - } - } - return m, nil - case "down", "j": - if m.mode == viewModeTable { - if m.focus == focusRunning { - if m.selected < len(m.visibleServers())-1 { - m.selected++ - } - } - if m.focus == focusManaged { - if m.managedSel < len(m.managedServices())-1 { - m.managedSel++ - } - } - } - return m, nil - case "y": - if m.mode == viewModeConfirm { - cmd := m.executeConfirm(true) - return m, cmd - } - return m, nil - case "n": - if m.mode == viewModeConfirm { - cmd := m.executeConfirm(false) - return m, cmd - } - return m, nil - case "enter": - switch m.mode { - case viewModeConfirm: - cmd := m.executeConfirm(true) - return m, cmd - case viewModeTable: - if m.focus == focusManaged { - managed := m.managedServices() - if m.managedSel >= 0 && m.managedSel < len(managed) { - if err := m.app.StartCmd(managed[m.managedSel].Name); err != nil { - m.cmdStatus = err.Error() - } else { - name := managed[m.managedSel].Name - m.cmdStatus = fmt.Sprintf("Started %q", name) - m.starting[name] = time.Now() - } - m.refresh() - return m, nil - } - } - if m.focus == focusRunning { - visible := m.visibleServers() - if m.selected >= 0 && m.selected < len(visible) { - srv := visible[m.selected] - if srv.ManagedService == nil { - m.mode = viewModeLogs - m.logSvc = nil - m.logPID = srv.ProcessRecord.PID - return m, m.tailLogsCmd() - } - m.mode = viewModeLogs - m.logSvc = srv.ManagedService - m.logPID = 0 - return m, m.tailLogsCmd() - } - } - return m, nil - } - return m, nil - default: - if m.mode == viewModeCommand && len(msg.Runes) == 1 { - r := msg.Runes[0] - if r >= 32 && r != 127 { - m.cmdInput += string(r) - } - return m, nil - } - if m.mode == viewModeSearch && len(msg.Runes) == 1 { - r := msg.Runes[0] - if r >= 32 && r != 127 { - m.searchQuery += string(r) - } - return m, nil - } - return m, nil - } - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - return m, nil - case tickMsg: - m.refresh() - if m.mode == viewModeLogs && m.followLogs { - return m, m.tailLogsCmd() - } - if m.mode == viewModeTable && !m.healthBusy && time.Since(m.healthLast) > 2*time.Second && time.Since(m.lastInput) > 900*time.Millisecond { - m.healthBusy = true - return m, m.healthCmd() - } - return m, tickCmd() - case logMsg: - m.logLines = msg.lines - m.logErr = msg.err - return m, tickCmd() - case healthMsg: - m.healthBusy = false - if msg.err == nil { - m.health = msg.icons - m.healthDetails = msg.details - m.healthLast = time.Now() - } - return m, tickCmd() - } - return m, nil -} - -func (m *topModel) refresh() { - if servers, err := m.app.discoverServers(); err == nil { - m.servers = servers - m.lastUpdate = time.Now() - if m.selected >= len(m.visibleServers()) && len(m.visibleServers()) > 0 { - m.selected = len(m.visibleServers()) - 1 - } - if m.managedSel >= len(m.managedServices()) && len(m.managedServices()) > 0 { - m.managedSel = len(m.managedServices()) - 1 - } - for name, at := range m.starting { - if m.isServiceRunning(name) || time.Since(at) > 45*time.Second { - delete(m.starting, name) - } - } - } else { - m.err = err - } -} - -func (m topModel) View() string { - if m.err != nil { - return fmt.Sprintf("Error: %v\nPress 'q' to quit\n", m.err) - } - - width := m.width - if width <= 0 { - width = 120 - } - - var b strings.Builder - headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true) - - // Ensure stale lines are removed when viewport shrinks/resizes. - b.WriteString("\x1b[H\x1b[2J") - b.WriteString("\n") - if m.mode == viewModeLogs { - name := "-" - if m.logSvc != nil { - name = m.logSvc.Name - } else if m.logPID > 0 { - name = fmt.Sprintf("pid:%d", m.logPID) - } - b.WriteString(headerStyle.Render(fmt.Sprintf("Logs: %s (b back, f follow:%t)", name, m.followLogs))) - } else { - b.WriteString(headerStyle.Render("Dev Process Tracker - Health Monitor (q quit)")) - } - b.WriteString("\n\n") - if m.mode == viewModeTable || m.mode == viewModeCommand || m.mode == viewModeSearch || m.mode == viewModeConfirm { - focus := "running" - if m.focus == focusManaged { - focus = "managed" - } - filter := m.searchQuery - if strings.TrimSpace(filter) == "" { - filter = "none" - } - ctx := fmt.Sprintf("Focus: %s | Sort: %s | Filter: %s", focus, sortModeLabel(m.sortBy), filter) - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(fitLine(ctx, width))) - b.WriteString("\n\n") - } - - switch m.mode { - case viewModeHelp: - b.WriteString(m.renderHelp(width)) - case viewModeLogs: - b.WriteString(m.renderLogs(width)) - default: - rowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("15")) - b.WriteString(rowStyle.Render(m.renderTable(width))) - b.WriteString("\n\n") - b.WriteString(m.renderManaged(width)) - } - - if m.mode == viewModeCommand { - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render(fitLine(":"+m.cmdInput, width))) - b.WriteString("\n") - hint := `Example: add my-app ~/projects/my-app "npm run dev" 3000` - if strings.HasPrefix(strings.TrimSpace(m.cmdInput), "add") { - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(fitLine(hint, width))) - b.WriteString("\n") - } - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(fitLine("Esc to go back", width))) - b.WriteString("\n") - } - if m.mode == viewModeSearch { - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render(fitLine("/"+m.searchQuery, width))) - b.WriteString("\n") - } - if m.mode == viewModeConfirm && m.confirm != nil { - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("11")).Bold(true).Render(fitLine(m.confirm.prompt+" [y/N]", width))) - b.WriteString("\n") - } - if m.cmdStatus != "" { - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(fitLine(m.cmdStatus, width))) - b.WriteString("\n") - } - - b.WriteString("\n") - footer := fmt.Sprintf("Last updated: %s | Services: %d | Tab switch | Enter logs/start | x remove managed | / filter | ^L clear filter | s sort | ? help | ^A add ^R restart ^E stop", m.lastUpdate.Format("15:04:05"), m.countVisible()) - footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Italic(true) - for _, line := range wrapWords(footer, width) { - b.WriteString(footerStyle.Render(fitLine(line, width))) - b.WriteString("\n") - } - return b.String() -} - -func (m topModel) renderTable(width int) string { - visible := m.visibleServers() - displayNames := m.displayNames(visible) - nameW, portW, pidW, projectW, healthW := 14, 6, 7, 14, 7 - sep := 2 - used := nameW + sep + portW + sep + pidW + sep + projectW + sep + healthW + sep - cmdW := width - used - if cmdW < 12 { - cmdW = 12 - } - - var lines []string - header := fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s", - fixedCell("Name", nameW), strings.Repeat(" ", sep), - fixedCell("Port", portW), strings.Repeat(" ", sep), - fixedCell("PID", pidW), strings.Repeat(" ", sep), - fixedCell("Project", projectW), strings.Repeat(" ", sep), - fixedCell("Command", cmdW), strings.Repeat(" ", sep), - fixedCell("Health", healthW), - ) - divider := fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s", - fixedCell(strings.Repeat("─", nameW), nameW), strings.Repeat(" ", sep), - fixedCell(strings.Repeat("─", portW), portW), strings.Repeat(" ", sep), - fixedCell(strings.Repeat("─", pidW), pidW), strings.Repeat(" ", sep), - fixedCell(strings.Repeat("─", projectW), projectW), strings.Repeat(" ", sep), - fixedCell(strings.Repeat("─", cmdW), cmdW), strings.Repeat(" ", sep), - fixedCell(strings.Repeat("─", healthW), healthW), - ) - lines = append(lines, fitLine(header, width)) - lines = append(lines, fitLine(divider, width)) - - rowFirstLineIdx := make([]int, len(visible)) - for i, srv := range visible { - project := "-" - if srv.ProcessRecord != nil { - if srv.ProcessRecord.ProjectRoot != "" { - project = pathBase(srv.ProcessRecord.ProjectRoot) - } else if srv.ProcessRecord.CWD != "" { - project = pathBase(srv.ProcessRecord.CWD) - } - } - if project == "-" && srv.ManagedService != nil && srv.ManagedService.CWD != "" { - project = pathBase(srv.ManagedService.CWD) - } - - port := "-" - pid := 0 - cmd := "-" - icon := "…" - if srv.ProcessRecord != nil { - pid = srv.ProcessRecord.PID - cmd = srv.ProcessRecord.Command - if srv.ProcessRecord.Port > 0 { - port = fmt.Sprintf("%d", srv.ProcessRecord.Port) - if cached := m.health[srv.ProcessRecord.Port]; cached != "" { - icon = cached - } - } - } - - cmdLines := wrapRunes(cmd, cmdW) - if len(cmdLines) == 0 { - cmdLines = []string{"-"} - } - rowFirstLineIdx[i] = len(lines) - for j, c := range cmdLines { - if j == 0 { - line := fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s", - fixedCell(displayNames[i], nameW), strings.Repeat(" ", sep), - fixedCell(port, portW), strings.Repeat(" ", sep), - fixedCell(fmt.Sprintf("%d", pid), pidW), strings.Repeat(" ", sep), - fixedCell(project, projectW), strings.Repeat(" ", sep), - fixedCell(c, cmdW), strings.Repeat(" ", sep), - fixedCell(icon, healthW), - ) - lines = append(lines, fitLine(line, width)) - } else { - line := fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s", - fixedCell("", nameW), strings.Repeat(" ", sep), - fixedCell("", portW), strings.Repeat(" ", sep), - fixedCell("", pidW), strings.Repeat(" ", sep), - fixedCell("", projectW), strings.Repeat(" ", sep), - fixedCell(c, cmdW), strings.Repeat(" ", sep), - fixedCell("", healthW), - ) - lines = append(lines, fitLine(line, width)) - } - } - } - - if len(visible) == 0 { - if m.searchQuery != "" { - return fitLine("(no matching servers for filter)", width) - } - return fitLine("(no matching servers)", width) - } - - selectedLine := rowFirstLineIdx[m.selected] - if selectedLine >= 2 && selectedLine < len(lines) { - lines[selectedLine] = lipgloss.NewStyle().Background(lipgloss.Color("57")).Foreground(lipgloss.Color("15")).Render(lines[selectedLine]) - } - - out := strings.Join(lines, "\n") - if m.showHealthDetail { - if m.selected >= 0 && m.selected < len(visible) { - port := 0 - if visible[m.selected].ProcessRecord != nil { - port = visible[m.selected].ProcessRecord.Port - } - if d := m.healthDetails[port]; d != nil { - out += "\n" + fitLine(fmt.Sprintf("Health detail: %s %dms %s", health.StatusIcon(d.Status), d.ResponseMs, d.Message), width) - } - } - } - return out -} - -func fixedCell(s string, width int) string { - if width <= 0 { - return "" - } - if runewidth.StringWidth(s) > width { - return runewidth.Truncate(s, width, "") - } - return s + strings.Repeat(" ", width-runewidth.StringWidth(s)) -} - -func wrapRunes(s string, width int) []string { - if width <= 0 { - return []string{s} - } - if s == "" { - return []string{""} - } - var out []string - rest := s - for runewidth.StringWidth(rest) > width { - chunk := runewidth.Truncate(rest, width, "") - if chunk == "" { - break - } - out = append(out, chunk) - rest = strings.TrimPrefix(rest, chunk) - } - if rest != "" { - out = append(out, rest) - } - return out -} - -func wrapWords(s string, width int) []string { - if width <= 0 { - return []string{s} - } - words := strings.Fields(s) - if len(words) == 0 { - return []string{""} - } - lines := make([]string, 0, 4) - cur := words[0] - for _, w := range words[1:] { - candidate := cur + " " + w - if runewidth.StringWidth(candidate) <= width { - cur = candidate - continue - } - lines = append(lines, cur) - // If a single word is longer than width, fall back to rune wrapping. - if runewidth.StringWidth(w) > width { - chunks := wrapRunes(w, width) - if len(chunks) > 0 { - lines = append(lines, chunks[:len(chunks)-1]...) - cur = chunks[len(chunks)-1] - } else { - cur = w - } - } else { - cur = w - } - } - lines = append(lines, cur) - return lines -} - -func (m topModel) renderManaged(width int) string { - managed := m.managedServices() - if len(managed) == 0 { - return fitLine(`No managed services yet. Use ^A then: add myapp /path/to/app "npm run dev" 3000`, width) - } - - portOwners := make(map[int]int) - for _, svc := range managed { - for _, p := range svc.Ports { - portOwners[p]++ - } - } - - var b strings.Builder - b.WriteString(fitLine("Managed Services (Tab focus, Enter start)", width)) - b.WriteString("\n") - for i, svc := range managed { - state := m.serviceStatus(svc.Name) - if state == "stopped" { - if _, ok := m.starting[svc.Name]; ok { - state = "starting" - } - } - line := fmt.Sprintf("%s [%s]", svc.Name, state) - - conflicting := false - for _, p := range svc.Ports { - if portOwners[p] > 1 { - conflicting = true - break - } - } - if conflicting { - line = fmt.Sprintf("%s (port conflict)", line) - } else if len(svc.Ports) > 1 { - line = fmt.Sprintf("%s (ports: %v)", line, svc.Ports) - } - - line = fitLine(line, width) - if m.focus == focusManaged && i == m.managedSel { - line = lipgloss.NewStyle().Background(lipgloss.Color("57")).Foreground(lipgloss.Color("15")).Render(line) - } - b.WriteString(line) - b.WriteString("\n") - } - if m.focus == focusManaged && m.managedSel >= 0 && m.managedSel < len(managed) { - svc := managed[m.managedSel] - if reason := m.crashReasonForService(svc.Name); reason != "" { - b.WriteString(fitLine("Crash reason: "+reason, width)) - b.WriteString("\n") - } - } - return b.String() -} - -func (m topModel) renderLogs(width int) string { - if m.logErr != nil { - if errors.Is(m.logErr, process.ErrNoLogs) { - return "No devpt logs for this service yet.\nLogs are only captured when started by devpt.\n" - } - if errors.Is(m.logErr, process.ErrNoProcessLogs) { - return "No accessible logs for this process.\nIf it writes only to a terminal, there may be nothing to tail here.\n" - } - return fmt.Sprintf("Error: %v\n", m.logErr) - } - if len(m.logLines) == 0 { - return "(no logs yet)\n" - } - var b strings.Builder - for _, line := range m.logLines { - b.WriteString(fitLine(line, width)) - b.WriteString("\n") - } - return b.String() -} - -func (m topModel) renderHelp(width int) string { - lines := []string{ - "Keymap", - "q quit, Tab switch list, Enter logs/start, / filter, Ctrl+L clear filter, s sort, h health detail, ? help", - "Ctrl+A add command, Ctrl+R restart selected, Ctrl+E stop selected", - "Logs: b back, f toggle follow", - "Managed list: x remove selected service", - "Commands: add, start, stop, remove, restore, list, help", - } - var out []string - for _, l := range lines { - out = append(out, fitLine(l, width)) - } - return strings.Join(out, "\n") -} - -func (m topModel) countVisible() int { return len(m.visibleServers()) } - -func (m topModel) visibleServers() []*models.ServerInfo { - var visible []*models.ServerInfo - q := strings.ToLower(strings.TrimSpace(m.searchQuery)) - for _, srv := range m.servers { - if srv == nil || srv.ProcessRecord == nil { - continue - } - if srv.ManagedService == nil { - if srv.ProcessRecord.Port == 0 || !isRuntimeCommand(srv.ProcessRecord.Command) { - continue - } - } - if q != "" { - hay := strings.ToLower(fmt.Sprintf("%s %s %s %d %s %s", - m.serviceNameFor(srv), projectOf(srv), srv.ProcessRecord.Command, srv.ProcessRecord.Port, srv.ProcessRecord.CWD, srv.ProcessRecord.ProjectRoot)) - if !strings.Contains(hay, q) { - continue - } - } - visible = append(visible, srv) - } - m.sortServers(visible) - return visible -} - -func (m topModel) managedServices() []*models.ManagedService { - services := m.app.registry.ListServices() - q := strings.ToLower(strings.TrimSpace(m.searchQuery)) - var filtered []*models.ManagedService - for _, svc := range services { - if q == "" || strings.Contains(strings.ToLower(svc.Name+" "+svc.CWD+" "+svc.Command), q) { - filtered = append(filtered, svc) - } - } - sort.Slice(filtered, func(i, j int) bool { return strings.ToLower(filtered[i].Name) < strings.ToLower(filtered[j].Name) }) - return filtered -} - -func (m topModel) displayNames(servers []*models.ServerInfo) []string { - base := make([]string, len(servers)) - projectToSvc := make(map[string]string) - for _, svc := range m.app.registry.ListServices() { - cwd := strings.TrimRight(strings.TrimSpace(svc.CWD), "/") - if cwd != "" { - projectToSvc[cwd] = svc.Name - } - } - for i, srv := range servers { - base[i] = m.serviceNameFor(srv) - if base[i] == "-" && srv.ProcessRecord != nil { - root := strings.TrimRight(strings.TrimSpace(srv.ProcessRecord.ProjectRoot), "/") - cwd := strings.TrimRight(strings.TrimSpace(srv.ProcessRecord.CWD), "/") - if mapped := projectToSvc[root]; mapped != "" { - base[i] = mapped - } else if mapped := projectToSvc[cwd]; mapped != "" { - base[i] = mapped - } - } - } - - count := make(map[string]int) - for _, n := range base { - count[n]++ - } - type row struct{ idx, pid int } - group := make(map[string][]row) - for i, n := range base { - group[n] = append(group[n], row{idx: i, pid: pidOf(servers[i])}) - } - out := make([]string, len(base)) - for name, rows := range group { - if count[name] <= 1 || name == "-" { - for _, r := range rows { - out[r.idx] = name - } - continue - } - sort.Slice(rows, func(i, j int) bool { return rows[i].pid < rows[j].pid }) - for i, r := range rows { - out[r.idx] = fmt.Sprintf("%s~%d", name, i+1) - } - } - return out -} - -func (m topModel) sortServers(servers []*models.ServerInfo) { - switch m.sortBy { - case sortName: - sort.Slice(servers, func(i, j int) bool { - return strings.ToLower(m.serviceNameFor(servers[i])) < strings.ToLower(m.serviceNameFor(servers[j])) - }) - case sortProject: - sort.Slice(servers, func(i, j int) bool { - return strings.ToLower(projectOf(servers[i])) < strings.ToLower(projectOf(servers[j])) - }) - case sortPort: - sort.Slice(servers, func(i, j int) bool { return portOf(servers[i]) < portOf(servers[j]) }) - case sortHealth: - sort.Slice(servers, func(i, j int) bool { - return strings.Compare(m.health[portOf(servers[i])], m.health[portOf(servers[j])]) < 0 - }) - default: - sort.Slice(servers, func(i, j int) bool { return pidOf(servers[i]) > pidOf(servers[j]) }) - } -} - -func (m topModel) serviceNameFor(srv *models.ServerInfo) string { - if srv == nil { - return "-" - } - if srv.ManagedService != nil && srv.ManagedService.Name != "" { - return srv.ManagedService.Name - } - if srv.ProcessRecord != nil { - if srv.ProcessRecord.ProjectRoot != "" { - return pathBase(srv.ProcessRecord.ProjectRoot) - } - if srv.ProcessRecord.CWD != "" { - return pathBase(srv.ProcessRecord.CWD) - } - if srv.ProcessRecord.Command != "" { - return pathBase(srv.ProcessRecord.Command) - } - } - return "-" -} - -func (m topModel) runCommand(input string) string { - if input == "" { - return "" - } - args, err := parseArgs(input) - if err != nil || len(args) == 0 { - return "Invalid command" - } - switch args[0] { - case "help": - m.mode = viewModeHelp - return "" - case "list": - services := m.app.registry.ListServices() - if len(services) == 0 { - return "No managed services" - } - names := make([]string, 0, len(services)) - for _, svc := range services { - names = append(names, svc.Name) - } - sort.Strings(names) - return "Managed services: " + strings.Join(names, ", ") - case "add": - if len(args) < 4 { - return "Usage: add \"\" [ports...]" - } - name, cwd, cmd := args[1], args[2], args[3] - var ports []int - for _, p := range args[4:] { - port, perr := strconv.Atoi(p) - if perr != nil { - return "Invalid port: " + p - } - ports = append(ports, port) - } - if err := m.app.AddCmd(name, cwd, cmd, ports); err != nil { - return err.Error() - } - return fmt.Sprintf("Added %q", name) - case "remove", "rm": - if len(args) < 2 { - return "Usage: remove " - } - svc := m.app.registry.GetService(args[1]) - if svc == nil { - return fmt.Sprintf("service %q not found", args[1]) - } - m.confirm = &confirmState{kind: confirmRemoveService, prompt: fmt.Sprintf("Remove %q from registry?", svc.Name), name: svc.Name} - m.mode = viewModeConfirm - return "" - case "restore": - if len(args) < 2 { - return "Usage: restore " - } - svc := m.removed[args[1]] - if svc == nil { - return fmt.Sprintf("no removed service %q in this session", args[1]) - } - if err := m.app.AddCmd(svc.Name, svc.CWD, svc.Command, svc.Ports); err != nil { - return err.Error() - } - delete(m.removed, args[1]) - return fmt.Sprintf("Restored %q", args[1]) - case "start": - if len(args) < 2 { - return "Usage: start " - } - if err := m.app.StartCmd(args[1]); err != nil { - return err.Error() - } - m.starting[args[1]] = time.Now() - return fmt.Sprintf("Started %q", args[1]) - case "stop": - if len(args) < 2 { - return "Usage: stop " - } - if args[1] == "--port" { - if len(args) < 3 { - return "Usage: stop --port PORT" - } - if err := m.app.StopCmd(args[2]); err != nil { - return err.Error() - } - return fmt.Sprintf("Stopped port %s", args[2]) - } - if err := m.app.StopCmd(args[1]); err != nil { - return err.Error() - } - return fmt.Sprintf("Stopped %q", args[1]) - default: - return "Unknown command (type :help)" - } -} - -func (m topModel) startSelected() string { - visible := m.visibleServers() - if m.selected < 0 || m.selected >= len(visible) { - return "No service selected" - } - srv := visible[m.selected] - if srv.ManagedService == nil { - return "Selected process is not a managed service" - } - if err := m.app.StartCmd(srv.ManagedService.Name); err != nil { - return err.Error() - } - m.starting[srv.ManagedService.Name] = time.Now() - return fmt.Sprintf("Started %q", srv.ManagedService.Name) -} - -func (m topModel) restartSelected() string { - visible := m.visibleServers() - if m.selected < 0 || m.selected >= len(visible) { - return "No service selected" - } - srv := visible[m.selected] - if srv.ManagedService == nil { - return "Selected process is not a managed service" - } - if err := m.app.RestartCmd(srv.ManagedService.Name); err != nil { - return err.Error() - } - m.starting[srv.ManagedService.Name] = time.Now() - return fmt.Sprintf("Restarted %q", srv.ManagedService.Name) -} - -func (m *topModel) prepareStopConfirm() { - visible := m.visibleServers() - if m.selected < 0 || m.selected >= len(visible) { - m.cmdStatus = "No service selected" - return - } - srv := visible[m.selected] - if srv.ProcessRecord == nil || srv.ProcessRecord.PID == 0 { - m.cmdStatus = "No PID to stop" - return - } - prompt := fmt.Sprintf("Stop PID %d?", srv.ProcessRecord.PID) - serviceName := "" - if srv.ManagedService != nil { - prompt = fmt.Sprintf("Stop %q (PID %d)?", srv.ManagedService.Name, srv.ProcessRecord.PID) - serviceName = srv.ManagedService.Name - } - m.confirm = &confirmState{kind: confirmStopPID, prompt: prompt, pid: srv.ProcessRecord.PID, serviceName: serviceName} - m.mode = viewModeConfirm -} - -func (m *topModel) executeConfirm(yes bool) tea.Cmd { - if m.confirm == nil { - m.mode = viewModeTable - return nil - } - c := *m.confirm - m.confirm = nil - m.mode = viewModeTable - if !yes { - m.cmdStatus = "Cancelled" - return nil - } - switch c.kind { - case confirmStopPID: - if err := m.app.processManager.Stop(c.pid, 5*time.Second); err != nil { - if errors.Is(err, process.ErrNeedSudo) { - m.confirm = &confirmState{kind: confirmSudoKill, prompt: fmt.Sprintf("Run sudo kill -9 %d now?", c.pid), pid: c.pid} - m.mode = viewModeConfirm - return nil - } - if isProcessFinishedErr(err) { - m.cmdStatus = fmt.Sprintf("Process %d already exited", c.pid) - if c.serviceName != "" { - _ = m.app.registry.ClearServicePID(c.serviceName) - } - } else { - m.cmdStatus = err.Error() - } - } else { - m.cmdStatus = fmt.Sprintf("Stopped PID %d", c.pid) - if c.serviceName != "" { - if clrErr := m.app.registry.ClearServicePID(c.serviceName); clrErr != nil { - m.cmdStatus = fmt.Sprintf("Stopped PID %d (warning: %v)", c.pid, clrErr) - } - } - } - case confirmRemoveService: - svc := m.app.registry.GetService(c.name) - if svc != nil { - copySvc := *svc - m.removed[c.name] = ©Svc - } - if err := m.app.RemoveCmd(c.name); err != nil { - m.cmdStatus = err.Error() - } else { - m.cmdStatus = fmt.Sprintf("Removed %q (use :restore %s)", c.name, c.name) - } - case confirmSudoKill: - m.cmdStatus = fmt.Sprintf("Run manually: sudo kill -9 %d", c.pid) - } - m.refresh() - return nil -} - -func (m topModel) tailLogsCmd() tea.Cmd { - return func() tea.Msg { - if m.logSvc != nil { - lines, err := m.app.processManager.Tail(m.logSvc.Name, 200) - return logMsg{lines: lines, err: err} - } - if m.logPID > 0 { - lines, err := m.app.processManager.TailProcess(m.logPID, 200) - return logMsg{lines: lines, err: err} - } - return logMsg{err: fmt.Errorf("no service selected")} - } -} - -func (m topModel) healthCmd() tea.Cmd { - visible := m.visibleServers() - return func() tea.Msg { - icons := make(map[int]string) - details := make(map[int]*health.HealthCheck) - for _, srv := range visible { - if srv.ProcessRecord == nil || srv.ProcessRecord.Port <= 0 { - continue - } - check := m.healthChk.Check(srv.ProcessRecord.Port) - icons[srv.ProcessRecord.Port] = health.StatusIcon(check.Status) - details[srv.ProcessRecord.Port] = check - } - return healthMsg{icons: icons, details: details} - } -} - -type tickMsg time.Time -type logMsg struct { - lines []string - err error -} -type healthMsg struct { - icons map[int]string - details map[int]*health.HealthCheck - err error -} - -func tickCmd() tea.Cmd { - return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) -} - -func parseArgs(input string) ([]string, error) { - var args []string - var buf strings.Builder - inQuotes := false - var quote rune - escaped := false - for _, r := range input { - if escaped { - buf.WriteRune(r) - escaped = false - continue - } - switch r { - case '\\': - escaped = true - case '"', '\'': - if inQuotes && r == quote { - inQuotes = false - quote = 0 - } else if !inQuotes { - inQuotes = true - quote = r - } else { - buf.WriteRune(r) - } - case ' ', '\t': - if inQuotes { - buf.WriteRune(r) - } else if buf.Len() > 0 { - args = append(args, buf.String()) - buf.Reset() - } - default: - buf.WriteRune(r) - } - } - if buf.Len() > 0 { - args = append(args, buf.String()) - } - return args, nil -} - -func fitLine(line string, width int) string { - if width <= 0 { - return line - } - lineWidth := runewidth.StringWidth(line) - if lineWidth == width { - return line - } - if lineWidth > width { - // Let the terminal wrap long lines to the viewport instead of truncating. - return line - } - return line + strings.Repeat(" ", width-lineWidth) -} - -func pathBase(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "-" - } - if strings.Contains(raw, " ") { - raw = strings.Fields(raw)[0] - } - raw = strings.TrimRight(raw, "/") - parts := strings.Split(raw, "/") - if len(parts) == 0 { - return "-" - } - base := parts[len(parts)-1] - if base == "" { - return "-" - } - return base -} - -func projectOf(srv *models.ServerInfo) string { - if srv == nil || srv.ProcessRecord == nil { - return "" - } - if srv.ProcessRecord.ProjectRoot != "" { - return pathBase(srv.ProcessRecord.ProjectRoot) - } - return pathBase(srv.ProcessRecord.CWD) -} - -func portOf(srv *models.ServerInfo) int { - if srv == nil || srv.ProcessRecord == nil { - return 0 - } - return srv.ProcessRecord.Port -} - -func pidOf(srv *models.ServerInfo) int { - if srv == nil || srv.ProcessRecord == nil { - return 0 - } - return srv.ProcessRecord.PID -} - -func isRuntimeCommand(raw string) bool { - base := strings.ToLower(pathBase(raw)) - switch base { - case "node", "nodejs", "npm", "npx", "pnpm", "yarn", "bun", "bunx", "deno", - "vite", "webpack", "webpack-dev-server", "next", "next-server", "nuxt", "ts-node", "tsx", - "python", "python3", "pip", "pipenv", "poetry", - "ruby", "rails", - "go", - "java", "javac", "gradle", "mvn", - "dotnet", - "php": - return true - default: - return false - } -} - -func sortModeLabel(s sortMode) string { - switch s { - case sortName: - return "name" - case sortProject: - return "project" - case sortPort: - return "port" - case sortHealth: - return "health" - default: - return "recent" - } -} - -func (m topModel) isServiceRunning(name string) bool { - for _, srv := range m.servers { - if srv.ManagedService != nil && srv.ManagedService.Name == name && srv.ProcessRecord != nil && srv.ProcessRecord.PID > 0 { - return true - } - } - return false -} - -func (m topModel) serviceStatus(name string) string { - for _, srv := range m.servers { - if srv.ManagedService != nil && srv.ManagedService.Name == name { - if srv.Status != "" { - return srv.Status - } - } - } - if m.isServiceRunning(name) { - return "running" - } - return "stopped" -} - -func (m topModel) crashReasonForService(name string) string { - for _, srv := range m.servers { - if srv.ManagedService != nil && srv.ManagedService.Name == name && srv.Status == "crashed" { - return srv.CrashReason - } - } - return "" + return tuipkg.Run(NewTUIAdapter(a)) } diff --git a/pkg/cli/tui/commands.go b/pkg/cli/tui/commands.go new file mode 100644 index 0000000..454e2c6 --- /dev/null +++ b/pkg/cli/tui/commands.go @@ -0,0 +1,313 @@ +package tui + +import ( + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/devports/devpt/pkg/health" + "github.com/devports/devpt/pkg/models" + "github.com/devports/devpt/pkg/process" +) + +func (m topModel) countVisible() int { return len(m.visibleServers()) } + +func (m topModel) currentFilterQuery() string { + if m.mode == viewModeSearch { + return m.searchInput.Value() + } + return m.searchQuery +} + +func (m topModel) visibleServers() []*models.ServerInfo { + var visible []*models.ServerInfo + q := strings.ToLower(strings.TrimSpace(m.currentFilterQuery())) + for _, srv := range m.servers { + if srv == nil || srv.ProcessRecord == nil { + continue + } + if srv.ManagedService == nil { + if srv.ProcessRecord.Port == 0 || !isRuntimeCommand(srv.ProcessRecord.Command) { + continue + } + } + if q != "" { + hay := strings.ToLower(fmt.Sprintf("%s %s %s %d %s %s", + m.serviceNameFor(srv), projectOf(srv), srv.ProcessRecord.Command, srv.ProcessRecord.Port, srv.ProcessRecord.CWD, srv.ProcessRecord.ProjectRoot)) + if !strings.Contains(hay, q) { + continue + } + } + visible = append(visible, srv) + } + m.sortServers(visible) + return visible +} + +func (m topModel) managedServices() []*models.ManagedService { + services := m.app.ListServices() + q := strings.ToLower(strings.TrimSpace(m.currentFilterQuery())) + var filtered []*models.ManagedService + for _, svc := range services { + if q == "" || strings.Contains(strings.ToLower(svc.Name+" "+svc.CWD+" "+svc.Command), q) { + filtered = append(filtered, svc) + } + } + sort.Slice(filtered, func(i, j int) bool { return strings.ToLower(filtered[i].Name) < strings.ToLower(filtered[j].Name) }) + return filtered +} + +func (m topModel) serviceNameFor(srv *models.ServerInfo) string { + if srv == nil { + return "-" + } + if srv.ManagedService != nil && srv.ManagedService.Name != "" { + return srv.ManagedService.Name + } + if srv.ProcessRecord != nil { + if srv.ProcessRecord.ProjectRoot != "" { + return pathBase(srv.ProcessRecord.ProjectRoot) + } + if srv.ProcessRecord.CWD != "" { + return pathBase(srv.ProcessRecord.CWD) + } + if srv.ProcessRecord.Command != "" { + return pathBase(srv.ProcessRecord.Command) + } + } + return "-" +} + +func (m *topModel) runCommand(input string) string { + if input == "" { + return "" + } + args, err := parseArgs(input) + if err != nil || len(args) == 0 { + return "Invalid command" + } + switch args[0] { + case "help": + m.openHelpModal() + return "" + case "list": + services := m.app.ListServices() + if len(services) == 0 { + return "No managed services" + } + names := make([]string, 0, len(services)) + for _, svc := range services { + names = append(names, svc.Name) + } + sort.Strings(names) + return "Managed services: " + strings.Join(names, ", ") + case "add": + if len(args) < 4 { + return "Usage: add \"\" [ports...]" + } + name, cwd, cmd := args[1], args[2], args[3] + var ports []int + for _, p := range args[4:] { + port, perr := strconv.Atoi(p) + if perr != nil { + return "Invalid port: " + p + } + ports = append(ports, port) + } + if err := m.app.AddCmd(name, cwd, cmd, ports); err != nil { + return err.Error() + } + return fmt.Sprintf("Added %q", name) + case "remove", "rm": + if len(args) < 2 { + return "Usage: remove " + } + svc := m.app.GetService(args[1]) + if svc == nil { + return fmt.Sprintf("service %q not found", args[1]) + } + m.openConfirmModal(&confirmState{kind: confirmRemoveService, prompt: fmt.Sprintf("Remove %q from registry?", svc.Name), name: svc.Name}) + return "" + case "restore": + if len(args) < 2 { + return "Usage: restore " + } + svc := m.removed[args[1]] + if svc == nil { + return fmt.Sprintf("no removed service %q in this session", args[1]) + } + if err := m.app.AddCmd(svc.Name, svc.CWD, svc.Command, svc.Ports); err != nil { + return err.Error() + } + delete(m.removed, args[1]) + return fmt.Sprintf("Restored %q", args[1]) + case "start": + if len(args) < 2 { + return "Usage: start " + } + if err := m.app.StartCmd(args[1]); err != nil { + return err.Error() + } + m.starting[args[1]] = time.Now() + return fmt.Sprintf("Started %q", args[1]) + case "stop": + if len(args) < 2 { + return "Usage: stop " + } + if args[1] == "--port" { + if len(args) < 3 { + return "Usage: stop --port PORT" + } + if err := m.app.StopCmd(args[2]); err != nil { + return err.Error() + } + return fmt.Sprintf("Stopped port %s", args[2]) + } + if err := m.app.StopCmd(args[1]); err != nil { + return err.Error() + } + return fmt.Sprintf("Stopped %q", args[1]) + default: + return "Unknown command (type :help)" + } +} + +func (m topModel) startSelected() string { + visible := m.visibleServers() + if m.selected < 0 || m.selected >= len(visible) { + return "No service selected" + } + srv := visible[m.selected] + if srv.ManagedService == nil { + return "Selected process is not a managed service" + } + if err := m.app.StartCmd(srv.ManagedService.Name); err != nil { + return err.Error() + } + m.starting[srv.ManagedService.Name] = time.Now() + return fmt.Sprintf("Started %q", srv.ManagedService.Name) +} + +func (m topModel) restartSelected() string { + visible := m.visibleServers() + if m.selected < 0 || m.selected >= len(visible) { + return "No service selected" + } + srv := visible[m.selected] + if srv.ManagedService == nil { + return "Selected process is not a managed service" + } + if err := m.app.RestartCmd(srv.ManagedService.Name); err != nil { + return err.Error() + } + m.starting[srv.ManagedService.Name] = time.Now() + return fmt.Sprintf("Restarted %q", srv.ManagedService.Name) +} + +func (m *topModel) prepareStopConfirm() { + visible := m.visibleServers() + if m.selected < 0 || m.selected >= len(visible) { + m.cmdStatus = "No service selected" + return + } + srv := visible[m.selected] + if srv.ProcessRecord == nil || srv.ProcessRecord.PID == 0 { + m.cmdStatus = "No PID to stop" + return + } + prompt := fmt.Sprintf("Stop PID %d?", srv.ProcessRecord.PID) + serviceName := "" + if srv.ManagedService != nil { + prompt = fmt.Sprintf("Stop %q (PID %d)?", srv.ManagedService.Name, srv.ProcessRecord.PID) + serviceName = srv.ManagedService.Name + } + m.openConfirmModal(&confirmState{kind: confirmStopPID, prompt: prompt, pid: srv.ProcessRecord.PID, serviceName: serviceName}) +} + +func (m *topModel) executeConfirm(yes bool) tea.Cmd { + if m.confirm == nil { + m.closeModal() + return nil + } + c := *m.confirm + m.closeModal() + if !yes { + m.cmdStatus = "Cancelled" + return nil + } + switch c.kind { + case confirmStopPID: + if err := m.app.StopProcess(c.pid, 5*time.Second); err != nil { + if errors.Is(err, process.ErrNeedSudo) { + m.openConfirmModal(&confirmState{kind: confirmSudoKill, prompt: fmt.Sprintf("Run sudo kill -9 %d now?", c.pid), pid: c.pid}) + return nil + } + if isProcessFinishedErr(err) { + m.cmdStatus = fmt.Sprintf("Process %d already exited", c.pid) + if c.serviceName != "" { + _ = m.app.ClearServicePID(c.serviceName) + } + } else { + m.cmdStatus = err.Error() + } + } else { + m.cmdStatus = fmt.Sprintf("Stopped PID %d", c.pid) + if c.serviceName != "" { + if clrErr := m.app.ClearServicePID(c.serviceName); clrErr != nil { + m.cmdStatus = fmt.Sprintf("Stopped PID %d (warning: %v)", c.pid, clrErr) + } + } + } + case confirmRemoveService: + svc := m.app.GetService(c.name) + if svc != nil { + copySvc := *svc + m.removed[c.name] = ©Svc + } + if err := m.app.RemoveCmd(c.name); err != nil { + m.cmdStatus = err.Error() + } else { + m.cmdStatus = fmt.Sprintf("Removed %q (use :restore %s)", c.name, c.name) + } + case confirmSudoKill: + m.cmdStatus = fmt.Sprintf("Run manually: sudo kill -9 %d", c.pid) + } + m.refresh() + return nil +} + +func (m topModel) tailLogsCmd() tea.Cmd { + return func() tea.Msg { + if m.logSvc != nil { + lines, err := m.app.TailServiceLogs(m.logSvc.Name, 200) + return logMsg{lines: lines, err: err} + } + if m.logPID > 0 { + lines, err := m.app.TailProcessLogs(m.logPID, 200) + return logMsg{lines: lines, err: err} + } + return logMsg{err: fmt.Errorf("no service selected")} + } +} + +func (m topModel) healthCmd() tea.Cmd { + visible := m.visibleServers() + return func() tea.Msg { + icons := make(map[int]string) + details := make(map[int]*health.HealthCheck) + for _, srv := range visible { + if srv.ProcessRecord == nil || srv.ProcessRecord.Port <= 0 { + continue + } + check := m.healthChk.Check(srv.ProcessRecord.Port) + icons[srv.ProcessRecord.Port] = health.StatusIcon(check.Status) + details[srv.ProcessRecord.Port] = check + } + return healthMsg{icons: icons, details: details} + } +} diff --git a/pkg/cli/tui/deps.go b/pkg/cli/tui/deps.go new file mode 100644 index 0000000..020e14b --- /dev/null +++ b/pkg/cli/tui/deps.go @@ -0,0 +1,24 @@ +package tui + +import ( + "time" + + "github.com/devports/devpt/pkg/models" +) + +// AppDeps is the narrow surface the TUI needs from the CLI application layer. +type AppDeps interface { + DiscoverServers() ([]*models.ServerInfo, error) + ListServices() []*models.ManagedService + GetService(name string) *models.ManagedService + ClearServicePID(name string) error + AddCmd(name, cwd, command string, ports []int) error + RemoveCmd(name string) error + StartCmd(name string) error + StopCmd(identifier string) error + RestartCmd(name string) error + StopProcess(pid int, timeout time.Duration) error + TailServiceLogs(name string, lines int) ([]string, error) + TailProcessLogs(pid int, lines int) ([]string, error) + LatestServiceLogPath(name string) (string, error) +} diff --git a/pkg/cli/tui/helpers.go b/pkg/cli/tui/helpers.go new file mode 100644 index 0000000..0263215 --- /dev/null +++ b/pkg/cli/tui/helpers.go @@ -0,0 +1,455 @@ +package tui + +import ( + "strconv" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/mattn/go-runewidth" + + "github.com/devports/devpt/pkg/models" +) + +func fixedCell(s string, width int) string { + if width <= 0 { + return "" + } + if runewidth.StringWidth(s) > width { + return runewidth.Truncate(s, width, "") + } + return s + strings.Repeat(" ", width-runewidth.StringWidth(s)) +} + +func wrapRunes(s string, width int) []string { + if width <= 0 { + return []string{s} + } + if s == "" { + return []string{""} + } + var out []string + rest := s + for runewidth.StringWidth(rest) > width { + chunk := runewidth.Truncate(rest, width, "") + if chunk == "" { + break + } + out = append(out, chunk) + rest = strings.TrimPrefix(rest, chunk) + } + if rest != "" { + out = append(out, rest) + } + return out +} + +func wrapWords(s string, width int) []string { + if width <= 0 { + return []string{s} + } + words := strings.Fields(s) + if len(words) == 0 { + return []string{""} + } + lines := make([]string, 0, 4) + cur := words[0] + for _, w := range words[1:] { + candidate := cur + " " + w + if runewidth.StringWidth(candidate) <= width { + cur = candidate + continue + } + lines = append(lines, cur) + if runewidth.StringWidth(w) > width { + chunks := wrapRunes(w, width) + if len(chunks) > 0 { + lines = append(lines, chunks[:len(chunks)-1]...) + cur = chunks[len(chunks)-1] + } else { + cur = w + } + } else { + cur = w + } + } + lines = append(lines, cur) + return lines +} + +func parseArgs(input string) ([]string, error) { + var args []string + var buf strings.Builder + inQuotes := false + var quote rune + escaped := false + for _, r := range input { + if escaped { + buf.WriteRune(r) + escaped = false + continue + } + switch r { + case '\\': + escaped = true + case '"', '\'': + if inQuotes && r == quote { + inQuotes = false + quote = 0 + } else if !inQuotes { + inQuotes = true + quote = r + } else { + buf.WriteRune(r) + } + case ' ', '\t': + if inQuotes { + buf.WriteRune(r) + } else if buf.Len() > 0 { + args = append(args, buf.String()) + buf.Reset() + } + default: + buf.WriteRune(r) + } + } + if buf.Len() > 0 { + args = append(args, buf.String()) + } + return args, nil +} + +func fitLine(line string, width int) string { + if width <= 0 { + return line + } + lineWidth := runewidth.StringWidth(line) + if lineWidth >= width { + if width <= 3 { + return runewidth.Truncate(line, width, "") + } + return runewidth.Truncate(line, width, "...") + } + return line + strings.Repeat(" ", width-lineWidth) +} + +func pathBase(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "-" + } + if strings.Contains(raw, " ") { + raw = strings.Fields(raw)[0] + } + raw = strings.TrimRight(raw, "/") + parts := strings.Split(raw, "/") + if len(parts) == 0 { + return "-" + } + base := parts[len(parts)-1] + if base == "" { + return "-" + } + return base +} + +func projectOf(srv *models.ServerInfo) string { + if srv == nil || srv.ProcessRecord == nil { + return "" + } + if srv.ProcessRecord.ProjectRoot != "" { + return pathBase(srv.ProcessRecord.ProjectRoot) + } + return pathBase(srv.ProcessRecord.CWD) +} + +func portOf(srv *models.ServerInfo) int { + if srv == nil || srv.ProcessRecord == nil { + return 0 + } + return srv.ProcessRecord.Port +} + +func pidOf(srv *models.ServerInfo) int { + if srv == nil || srv.ProcessRecord == nil { + return 0 + } + return srv.ProcessRecord.PID +} + +func isRuntimeCommand(raw string) bool { + base := strings.ToLower(pathBase(raw)) + switch base { + case "node", "nodejs", "npm", "npx", "pnpm", "yarn", "bun", "bunx", "deno", + "vite", "webpack", "webpack-dev-server", "next", "next-server", "nuxt", "ts-node", "tsx", + "python", "python3", "pip", "pipenv", "poetry", + "ruby", "rails", + "go", + "java", "javac", "gradle", "mvn", + "dotnet", + "php": + return true + default: + return false + } +} + +func isProcessFinishedErr(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "process already finished") || strings.Contains(msg, "no such process") +} + +func (m topModel) isServiceRunning(name string) bool { + for _, srv := range m.servers { + if srv.ManagedService != nil && srv.ManagedService.Name == name && srv.ProcessRecord != nil && srv.ProcessRecord.PID > 0 { + return true + } + } + return false +} + +func (m topModel) serviceStatus(name string) string { + for _, srv := range m.servers { + if srv.ManagedService != nil && srv.ManagedService.Name == name { + if srv.Status != "" { + return srv.Status + } + } + } + if m.isServiceRunning(name) { + return "running" + } + return "stopped" +} + +func (m topModel) crashReasonForService(name string) string { + for _, srv := range m.servers { + if srv.ManagedService != nil && srv.ManagedService.Name == name && srv.Status == "crashed" { + return srv.CrashReason + } + } + return "" +} + +func (m topModel) serverInfoForService(name string) *models.ServerInfo { + for _, srv := range m.servers { + if srv.ManagedService != nil && srv.ManagedService.Name == name { + return srv + } + } + return nil +} + +func (m topModel) selectedManagedService() *models.ManagedService { + managed := m.managedServices() + if m.managedSel < 0 || m.managedSel >= len(managed) { + return nil + } + return managed[m.managedSel] +} + +func managedStatusSymbol(state string) string { + switch state { + case "running": + return "▶" + case "crashed": + return "✘" + case "starting": + return "…" + default: + return "■" + } +} + +func managedStatusColor(state string) string { + switch state { + case "running": + return "10" + case "crashed": + return "9" + case "starting": + return "11" + default: + return "8" + } +} + +func nonEmptyTail(lines []string, n int) []string { + if n <= 0 || len(lines) == 0 { + return nil + } + filtered := make([]string, 0, len(lines)) + for _, line := range lines { + if strings.TrimSpace(line) != "" { + filtered = append(filtered, line) + } + } + if len(filtered) <= n { + return filtered + } + return filtered[len(filtered)-n:] +} + +func (m topModel) calculateGutterWidth() int { + totalLines := m.viewport.TotalLineCount() + if totalLines <= 0 { + return 0 + } + width := len(strconv.Itoa(totalLines)) + return width + 1 +} + +func (m *topModel) handleMouseClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + mouse := msg.Mouse() + if mouse.Button != tea.MouseLeft { + return m, nil + } + if len(m.logLines) == 0 { + return m, nil + } + + gutterWidth := m.calculateGutterWidth() + clickedInGutter := mouse.X < gutterWidth + clickedLine := mouse.Y + absoluteLine := clickedLine + m.viewport.YOffset() + + if absoluteLine < 0 || absoluteLine >= len(m.logLines) { + return m, nil + } + + if clickedInGutter { + m.viewport.SetYOffset(absoluteLine) + } else { + visibleLines := m.viewport.VisibleLineCount() + if visibleLines > 0 { + centerOffset := absoluteLine - (visibleLines / 2) + if centerOffset < 0 { + centerOffset = 0 + } + m.viewport.SetYOffset(centerOffset) + } + } + + return m, nil +} + +func (m *topModel) handleEnterKey() (tea.Model, tea.Cmd) { + if m.focus == focusManaged { + managed := m.managedServices() + if m.managedSel >= 0 && m.managedSel < len(managed) { + if err := m.app.StartCmd(managed[m.managedSel].Name); err != nil { + m.cmdStatus = err.Error() + } else { + name := managed[m.managedSel].Name + m.cmdStatus = "Started " + strconv.Quote(name) + m.starting[name] = time.Now() + } + m.refresh() + return m, nil + } + } + if m.focus == focusRunning { + visible := m.visibleServers() + if m.selected >= 0 && m.selected < len(visible) { + srv := visible[m.selected] + m.mode = viewModeLogs + if srv.ManagedService == nil { + m.logSvc = nil + m.logPID = srv.ProcessRecord.PID + } else { + m.logSvc = srv.ManagedService + m.logPID = 0 + } + m.viewportNeedsTop = true + return m, m.tailLogsCmd() + } + } + return m, nil +} + +func (m *topModel) handleTableMouseClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + visible := m.visibleServers() + managed := m.managedServices() + mouse := msg.Mouse() + + headerOffset := m.tableTopLines(m.width) + // Bubble Tea mouse row coordinates are effectively one line below our table math. + viewportY := mouse.Y - headerOffset + 1 + if viewportY < 0 { + return m, nil + } + + // Check if click is on the header row (line 0 in running viewport) + if viewportY < m.table.lastRunningHeight { + absoluteLine := viewportY + m.table.runningYOffset() + if absoluteLine == 0 { + if col := m.columnAtX(mouse.X); col >= 0 { + m.cycleSort(col) + m.lastInput = time.Now() + return m, nil + } + } + } + + runningDataStart := 2 + + const doubleClickThreshold = 500 * time.Millisecond + isDoubleClick := !m.lastClickTime.IsZero() && + time.Since(m.lastClickTime) < doubleClickThreshold && + m.lastClickY == mouse.Y + + m.lastClickTime = time.Now() + m.lastClickY = mouse.Y + + if viewportY < m.table.lastRunningHeight { + absoluteLine := viewportY + m.table.runningYOffset() + runningDataEnd := runningDataStart + len(visible) - 1 + if absoluteLine < runningDataStart || absoluteLine > runningDataEnd { + return m, nil + } + newSelected := absoluteLine - runningDataStart + if newSelected >= 0 && newSelected < len(visible) { + if isDoubleClick && m.selected == newSelected { + m.focus = focusRunning + m.tableFollowSelection = true + m.lastInput = time.Now() + return m.handleEnterKey() + } + m.focus = focusRunning + m.selected = newSelected + m.tableFollowSelection = true + m.lastInput = time.Now() + } + return m, nil + } + + // Managed header sits directly above the managed viewport content. + if viewportY == m.table.lastRunningHeight { + return m, nil + } + + managedViewportY := viewportY - m.table.lastRunningHeight - 1 + if managedViewportY < 0 || managedViewportY >= m.table.lastManagedHeight { + return m, nil + } + + absoluteManagedLine := managedViewportY + m.table.managedYOffset() + newManagedSel := absoluteManagedLine + if newManagedSel >= 0 && newManagedSel < len(managed) { + if isDoubleClick && m.managedSel == newManagedSel { + m.focus = focusManaged + m.tableFollowSelection = true + m.lastInput = time.Now() + return m.handleEnterKey() + } + m.focus = focusManaged + m.managedSel = newManagedSel + m.tableFollowSelection = true + m.lastInput = time.Now() + } + + return m, nil +} diff --git a/pkg/cli/tui/keymap.go b/pkg/cli/tui/keymap.go new file mode 100644 index 0000000..b975611 --- /dev/null +++ b/pkg/cli/tui/keymap.go @@ -0,0 +1,134 @@ +package tui + +import "charm.land/bubbles/v2/key" + +type keyMap struct { + Up key.Binding + Down key.Binding + Tab key.Binding + Enter key.Binding + Search key.Binding + ClearFilter key.Binding + Sort key.Binding + SortReverse key.Binding + Health key.Binding + Help key.Binding + Add key.Binding + Restart key.Binding + Stop key.Binding + Remove key.Binding + Debug key.Binding + Back key.Binding + Follow key.Binding + NextMatch key.Binding + PrevMatch key.Binding + Confirm key.Binding + Cancel key.Binding + Quit key.Binding +} + +func defaultKeyMap() keyMap { + return keyMap{ + Up: key.NewBinding( + key.WithKeys("k", "up"), + key.WithHelp("up/k", "move up"), + ), + Down: key.NewBinding( + key.WithKeys("j", "down"), + key.WithHelp("down/j", "move down"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch list"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "logs/start"), + ), + Search: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "filter"), + ), + ClearFilter: key.NewBinding( + key.WithKeys("ctrl+l"), + key.WithHelp("^L", "clear filter"), + ), + Sort: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "sort"), + ), + SortReverse: key.NewBinding( + key.WithKeys("S"), + key.WithHelp("S", "sort reverse"), + ), + Health: key.NewBinding( + key.WithKeys("h"), + key.WithHelp("h", "health detail"), + ), + Help: key.NewBinding( + key.WithKeys("?", "f1"), + key.WithHelp("?", "toggle help"), + ), + Add: key.NewBinding( + key.WithKeys("ctrl+a"), + key.WithHelp("^A", "add"), + ), + Restart: key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("^R", "restart"), + ), + Stop: key.NewBinding( + key.WithKeys("ctrl+e"), + key.WithHelp("^E", "stop"), + ), + Remove: key.NewBinding( + key.WithKeys("x", "delete", "ctrl+d"), + key.WithHelp("x", "remove managed"), + ), + Debug: key.NewBinding( + key.WithKeys("D"), + key.WithHelp("D", "debug"), + ), + Back: key.NewBinding( + key.WithKeys("esc", "b"), + key.WithHelp("esc/b", "back"), + ), + Follow: key.NewBinding( + key.WithKeys("f"), + key.WithHelp("f", "toggle follow"), + ), + NextMatch: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "next match"), + ), + PrevMatch: key.NewBinding( + key.WithKeys("N"), + key.WithHelp("N", "prev match"), + ), + Confirm: key.NewBinding( + key.WithKeys("enter", "y"), + key.WithHelp("enter/y", "confirm"), + ), + Cancel: key.NewBinding( + key.WithKeys("n", "esc"), + key.WithHelp("n/esc", "cancel"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + } +} + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Tab, k.Enter, k.Search, k.Help} +} + +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down, k.Tab, k.Enter, k.Search, k.ClearFilter}, + {k.Sort, k.SortReverse, k.Health, k.Help, k.Add, k.Restart, k.Stop}, + {k.Remove, k.Debug, k.Back, k.Follow, k.NextMatch, k.PrevMatch}, + {k.Confirm, k.Cancel, k.Quit}, + } +} diff --git a/pkg/cli/tui/modal.go b/pkg/cli/tui/modal.go new file mode 100644 index 0000000..091b32a --- /dev/null +++ b/pkg/cli/tui/modal.go @@ -0,0 +1,188 @@ +package tui + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +type modalBounds struct { + x int + y int + width int + height int +} + +func (m *topModel) openHelpModal() { + m.modal = &modalState{kind: modalHelp} +} + +func (m *topModel) openConfirmModal(confirm *confirmState) { + m.confirm = confirm + m.modal = &modalState{kind: modalConfirm} +} + +func (m *topModel) closeModal() { + m.modal = nil + m.confirm = nil +} + +func (m *topModel) activeModalKind() modalKind { + if m.modal == nil { + return 0 + } + return m.modal.kind +} + +func renderModal(title, body, hint string, width, maxWidth int, accent string) string { + boxWidth := width - 8 + if maxWidth > 0 && boxWidth > maxWidth { + boxWidth = maxWidth + } + if boxWidth < 24 { + boxWidth = width + } + + bodyWidth := boxWidth - 4 + if bodyWidth < 8 { + bodyWidth = boxWidth + } + + lines := []string{ + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(accent)).Render(title), + } + for _, line := range strings.Split(body, "\n") { + lines = append(lines, fitAnsiLine(line, bodyWidth)) + } + if hint != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(fitAnsiLine(hint, bodyWidth))) + } + content := strings.Join(lines, "\n") + + return lipgloss.NewStyle(). + Width(boxWidth). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(accent)). + Padding(0, 1). + Render(content) +} + +func (m *topModel) renderConfirmModal(width int) string { + if m.confirm == nil { + return "" + } + return renderModal("Confirm", m.confirm.prompt, "Enter/y confirm, n/Esc cancel", width, 72, "11") +} + +func (m *topModel) renderHelpModal(width int) string { + h := m.help + boxWidth := width - 12 + if boxWidth > 96 { + boxWidth = 96 + } + if boxWidth < 36 { + boxWidth = width + } + h.ShowAll = true + h.SetWidth(boxWidth - 4) + + body := strings.Join([]string{ + h.View(m.keys), + "", + "Commands: add, start, stop, remove, restore, list, help", + }, "\n") + return renderModal("Help", body, "Esc/? closes", width, boxWidth, "12") +} + +func (m *topModel) activeModalOverlay(width int) string { + switch m.activeModalKind() { + case modalHelp: + return m.renderHelpModal(width) + case modalConfirm: + return m.renderConfirmModal(width) + default: + return "" + } +} + +func overlayModal(background, overlay string, width int) string { + bgLines := strings.Split(strings.TrimRight(background, "\n"), "\n") + ovLines := strings.Split(overlay, "\n") + if len(bgLines) == 0 || len(ovLines) == 0 { + return background + } + + bounds := calculateModalBounds(bgLines, ovLines, width) + + for i, line := range ovLines { + targetY := bounds.y + i + if targetY < 0 || targetY >= len(bgLines) { + continue + } + left := ansi.Cut(bgLines[targetY], 0, bounds.x) + rightStart := bounds.x + ansi.StringWidth(line) + right := "" + if rightStart < width { + right = ansi.Cut(bgLines[targetY], rightStart, width) + } + bgLines[targetY] = padAnsiLine(left, bounds.x) + line + padAnsiLine(right, width-rightStart) + } + + return strings.Join(bgLines, "\n") + "\n" +} + +func (m *topModel) activeModalBounds(width int, background string) modalBounds { + overlay := m.activeModalOverlay(width) + bgLines := strings.Split(strings.TrimRight(background, "\n"), "\n") + ovLines := strings.Split(overlay, "\n") + return calculateModalBounds(bgLines, ovLines, width) +} + +func calculateModalBounds(bgLines, ovLines []string, width int) modalBounds { + bounds := modalBounds{} + if len(bgLines) == 0 || len(ovLines) == 0 { + return bounds + } + + bounds.height = len(ovLines) + bounds.y = (len(bgLines) - bounds.height) / 2 + if bounds.y < 0 { + bounds.y = 0 + } + + for _, line := range ovLines { + if w := ansi.StringWidth(line); w > bounds.width { + bounds.width = w + } + } + + bounds.x = (width - bounds.width) / 2 + if bounds.x < 0 { + bounds.x = 0 + } + + return bounds +} + +func (b modalBounds) contains(x, y int) bool { + return x >= b.x && x < b.x+b.width && y >= b.y && y < b.y+b.height +} + +func padAnsiLine(line string, targetWidth int) string { + width := ansi.StringWidth(line) + if width >= targetWidth { + return line + } + return line + strings.Repeat(" ", targetWidth-width) +} + +func fitAnsiLine(line string, targetWidth int) string { + if targetWidth <= 0 { + return line + } + if ansi.StringWidth(line) > targetWidth { + return ansi.Truncate(line, targetWidth, "...") + } + return padAnsiLine(line, targetWidth) +} diff --git a/pkg/cli/tui/model.go b/pkg/cli/tui/model.go new file mode 100644 index 0000000..b74d4f8 --- /dev/null +++ b/pkg/cli/tui/model.go @@ -0,0 +1,200 @@ +package tui + +import ( + "time" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/devports/devpt/pkg/health" + "github.com/devports/devpt/pkg/models" +) + +type viewMode int +type viewFocus int +type confirmKind int +type modalKind int + +const ( + viewModeTable viewMode = iota + viewModeLogs + viewModeLogsDebug + viewModeCommand + viewModeSearch +) + +const ( + focusRunning viewFocus = iota + focusManaged +) + +const ( + confirmStopPID confirmKind = iota + confirmRemoveService + confirmSudoKill +) + +const ( + modalHelp modalKind = iota + 1 + modalConfirm +) + +type confirmState struct { + kind confirmKind + prompt string + pid int + name string + serviceName string +} + +type modalState struct { + kind modalKind +} + +type topModel struct { + app AppDeps + servers []*models.ServerInfo + width int + height int + lastUpdate time.Time + lastInput time.Time + err error + + selected int + managedSel int + focus viewFocus + mode viewMode + + logLines []string + logErr error + logSvc *models.ManagedService + logPID int + followLogs bool + + cmdInput string + searchQuery string + cmdStatus string + searchInput textinput.Model + + health map[int]string + healthDetails map[int]*health.HealthCheck + showHealthDetail bool + healthBusy bool + healthLast time.Time + healthChk *health.Checker + + sortBy sortMode + sortReverse bool + lastSortBy sortMode // track last sorted column for 3-state cycle + + starting map[string]time.Time + removed map[string]*models.ManagedService + + modal *modalState + confirm *confirmState + table processTable + + keys keyMap + help help.Model + viewport viewport.Model + viewportNeedsTop bool + highlightIndex int + highlightMatches []int + + lastClickTime time.Time + lastClickY int + tableFollowSelection bool +} + +type tickMsg time.Time + +type logMsg struct { + lines []string + err error +} + +type healthMsg struct { + icons map[int]string + details map[int]*health.HealthCheck + err error +} + +func Run(app AppDeps) error { + model := newTopModel(app) + p := tea.NewProgram(model) + _, err := p.Run() + return err +} + +func newTopModel(app AppDeps) *topModel { + searchInput := textinput.New() + searchInput.Prompt = ">" + searchInput.Placeholder = "" + searchInput.CharLimit = 256 + searchInput.SetVirtualCursor(true) + searchStyles := textinput.DefaultStyles(false) + searchStyles.Focused.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true) + searchStyles.Focused.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true) + searchStyles.Blurred.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + searchStyles.Blurred.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + searchInput.SetStyles(searchStyles) + + m := &topModel{ + app: app, + lastUpdate: time.Now(), + lastInput: time.Now(), + mode: viewModeTable, + focus: focusRunning, + followLogs: false, + health: make(map[int]string), + healthDetails: make(map[int]*health.HealthCheck), + healthChk: health.NewChecker(800 * time.Millisecond), + sortBy: sortRecent, + starting: make(map[string]time.Time), + removed: make(map[string]*models.ManagedService), + keys: defaultKeyMap(), + help: help.New(), + searchInput: searchInput, + tableFollowSelection: true, + } + if servers, err := app.DiscoverServers(); err == nil { + m.servers = servers + } + + m.viewport = viewport.New() + m.table = newProcessTable() + m.highlightIndex = 0 + + return m +} + +func (m topModel) Init() tea.Cmd { + return tickCmd() +} + +func (m *topModel) refresh() { + if servers, err := m.app.DiscoverServers(); err == nil { + m.servers = servers + m.lastUpdate = time.Now() + if m.selected >= len(m.visibleServers()) && len(m.visibleServers()) > 0 { + m.selected = len(m.visibleServers()) - 1 + } + if m.managedSel >= len(m.managedServices()) && len(m.managedServices()) > 0 { + m.managedSel = len(m.managedServices()) - 1 + } + for name, at := range m.starting { + if m.isServiceRunning(name) || time.Since(at) > 45*time.Second { + delete(m.starting, name) + } + } + } else { + m.err = err + } +} + +func tickCmd() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) +} diff --git a/pkg/cli/tui/sort.go b/pkg/cli/tui/sort.go new file mode 100644 index 0000000..c5d9ea5 --- /dev/null +++ b/pkg/cli/tui/sort.go @@ -0,0 +1,144 @@ +package tui + +import ( + "sort" + "strings" + + "github.com/devports/devpt/pkg/models" +) + +type sortMode int + +const ( + sortRecent sortMode = iota + sortName + sortProject + sortPort + sortHealth + sortModeCount +) + +// sortModeLabel returns a human-readable label for the sort mode. +func sortModeLabel(s sortMode) string { + switch s { + case sortName: + return "name" + case sortProject: + return "project" + case sortPort: + return "port" + case sortHealth: + return "health" + default: + return "recent" + } +} + +// sortServers sorts the given servers slice according to the current sort mode. +func (m topModel) sortServers(servers []*models.ServerInfo) { + switch m.sortBy { + case sortName: + sort.Slice(servers, func(i, j int) bool { + cmp := strings.Compare(strings.ToLower(m.serviceNameFor(servers[i])), strings.ToLower(m.serviceNameFor(servers[j]))) + if m.sortReverse { + return cmp > 0 + } + return cmp < 0 + }) + case sortProject: + sort.Slice(servers, func(i, j int) bool { + cmp := strings.Compare(strings.ToLower(projectOf(servers[i])), strings.ToLower(projectOf(servers[j]))) + if m.sortReverse { + return cmp > 0 + } + return cmp < 0 + }) + case sortPort: + sort.Slice(servers, func(i, j int) bool { + if m.sortReverse { + return portOf(servers[i]) > portOf(servers[j]) + } + return portOf(servers[i]) < portOf(servers[j]) + }) + case sortHealth: + sort.Slice(servers, func(i, j int) bool { + cmp := strings.Compare(m.health[portOf(servers[i])], m.health[portOf(servers[j])]) + if m.sortReverse { + return cmp > 0 + } + return cmp < 0 + }) + default: + sort.Slice(servers, func(i, j int) bool { return pidOf(servers[i]) > pidOf(servers[j]) }) + } +} + +// columnAtX returns the sortMode for the column at the given X coordinate. +// Returns -1 if the X is not within a clickable column header. +func (m *topModel) columnAtX(x int) sortMode { + nameW, portW, pidW, projectW, healthW := 14, 6, 7, 14, 7 + sep := 2 + used := nameW + sep + portW + sep + pidW + sep + projectW + sep + healthW + sep + cmdW := m.width - used + if cmdW < 12 { + cmdW = 12 + } + + // Column positions (start, end) + nameEnd := nameW + portStart := nameW + sep + portEnd := portStart + portW + pidStart := portEnd + sep + pidEnd := pidStart + pidW + projectStart := pidEnd + sep + projectEnd := projectStart + projectW + cmdStart := projectEnd + sep + cmdEnd := cmdStart + cmdW + healthStart := cmdEnd + sep + healthEnd := healthStart + healthW + + switch { + case x >= 0 && x < nameEnd: + return sortName + case x >= portStart && x < portEnd: + return sortPort + case x >= pidStart && x < pidEnd: + return sortRecent // PID sorts by recent (default) + case x >= projectStart && x < projectEnd: + return sortProject + case x >= cmdStart && x < cmdEnd: + return sortRecent // Command column - no specific sort, use recent + case x >= healthStart && x < healthEnd: + return sortHealth + default: + return -1 + } +} + +// toggleSortDirection flips the sort direction between ascending and descending. +// No effect when in "Recent" mode (natural order only). +func (m *topModel) toggleSortDirection() { + if m.sortBy == sortRecent { + return + } + m.sortReverse = !m.sortReverse +} + +// cycleSort implements 3-state sort cycling: ascending (yellow) → reverse (orange) → reset to recent +func (m *topModel) cycleSort(col sortMode) { + // If clicking the same column that's currently sorted + if m.sortBy == col && m.sortBy != sortRecent { + if !m.sortReverse { + // State 1 → State 2: same column, now reverse + m.sortReverse = true + } else { + // State 2 → State 3: reset to recent + m.sortBy = sortRecent + m.sortReverse = false + } + } else { + // Different column or clicking recent: go to State 1 (ascending) + m.sortBy = col + m.sortReverse = false + } +} diff --git a/pkg/cli/tui/table.go b/pkg/cli/tui/table.go new file mode 100644 index 0000000..cbfeb54 --- /dev/null +++ b/pkg/cli/tui/table.go @@ -0,0 +1,563 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/mattn/go-runewidth" + + "github.com/devports/devpt/pkg/health" + "github.com/devports/devpt/pkg/models" +) + +type processTable struct { + runningVP viewport.Model + managedVP viewport.Model + + lastRunningHeight int + lastManagedHeight int +} + +func newProcessTable() processTable { + return processTable{ + runningVP: viewport.New(), + managedVP: viewport.New(), + } +} + +func (t *processTable) heightFor(termHeight, aboveLines, belowLines int) int { + h := termHeight - aboveLines - belowLines + if h < 3 { + h = 3 + } + return h +} + +func (t *processTable) Render(m *topModel, width int) string { + topLines := m.tableTopLines(width) + bottomLines := m.tableBottomLines(width) + totalHeight := t.heightFor(m.height, topLines, bottomLines) + runningContent := m.renderRunningTable(width) + managedHeader := m.renderManagedHeader(width) + managedContent := m.renderManagedSection(width) + runningLines := 1 + strings.Count(runningContent, "\n") + managedLines := 1 + strings.Count(managedContent, "\n") + runningHeight, managedHeight := t.sectionHeights(totalHeight, runningLines, managedLines) + + t.lastRunningHeight = runningHeight + t.lastManagedHeight = managedHeight + + t.runningVP.SetWidth(width) + t.runningVP.SetHeight(runningHeight) + t.runningVP.SetContent(runningContent) + + t.managedVP.SetWidth(width) + t.managedVP.SetHeight(managedHeight) + t.managedVP.SetContent(managedContent) + if m.tableFollowSelection { + t.scrollToSelection(m) + } + + return t.runningVP.View() + "\n" + managedHeader + "\n" + t.managedVP.View() +} + +func (m *topModel) tableTopLines(width int) int { + // Header line + blank line before the table content. + return 2 +} + +func (m *topModel) tableBottomLines(width int) int { + lines := renderedLineCount(m.renderFooter(width)) + if sl := m.renderStatusLine(width); sl != "" { + lines += renderedLineCount(sl) + } + return lines +} + +func (m *topModel) hasStatusLine() bool { + if m.cmdStatus != "" { + return true + } + // With split view, details pane shows service context - no need for status line + return false +} + +func (m *topModel) renderStatusLine(width int) string { + text := "" + if m.cmdStatus != "" { + text = m.cmdStatus + } + // With split view, the details pane shows service state - no duplication in status line + if text == "" { + return "" + } + s := lipgloss.NewStyle().Foreground(lipgloss.Color("208")) + return s.Render(fitLine(text, width)) +} + +func (m *topModel) renderFooter(width int) string { + s := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Italic(true) + h := m.help + h.SetWidth(width) + return strings.TrimRight(s.Render(h.View(m.footerKeyMap())), "\n") +} + +func (m *topModel) footerKeyMap() keyMap { + k := m.keys + k.Search = key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", m.footerFilterLabel()), + ) + return k +} + +func (m *topModel) footerFilterLabel() string { + switch { + case m.mode == viewModeSearch: + inputWidth := runewidth.StringWidth(m.searchInput.Value()) + 1 + if inputWidth < 1 { + inputWidth = 1 + } + if inputWidth > 24 { + inputWidth = 24 + } + m.searchInput.SetWidth(inputWidth) + return m.searchInput.View() + case strings.TrimSpace(m.searchQuery) != "": + return lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render(m.searchQuery) + default: + return "filter" + } +} + +func (t *processTable) sectionHeights(totalHeight, runningLines, managedLines int) (int, int) { + if totalHeight < 3 { + return 1, 1 + } + + separator := 1 + minManaged := 3 + maxRunning := totalHeight - separator - minManaged + if maxRunning < 1 { + maxRunning = 1 + } + + runningHeight := runningLines + if runningHeight > maxRunning { + runningHeight = maxRunning + } + if runningHeight < 1 { + runningHeight = 1 + } + + managedHeight := totalHeight - separator - runningHeight + if managedHeight < 1 { + managedHeight = 1 + } + if managedLines > 0 && managedHeight > managedLines { + managedHeight = managedLines + } + + return runningHeight, managedHeight +} + +func (t *processTable) scrollToSelection(m *topModel) { + visible := m.visibleServers() + managed := m.managedServices() + + if m.focus == focusRunning && m.selected >= 0 && m.selected < len(visible) { + selectedLine := 2 + m.selected + t.scrollViewportToLine(&t.runningVP, selectedLine) + } else if m.focus == focusManaged && m.managedSel >= 0 && m.managedSel < len(managed) { + selectedLine := m.managedSel + t.scrollViewportToLine(&t.managedVP, selectedLine) + } +} + +func (t *processTable) scrollViewportToLine(vp *viewport.Model, selectedLine int) { + totalLines := vp.TotalLineCount() + visibleLines := vp.VisibleLineCount() + currentOffset := vp.YOffset() + + if selectedLine < currentOffset || selectedLine >= currentOffset+visibleLines { + desired := selectedLine - visibleLines/3 + if desired < 0 { + desired = 0 + } + if desired > totalLines-visibleLines { + desired = totalLines - visibleLines + } + if desired < 0 { + desired = 0 + } + vp.SetYOffset(desired) + } +} + +func (m *topModel) renderRunningTable(width int) string { + visible := m.visibleServers() + displayNames := m.displayNames(visible) + headerStyle := lipgloss.NewStyle() + yellowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("11")).Bold(true) // yellow for ascending + orangeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Bold(true) // orange for reverse + + nameW, portW, pidW, projectW, healthW := 14, 6, 7, 14, 7 + sep := 2 + used := nameW + sep + portW + sep + pidW + sep + projectW + sep + healthW + sep + cmdW := width - used + if cmdW < 12 { + cmdW = 12 + } + + nameHeader := headerStyle.Render(fixedCell(fmt.Sprintf("Name (%d)", len(visible)), nameW)) + portHeader := headerStyle.Render(fixedCell("Port", portW)) + pidHeader := headerStyle.Render(fixedCell("PID", pidW)) + projectHeader := headerStyle.Render(fixedCell("Project", projectW)) + commandHeader := headerStyle.Render(fixedCell("Command", cmdW)) + healthHeader := headerStyle.Render(fixedCell("Health", healthW)) + + // Apply color based on sort state + switch m.sortBy { + case sortName: + if m.sortReverse { + nameHeader = orangeStyle.Render(fixedCell(fmt.Sprintf("Name (%d)", len(visible)), nameW)) + } else { + nameHeader = yellowStyle.Render(fixedCell(fmt.Sprintf("Name (%d)", len(visible)), nameW)) + } + case sortPort: + if m.sortReverse { + portHeader = orangeStyle.Render(fixedCell("Port", portW)) + } else { + portHeader = yellowStyle.Render(fixedCell("Port", portW)) + } + case sortProject: + if m.sortReverse { + projectHeader = orangeStyle.Render(fixedCell("Project", projectW)) + } else { + projectHeader = yellowStyle.Render(fixedCell("Project", projectW)) + } + case sortHealth: + if m.sortReverse { + healthHeader = orangeStyle.Render(fixedCell("Health", healthW)) + } else { + healthHeader = yellowStyle.Render(fixedCell("Health", healthW)) + } + } + + header := fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s", + nameHeader, pad(sep), + portHeader, pad(sep), + pidHeader, pad(sep), + projectHeader, pad(sep), + commandHeader, pad(sep), + healthHeader, + ) + divider := fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s", + fixedCell(strings.Repeat("─", nameW), nameW), pad(sep), + fixedCell(strings.Repeat("─", portW), portW), pad(sep), + fixedCell(strings.Repeat("─", pidW), pidW), pad(sep), + fixedCell(strings.Repeat("─", projectW), projectW), pad(sep), + fixedCell(strings.Repeat("─", cmdW), cmdW), pad(sep), + fixedCell(strings.Repeat("─", healthW), healthW), + ) + + if len(visible) == 0 { + if m.searchQuery != "" { + return fitLine("(no matching servers for filter)", width) + } + return fitLine("(no matching servers)", width) + } + + var lines []string + lines = append(lines, fitAnsiLine(header, width)) + lines = append(lines, fitLine(divider, width)) + + rowIndices := make([]int, len(visible)) + for i, srv := range visible { + rowIndices[i] = len(lines) + + project := projectOf(srv) + port := "-" + pid := 0 + cmd := "-" + icon := "…" + if srv.ProcessRecord != nil { + pid = srv.ProcessRecord.PID + cmd = srv.ProcessRecord.Command + if srv.ProcessRecord.Port > 0 { + port = fmt.Sprintf("%d", srv.ProcessRecord.Port) + if cached := m.health[srv.ProcessRecord.Port]; cached != "" { + icon = cached + } + } + } + + truncatedCmd := cmd + if runewidth.StringWidth(cmd) > cmdW { + truncatedCmd = runewidth.Truncate(cmd, cmdW-3, "...") + } + + line := fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s", + fixedCell(displayNames[i], nameW), pad(sep), + fixedCell(port, portW), pad(sep), + fixedCell(fmt.Sprintf("%d", pid), pidW), pad(sep), + fixedCell(project, projectW), pad(sep), + fixedCell(truncatedCmd, cmdW), pad(sep), + fixedCell(icon, healthW), + ) + lines = append(lines, fitLine(line, width)) + } + + if m.selected >= 0 && m.selected < len(visible) { + idx := rowIndices[m.selected] + bg := "8" + if m.focus == focusRunning { + bg = "57" + } + lines[idx] = lipgloss.NewStyle().Background(lipgloss.Color(bg)).Foreground(lipgloss.Color("15")).Render(lines[idx]) + } + + out := strings.Join(lines, "\n") + if m.showHealthDetail && m.selected >= 0 && m.selected < len(visible) { + port := 0 + if visible[m.selected].ProcessRecord != nil { + port = visible[m.selected].ProcessRecord.Port + } + if d := m.healthDetails[port]; d != nil { + out += "\n" + fitLine(fmt.Sprintf("Health detail: %s %dms %s", health.StatusIcon(d.Status), d.ResponseMs, d.Message), width) + } + } + + return out +} + +func (m *topModel) renderManagedHeader(width int) string { + text := fmt.Sprintf("Managed Services (%d) ", len(m.managedServices())) + fillW := width - runewidth.StringWidth(text) + if fillW < 0 { + fillW = 0 + } + header := text + strings.Repeat("─", fillW) + return lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Render(fitLine(header, width)) +} + +func (m *topModel) renderManagedSection(width int) string { + managed := m.managedServices() + if len(managed) == 0 { + return fitLine(`No managed services yet. Use ^A then: add myapp /path/to/app "npm run dev" 3000`, width) + } + + // Split width 50|50 + listWidth := width / 2 + detailsWidth := width - listWidth + if listWidth < 1 { + listWidth = 1 + } + if detailsWidth < 1 { + detailsWidth = 1 + } + + listPane := m.renderManagedList(listWidth) + detailsPane := m.renderManagedDetails(detailsWidth) + + return lipgloss.JoinHorizontal(lipgloss.Top, listPane, detailsPane) +} + +func (m *topModel) renderManagedList(width int) string { + managed := m.managedServices() + + portOwners := make(map[int]int) + for _, svc := range managed { + for _, p := range svc.Ports { + portOwners[p]++ + } + } + + var lines []string + for i, svc := range managed { + state := m.serviceStatus(svc.Name) + if state == "stopped" { + if _, ok := m.starting[svc.Name]; ok { + state = "starting" + } + } + + // Build plain text first, then apply styling + symbolChar := managedStatusSymbol(state) + symbolColor := managedStatusColor(state) + plainLine := fmt.Sprintf("%s %s [%s]", symbolChar, svc.Name, state) + + conflicting := false + for _, p := range svc.Ports { + if portOwners[p] > 1 { + conflicting = true + break + } + } + if conflicting { + plainLine = fmt.Sprintf("%s (port conflict)", plainLine) + } else if len(svc.Ports) > 1 { + plainLine = fmt.Sprintf("%s (ports: %v)", plainLine, svc.Ports) + } + + var line string + if i == m.managedSel { + bg := "8" + if m.focus == focusManaged { + bg = "57" + } + // Keep selected-row styling simple so the full line highlights consistently. + line = lipgloss.NewStyle().Background(lipgloss.Color(bg)).Foreground(lipgloss.Color("15")).Render(fitLine(plainLine, width)) + } else { + // Non-selected: color just the state symbol. + symbolStyled := lipgloss.NewStyle().Foreground(lipgloss.Color(symbolColor)).Bold(true).Render(symbolChar) + line = strings.Replace(plainLine, symbolChar, symbolStyled, 1) + line = fitAnsiLine(line, width) + } + lines = append(lines, line) + } + + return strings.Join(lines, "\n") +} + +func (m *topModel) renderManagedDetails(width int) string { + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) + header := headerStyle.Render("Selected service details") + + managed := m.managedServices() + if m.managedSel < 0 || m.managedSel >= len(managed) { + placeholder := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("Select a managed service to inspect status") + return header + "\n" + fitLine(placeholder, width) + } + + svc := managed[m.managedSel] + state := m.serviceStatus(svc.Name) + if state == "stopped" { + if _, ok := m.starting[svc.Name]; ok { + state = "starting" + } + } + + symbol := lipgloss.NewStyle().Foreground(lipgloss.Color(managedStatusColor(state))).Bold(true).Render(managedStatusSymbol(state)) + + var lines []string + lines = append(lines, fitLine(header, width)) + lines = append(lines, fitLine(fmt.Sprintf(" %s %s [%s]", symbol, svc.Name, state), width)) + + if srv := m.serverInfoForService(svc.Name); srv != nil && srv.Source != "" { + lines = append(lines, fitLine(fmt.Sprintf(" Source: %s", srv.Source), width)) + } + + if state == "crashed" { + if reason := m.crashReasonForService(svc.Name); reason != "" { + lines = append(lines, fitLine(fmt.Sprintf(" Headline: %s", reason), width)) + } + if logPath, err := m.app.LatestServiceLogPath(svc.Name); err == nil && strings.TrimSpace(logPath) != "" { + lines = append(lines, fitLine(fmt.Sprintf(" Log: %s", logPath), width)) + } + if srv := m.serverInfoForService(svc.Name); srv != nil { + for _, logLine := range nonEmptyTail(srv.CrashLogTail, 3) { + lines = append(lines, fitLine(" "+strings.TrimSpace(logLine), width)) + } + } + } + + return strings.Join(lines, "\n") +} + +func (t *processTable) updateFocusedViewport(focus viewFocus, msg tea.Msg) tea.Cmd { + if focus == focusManaged { + var cmd tea.Cmd + t.managedVP, cmd = t.managedVP.Update(msg) + return cmd + } + var cmd tea.Cmd + t.runningVP, cmd = t.runningVP.Update(msg) + return cmd +} + +func (t *processTable) updateViewportForTableY(viewportY int, msg tea.Msg) tea.Cmd { + if viewportY < 0 { + return nil + } + if viewportY < t.lastRunningHeight { + var cmd tea.Cmd + t.runningVP, cmd = t.runningVP.Update(msg) + return cmd + } + if viewportY == t.lastRunningHeight { + return nil + } + + localManagedY := viewportY - t.lastRunningHeight - 1 + if localManagedY >= 0 && localManagedY < t.lastManagedHeight { + var cmd tea.Cmd + t.managedVP, cmd = t.managedVP.Update(msg) + return cmd + } + return nil +} + +func (t *processTable) runningYOffset() int { + return t.runningVP.YOffset() +} + +func (t *processTable) managedYOffset() int { + return t.managedVP.YOffset() +} + +func pad(n int) string { + return strings.Repeat(" ", n) +} + +func (m topModel) displayNames(servers []*models.ServerInfo) []string { + base := make([]string, len(servers)) + projectToSvc := make(map[string]string) + for _, svc := range m.app.ListServices() { + cwd := strings.TrimRight(strings.TrimSpace(svc.CWD), "/") + if cwd != "" { + projectToSvc[cwd] = svc.Name + } + } + for i, srv := range servers { + base[i] = m.serviceNameFor(srv) + if base[i] == "-" && srv.ProcessRecord != nil { + root := strings.TrimRight(strings.TrimSpace(srv.ProcessRecord.ProjectRoot), "/") + cwd := strings.TrimRight(strings.TrimSpace(srv.ProcessRecord.CWD), "/") + if mapped := projectToSvc[root]; mapped != "" { + base[i] = mapped + } else if mapped := projectToSvc[cwd]; mapped != "" { + base[i] = mapped + } + } + } + + count := make(map[string]int) + for _, n := range base { + count[n]++ + } + type row struct{ idx, pid int } + group := make(map[string][]row) + for i, n := range base { + group[n] = append(group[n], row{idx: i, pid: pidOf(servers[i])}) + } + out := make([]string, len(base)) + for name, rows := range group { + if count[name] <= 1 || name == "-" { + for _, r := range rows { + out[r.idx] = name + } + continue + } + sort.Slice(rows, func(i, j int) bool { return rows[i].pid < rows[j].pid }) + for i, r := range rows { + out[r.idx] = fmt.Sprintf("%s~%d", name, i+1) + } + } + return out +} diff --git a/pkg/cli/tui/test_helpers_test.go b/pkg/cli/tui/test_helpers_test.go new file mode 100644 index 0000000..adbd221 --- /dev/null +++ b/pkg/cli/tui/test_helpers_test.go @@ -0,0 +1,99 @@ +package tui + +import ( + "fmt" + "time" + + "github.com/devports/devpt/pkg/models" +) + +type fakeAppDeps struct { + servers []*models.ServerInfo + services []*models.ManagedService + logPaths map[string]string +} + +func newTestModel() *topModel { + return newTopModel(&fakeAppDeps{ + servers: []*models.ServerInfo{ + { + ProcessRecord: &models.ProcessRecord{ + PID: 1001, + Port: 3000, + Command: "node server.js", + CWD: "/tmp/app", + ProjectRoot: "/tmp/app", + }, + Status: "running", + Source: models.SourceManual, + }, + }, + }) +} + +func (f *fakeAppDeps) DiscoverServers() ([]*models.ServerInfo, error) { + return f.servers, nil +} + +func (f *fakeAppDeps) ListServices() []*models.ManagedService { + return f.services +} + +func (f *fakeAppDeps) GetService(name string) *models.ManagedService { + for _, svc := range f.services { + if svc.Name == name { + return svc + } + } + return nil +} + +func (f *fakeAppDeps) ClearServicePID(string) error { + return nil +} + +func (f *fakeAppDeps) AddCmd(name, cwd, command string, ports []int) error { + f.services = append(f.services, &models.ManagedService{Name: name, CWD: cwd, Command: command, Ports: ports}) + return nil +} + +func (f *fakeAppDeps) RemoveCmd(name string) error { + for i, svc := range f.services { + if svc.Name == name { + f.services = append(f.services[:i], f.services[i+1:]...) + return nil + } + } + return fmt.Errorf("service %q not found", name) +} + +func (f *fakeAppDeps) StartCmd(string) error { + return nil +} + +func (f *fakeAppDeps) StopCmd(string) error { + return nil +} + +func (f *fakeAppDeps) RestartCmd(string) error { + return nil +} + +func (f *fakeAppDeps) StopProcess(int, time.Duration) error { + return nil +} + +func (f *fakeAppDeps) TailServiceLogs(string, int) ([]string, error) { + return nil, nil +} + +func (f *fakeAppDeps) TailProcessLogs(int, int) ([]string, error) { + return nil, nil +} + +func (f *fakeAppDeps) LatestServiceLogPath(name string) (string, error) { + if path, ok := f.logPaths[name]; ok { + return path, nil + } + return "", fmt.Errorf("no logs for %q", name) +} diff --git a/pkg/cli/tui/tui_key_input_test.go b/pkg/cli/tui/tui_key_input_test.go new file mode 100644 index 0000000..3dd22af --- /dev/null +++ b/pkg/cli/tui/tui_key_input_test.go @@ -0,0 +1,42 @@ +package tui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" +) + +func TestCommandModeAcceptsRuneKeys(t *testing.T) { + t.Parallel() + + for _, key := range []string{"b", "q", "s", "n"} { + m := &topModel{mode: viewModeCommand} + next, _ := m.Update(tea.KeyPressMsg{Text: key, Code: rune(key[0])}) + updated, ok := next.(*topModel) + if !ok { + t.Fatalf("expected *topModel, got %T", next) + } + if updated.cmdInput != key { + t.Fatalf("expected command input to include rune key %q, got %q", key, updated.cmdInput) + } + } +} + +func TestSearchModeAcceptsRuneKeys(t *testing.T) { + t.Parallel() + + m := newTopModel(&fakeAppDeps{}) + next, _ := m.Update(tea.KeyPressMsg{Text: "/", Code: '/'}) + updated, ok := next.(*topModel) + if !ok { + t.Fatalf("expected *topModel, got %T", next) + } + next, _ = updated.Update(tea.KeyPressMsg{Text: "s", Code: 's'}) + updated, ok = next.(*topModel) + if !ok { + t.Fatalf("expected *topModel, got %T", next) + } + if updated.searchInput.Value() != "s" { + t.Fatalf("expected search input to include rune key, got %q", updated.searchInput.Value()) + } +} diff --git a/pkg/cli/tui/tui_managed_split_test.go b/pkg/cli/tui/tui_managed_split_test.go new file mode 100644 index 0000000..2592f09 --- /dev/null +++ b/pkg/cli/tui/tui_managed_split_test.go @@ -0,0 +1,114 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + "github.com/devports/devpt/pkg/models" + "github.com/stretchr/testify/assert" +) + +func managedSplitTestModel() *topModel { + stoppedAt := time.Date(2026, 3, 27, 21, 54, 25, 0, time.UTC) + deps := &fakeAppDeps{ + services: []*models.ManagedService{ + { + Name: "test-go-basic-fake", + CWD: "/Users/kirby/.config/dev-process-tracker/sandbox/servers/go-basic", + Command: "go run .", + Ports: []int{3401}, + LastStop: &stoppedAt, + }, + { + Name: "docs-preview", + CWD: "/tmp/docs-preview", + Command: "npm run dev", + Ports: []int{3001}, + }, + }, + servers: []*models.ServerInfo{ + { + ManagedService: &models.ManagedService{Name: "test-go-basic-fake", CWD: "/Users/kirby/.config/dev-process-tracker/sandbox/servers/go-basic", Command: "go run .", Ports: []int{3401}}, + Status: "crashed", + Source: models.SourceManaged, + CrashReason: "exit status 1", + CrashLogTail: []string{ + "2026/03/27 21:54:25 [go-basic] listening on http://localhost:3400", + "2026/03/27 21:54:25 listen tcp :3400: bind: address already in use", + "exit status 1", + }, + }, + }, + logPaths: map[string]string{ + "test-go-basic-fake": "~/.config/devpt/logs/test-go-basic-fake/2026-03-12T22-14-37.log", + }, + } + + model := newTopModel(deps) + model.width = 120 + model.height = 30 + model.mode = viewModeTable + model.focus = focusManaged + model.managedSel = 0 + return model +} + +func TestManagedSplitView_SelectedServiceShowsDedicatedDetailsPane(t *testing.T) { + model := managedSplitTestModel() + // Services are sorted alphabetically, so test-go-basic-fake is at index 1 + model.managedSel = 1 + + output := model.View().Content + assert.Contains(t, output, "Managed Services") + assert.Contains(t, output, "Selected service details") + assert.Contains(t, output, "Headline: exit status 1") + assert.Contains(t, output, "test-go-basic-fake") +} + +func TestManagedSplitView_NoSelectionShowsPlaceholderPane(t *testing.T) { + model := managedSplitTestModel() + model.managedSel = -1 + + output := model.View().Content + assert.Contains(t, output, "Selected service details") + assert.Contains(t, output, "Select a managed service to inspect status") +} + +func TestManagedSplitView_StoppedServiceRemainsStopped(t *testing.T) { + model := managedSplitTestModel() + model.managedSel = 0 + + output := model.View().Content + assert.Contains(t, output, "docs-preview [stopped]") + assert.NotContains(t, output, "docs-preview crashed") +} + +func TestManagedSplitView_NarrowWidthPreservesPrimarySignals(t *testing.T) { + model := managedSplitTestModel() + model.width = 72 + model.managedSel = 1 + + output := model.View().Content + assert.Contains(t, output, "✘") + assert.Contains(t, output, "exit status 1") +} + +func TestManagedSplitView_SelectedManagedRowHighlightsWholeLine(t *testing.T) { + model := managedSplitTestModel() + model.managedSel = 0 + _ = model.View() + + var selectedLine string + for _, line := range strings.Split(model.table.managedVP.View(), "\n") { + if strings.Contains(ansi.Strip(line), "docs-preview [stopped]") { + selectedLine = line + break + } + } + + assert.NotEmpty(t, selectedLine) + assert.Contains(t, selectedLine, "48;5;57") + assert.NotContains(t, selectedLine, "\x1b[m docs-preview") +} diff --git a/pkg/cli/tui/tui_state_test.go b/pkg/cli/tui/tui_state_test.go new file mode 100644 index 0000000..225f465 --- /dev/null +++ b/pkg/cli/tui/tui_state_test.go @@ -0,0 +1,428 @@ +package tui + +import ( + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/devports/devpt/pkg/models" + "github.com/stretchr/testify/assert" +) + +func TestTUISimpleUpdate(t *testing.T) { + model := newTestModel() + + t.Run("tab switches focus between running and managed", func(t *testing.T) { + initialFocus := model.focus + newModel, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + assert.Nil(t, cmd) + + updatedModel := newModel.(*topModel) + assert.NotEqual(t, initialFocus, updatedModel.focus) + if initialFocus == focusRunning { + assert.Equal(t, focusManaged, updatedModel.focus) + } else { + assert.Equal(t, focusRunning, updatedModel.focus) + } + }) + + t.Run("escape key in logs mode returns to table", func(t *testing.T) { + model.mode = viewModeLogs + newModel, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + assert.Nil(t, cmd) + updatedModel := newModel.(*topModel) + assert.Equal(t, viewModeTable, updatedModel.mode) + }) + + t.Run("forward slash enters search mode", func(t *testing.T) { + model.mode = viewModeTable + newModel, cmd := model.Update(tea.KeyPressMsg{Text: "/", Code: '/'}) + assert.NotNil(t, cmd) + updatedModel := newModel.(*topModel) + assert.Equal(t, viewModeSearch, updatedModel.mode) + }) + + t.Run("question mark enters help mode", func(t *testing.T) { + model.mode = viewModeTable + newModel, cmd := model.Update(tea.KeyPressMsg{Text: "?", Code: '?'}) + assert.Nil(t, cmd) + updatedModel := newModel.(*topModel) + assert.Equal(t, modalHelp, updatedModel.activeModalKind()) + }) + + t.Run("s key cycles through sort modes", func(t *testing.T) { + model.mode = viewModeTable + initialSort := model.sortBy + newModel, cmd := model.Update(tea.KeyPressMsg{Text: "s", Code: 's'}) + assert.Nil(t, cmd) + updatedModel := newModel.(*topModel) + assert.NotEqual(t, initialSort, updatedModel.sortBy) + }) + + t.Run("enter opens logs for running selection", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeTable + model.focus = focusRunning + model.selected = 0 + + newModel, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + assert.NotNil(t, cmd) + + updatedModel := newModel.(*topModel) + assert.Equal(t, viewModeLogs, updatedModel.mode) + assert.Equal(t, 1001, updatedModel.logPID) + }) + + t.Run("enter starts service for managed selection", func(t *testing.T) { + model := newTopModel(&fakeAppDeps{ + servers: []*models.ServerInfo{ + { + ManagedService: &models.ManagedService{Name: "test-svc", CWD: "/tmp/app", Command: "npm run dev", Ports: []int{3000}}, + ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node server.js", CWD: "/tmp/app", ProjectRoot: "/tmp/app"}, + }, + }, + services: []*models.ManagedService{ + {Name: "test-svc", CWD: "/tmp/app", Command: "npm run dev", Ports: []int{3000}}, + }, + }) + model.mode = viewModeTable + model.focus = focusManaged + model.managedSel = 0 + + newModel, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + assert.Nil(t, cmd) + + updatedModel := newModel.(*topModel) + assert.Equal(t, viewModeTable, updatedModel.mode) + assert.Contains(t, updatedModel.cmdStatus, `Started "test-svc"`) + }) +} + +func TestTUIKeySequence(t *testing.T) { + t.Run("navigate and return to table", func(t *testing.T) { + model := newTestModel() + initialMode := model.mode + + newModel, _ := model.Update(tea.KeyPressMsg{Text: "/", Code: '/'}) + model = newModel.(*topModel) + assert.Equal(t, viewModeSearch, model.mode) + + newModel, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + model = newModel.(*topModel) + assert.Equal(t, initialMode, model.mode) + }) + + t.Run("help mode and exit", func(t *testing.T) { + model := newTestModel() + + newModel, _ := model.Update(tea.KeyPressMsg{Text: "?", Code: '?'}) + model = newModel.(*topModel) + assert.Equal(t, modalHelp, model.activeModalKind()) + + newModel, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + model = newModel.(*topModel) + assert.Equal(t, viewModeTable, model.mode) + assert.Nil(t, model.modal) + }) +} + +func TestTUIQuitKey(t *testing.T) { + model := newTestModel() + + t.Run("q key returns quit command", func(t *testing.T) { + _, cmd := model.Update(tea.KeyPressMsg{Text: "q", Code: 'q'}) + assert.NotNil(t, cmd) + }) + + t.Run("ctrl+c returns quit command", func(t *testing.T) { + _, cmd := model.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + assert.NotNil(t, cmd) + }) +} + +func TestTUIViewRendering(t *testing.T) { + model := newTestModel() + model.width = 100 + model.height = 40 + + t.Run("table view contains expected elements", func(t *testing.T) { + model.mode = viewModeTable + output := model.View() + assert.Contains(t, output.Content, "Dev Process Tracker") + assert.Contains(t, output.Content, "Name") + assert.Contains(t, output.Content, "Port") + assert.Contains(t, output.Content, "PID") + }) + + t.Run("help view contains help text", func(t *testing.T) { + model.openHelpModal() + output := model.View() + assert.Contains(t, output.Content, "Help") + assert.Contains(t, output.Content, "switch list") + }) +} + +func TestViewportStateTransitions(t *testing.T) { + t.Run("viewport state initialization", func(t *testing.T) { + model := newTestModel() + _ = model + t.Skip("TODO: Verify viewport state fields exist - OBL-highlight-state") + }) + + t.Run("highlight index boundary conditions", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.highlightMatches = []int{10, 20, 30} + model.highlightIndex = 0 + model.highlightIndex = len(model.highlightMatches) - 1 + _ = model + t.Skip("TODO: Test boundary conditions - Edge-2") + }) + + t.Run("highlight index with empty matches", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.highlightMatches = []int{} + model.highlightIndex = 0 + _ = model + t.Skip("TODO: Handle empty highlights - Edge case") + }) +} + +func TestSortCycling(t *testing.T) { + model := newTestModel() + + t.Run("cycleSort ascending to reverse to recent", func(t *testing.T) { + // Start with recent (default) + assert.Equal(t, sortRecent, model.sortBy) + assert.False(t, model.sortReverse) + + // Click name column -> ascending (yellow) + model.cycleSort(sortName) + assert.Equal(t, sortName, model.sortBy) + assert.False(t, model.sortReverse) + + // Click same column again -> reverse (orange) + model.cycleSort(sortName) + assert.Equal(t, sortName, model.sortBy) + assert.True(t, model.sortReverse) + + // Click same column again -> reset to recent + model.cycleSort(sortName) + assert.Equal(t, sortRecent, model.sortBy) + assert.False(t, model.sortReverse) + }) + + t.Run("clicking different column resets to ascending", func(t *testing.T) { + model.sortBy = sortName + model.sortReverse = true + + // Click different column -> ascending + model.cycleSort(sortPort) + assert.Equal(t, sortPort, model.sortBy) + assert.False(t, model.sortReverse) + }) + + t.Run("s key cycles sort modes without reverse", func(t *testing.T) { + model.sortBy = sortRecent + model.sortReverse = false + + // 's' key should cycle through modes and reset reverse + newModel, _ := model.Update(tea.KeyPressMsg{Code: 's'}) + updated := newModel.(*topModel) + assert.Equal(t, sortName, updated.sortBy) + assert.False(t, updated.sortReverse) + + newModel, _ = updated.Update(tea.KeyPressMsg{Code: 's'}) + updated = newModel.(*topModel) + assert.Equal(t, sortProject, updated.sortBy) + assert.False(t, updated.sortReverse) + }) +} + +func TestSortDirectionToggle(t *testing.T) { + model := newTestModel() + + t.Run("toggle flips reverse without changing column", func(t *testing.T) { + model.sortBy = sortName + model.sortReverse = false + + model.toggleSortDirection() + assert.Equal(t, sortName, model.sortBy) + assert.True(t, model.sortReverse) + + model.toggleSortDirection() + assert.Equal(t, sortName, model.sortBy) + assert.False(t, model.sortReverse) + }) + + t.Run("toggle is no-op in recent mode", func(t *testing.T) { + model.sortBy = sortRecent + model.sortReverse = false + + model.toggleSortDirection() + assert.Equal(t, sortRecent, model.sortBy) + assert.False(t, model.sortReverse) + }) + + t.Run("toggle preserves column across multiple flips", func(t *testing.T) { + model.sortBy = sortPort + model.sortReverse = false + + model.toggleSortDirection() + model.toggleSortDirection() + model.toggleSortDirection() + + assert.Equal(t, sortPort, model.sortBy) + assert.True(t, model.sortReverse) + }) + + t.Run("toggle works on every sortable column", func(t *testing.T) { + columns := []sortMode{sortName, sortProject, sortPort, sortHealth} + for _, col := range columns { + model.sortBy = col + model.sortReverse = false + + model.toggleSortDirection() + assert.Equal(t, col, model.sortBy, "column changed after toggle for %s", sortModeLabel(col)) + assert.True(t, model.sortReverse, "reverse not set for %s", sortModeLabel(col)) + } + }) +} + +func TestSortDirectionToggleViaKey(t *testing.T) { + model := newTestModel() + model.mode = viewModeTable + + t.Run("S key toggles direction for current column", func(t *testing.T) { + model.sortBy = sortName + model.sortReverse = false + + newModel, _ := model.Update(tea.KeyPressMsg{Text: "S", Code: 'S'}) + updated := newModel.(*topModel) + assert.Equal(t, sortName, updated.sortBy) + assert.True(t, updated.sortReverse) + }) + + t.Run("S key preserves column", func(t *testing.T) { + model.sortBy = sortProject + model.sortReverse = false + + newModel, _ := model.Update(tea.KeyPressMsg{Text: "S", Code: 'S'}) + updated := newModel.(*topModel) + assert.Equal(t, sortProject, updated.sortBy) + assert.True(t, updated.sortReverse) + }) + + t.Run("S key is no-op in recent mode", func(t *testing.T) { + model.sortBy = sortRecent + model.sortReverse = false + + newModel, _ := model.Update(tea.KeyPressMsg{Text: "S", Code: 'S'}) + updated := newModel.(*topModel) + assert.Equal(t, sortRecent, updated.sortBy) + assert.False(t, updated.sortReverse) + }) + + t.Run("S and s are independent operations", func(t *testing.T) { + model.sortBy = sortRecent + model.sortReverse = false + + // s -> Name ascending + newModel, _ := model.Update(tea.KeyPressMsg{Text: "s", Code: 's'}) + updated := newModel.(*topModel) + assert.Equal(t, sortName, updated.sortBy) + assert.False(t, updated.sortReverse) + + // S -> Name descending + newModel, _ = updated.Update(tea.KeyPressMsg{Text: "S", Code: 'S'}) + updated = newModel.(*topModel) + assert.Equal(t, sortName, updated.sortBy) + assert.True(t, updated.sortReverse) + + // s -> Project ascending (column switch resets reverse) + newModel, _ = updated.Update(tea.KeyPressMsg{Text: "s", Code: 's'}) + updated = newModel.(*topModel) + assert.Equal(t, sortProject, updated.sortBy) + assert.False(t, updated.sortReverse) + }) +} + +func TestSortColumnSwitchResetsDirection(t *testing.T) { + model := newTestModel() + model.mode = viewModeTable + + t.Run("s key resets reverse when switching columns", func(t *testing.T) { + model.sortBy = sortName + model.sortReverse = true + + newModel, _ := model.Update(tea.KeyPressMsg{Text: "s", Code: 's'}) + updated := newModel.(*topModel) + assert.Equal(t, sortProject, updated.sortBy) + assert.False(t, updated.sortReverse) + }) + + t.Run("s key wraps around to recent and resets reverse", func(t *testing.T) { + model.sortBy = sortHealth + model.sortReverse = true + + newModel, _ := model.Update(tea.KeyPressMsg{Text: "s", Code: 's'}) + updated := newModel.(*topModel) + assert.Equal(t, sortRecent, updated.sortBy) + assert.False(t, updated.sortReverse) + }) +} + +func TestSortPersistenceAcrossRefresh(t *testing.T) { + model := newTestModel() + model.width = 100 + model.height = 40 + model.mode = viewModeTable + + t.Run("sort state survives tick refresh", func(t *testing.T) { + model.sortBy = sortName + model.sortReverse = true + + newModel, _ := model.Update(tickMsg(time.Now())) + updated := newModel.(*topModel) + assert.Equal(t, sortName, updated.sortBy) + assert.True(t, updated.sortReverse) + }) + + t.Run("sort state survives multiple refreshes", func(t *testing.T) { + model.sortBy = sortPort + model.sortReverse = true + + for i := 0; i < 5; i++ { + newModel, _ := model.Update(tickMsg(time.Now())) + model = newModel.(*topModel) + } + assert.Equal(t, sortPort, model.sortBy) + assert.True(t, model.sortReverse) + }) +} + +func TestColumnAtX(t *testing.T) { + model := newTestModel() + model.width = 120 + + tests := []struct { + name string + x int + wantSort sortMode + }{ + {"name column", 5, sortName}, + {"port column", 18, sortPort}, + {"pid column", 26, sortRecent}, + {"project column", 40, sortProject}, + {"health column", 115, sortHealth}, + {"out of bounds", 200, sortMode(-1)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := model.columnAtX(tt.x) + assert.Equal(t, tt.wantSort, got) + }) + } +} diff --git a/pkg/cli/tui/tui_ui_test.go b/pkg/cli/tui/tui_ui_test.go new file mode 100644 index 0000000..18d2cda --- /dev/null +++ b/pkg/cli/tui/tui_ui_test.go @@ -0,0 +1,617 @@ +package tui + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/devports/devpt/pkg/buildinfo" + "github.com/devports/devpt/pkg/models" + "github.com/stretchr/testify/assert" +) + +func TestView_EscapeSequences(t *testing.T) { + model := newTestModel() + model.width = 100 + model.height = 40 + + t.Run("no raw screen clear escape", func(t *testing.T) { + output := model.View().Content + assert.NotContains(t, output, "\x1b[2J") + }) + + t.Run("output is non-empty", func(t *testing.T) { + output := model.View().Content + assert.NotEmpty(t, output) + }) +} + +func TestView_HeaderContent(t *testing.T) { + model := newTestModel() + model.width = 100 + model.mode = viewModeTable + + t.Run("header text is present", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "Dev Process Tracker") + assert.Contains(t, output, "Health Monitor") + }) + + t.Run("header shows current version", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, buildinfo.Version) + }) + + t.Run("header omits quit hint", func(t *testing.T) { + output := model.View().Content + assert.NotContains(t, output, "q quit") + }) +} + +func TestView_StatusBar(t *testing.T) { + model := newTestModel() + model.width = 120 + + t.Run("footer contains keybinding hints", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "switch list") + assert.Contains(t, output, "logs/start") + assert.Contains(t, output, "filter") + assert.Contains(t, output, "toggle help") + }) + + t.Run("footer shows service count", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "Name (1)") + }) + + t.Run("footer stays compact", func(t *testing.T) { + output := model.View().Content + assert.NotContains(t, output, "D for debug") + }) +} + +func TestView_CommandMode(t *testing.T) { + model := newTestModel() + model.width = 100 + model.mode = viewModeCommand + + t.Run("command prompt shows colon", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, ":") + }) + + t.Run("command mode shows hint", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "Esc to go back") + }) + + t.Run("command mode shows example", func(t *testing.T) { + model.cmdInput = "add" + output := model.View().Content + assert.Contains(t, output, "Example:") + }) +} + +func TestView_ConfirmDialog(t *testing.T) { + model := newTestModel() + model.width = 100 + model.height = 24 + model.openConfirmModal(&confirmState{kind: confirmStopPID, prompt: "Stop PID 123?", pid: 123}) + + t.Run("confirm prompt includes [y/N]", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "Enter/y confirm, n/Esc cancel") + }) + + t.Run("confirm shows prompt text", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "Stop PID 123?") + }) + + t.Run("confirm keeps table visible behind modal", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "app") + assert.Contains(t, output, "No managed") + assert.Contains(t, output, "Confirm") + }) + + t.Run("click outside confirm closes modal", func(t *testing.T) { + clickModel := newTestModel() + clickModel.width = 100 + clickModel.height = 24 + clickModel.openConfirmModal(&confirmState{kind: confirmStopPID, prompt: "Stop PID 123?", pid: 123}) + + newModel, cmd := clickModel.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 0, Y: 0}) + assert.Nil(t, cmd) + + updated := newModel.(*topModel) + assert.Equal(t, viewModeTable, updated.mode) + assert.Nil(t, updated.modal) + assert.Nil(t, updated.confirm) + assert.Equal(t, "Cancelled", updated.cmdStatus) + }) + + t.Run("enter confirms action in confirm mode", func(t *testing.T) { + enterModel := newTestModel() + enterModel.width = 100 + enterModel.height = 24 + enterModel.openConfirmModal(&confirmState{kind: confirmRemoveService, prompt: "Remove test?", name: "missing"}) + + newModel, cmd := enterModel.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + assert.Nil(t, cmd) + + updated := newModel.(*topModel) + assert.Equal(t, viewModeTable, updated.mode) + assert.Nil(t, updated.modal) + assert.Nil(t, updated.confirm) + assert.NotEmpty(t, updated.cmdStatus) + }) +} + +func TestView_TableStructure(t *testing.T) { + model := newTestModel() + model.width = 120 + model.mode = viewModeTable + + t.Run("table has all required column headers", func(t *testing.T) { + output := model.View().Content + lines := strings.Split(output, "\n") + headerLine := findLineContaining(lines, "Name") + + assert.NotEmpty(t, headerLine) + assert.Contains(t, headerLine, "Name (1)") + assert.Contains(t, headerLine, "Port") + assert.Contains(t, headerLine, "PID") + assert.Contains(t, headerLine, "Project") + assert.Contains(t, headerLine, "Command") + assert.Contains(t, headerLine, "Health") + }) + + t.Run("table has divider line", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "─") + }) +} + +func TestView_ManagedServicesSection(t *testing.T) { + model := newTestModel() + model.width = 120 + model.mode = viewModeTable + + t.Run("context line shows focus state", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "switch list") + }) + + t.Run("tab switch hint in footer", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "switch list") + }) +} + +func TestView_ContextLine(t *testing.T) { + model := newTestModel() + model.width = 100 + model.mode = viewModeTable + + t.Run("context line shows focus", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "switch list") + }) + + t.Run("context line omits service count", func(t *testing.T) { + output := model.View().Content + assert.NotContains(t, output, "Services: 1 |") + }) +} + +func TestView_LogsMode(t *testing.T) { + model := newTestModel() + model.width = 100 + model.mode = viewModeLogs + model.logPID = 1234 + + t.Run("logs header shows service name", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "Logs:") + assert.Contains(t, output, "PID: 1234") + }) + + t.Run("logs header shows port field", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "Port:") + }) + + t.Run("logs footer shows back hint", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "b back") + }) +} + +func TestView_HelpMode(t *testing.T) { + model := newTestModel() + model.width = 100 + model.height = 24 + model.openHelpModal() + + t.Run("help shows keymap header", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "Help") + }) + + t.Run("help shows keybindings", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "switch list") + assert.Contains(t, output, "toggle help") + assert.Contains(t, output, "/") + }) + + t.Run("help shows command hints", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "add") + assert.Contains(t, output, "logs/start") + assert.Contains(t, output, "toggle follow") + }) + + t.Run("help keeps table visible behind modal", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "app") + assert.Contains(t, output, "Manage") + assert.Contains(t, output, "Help") + }) + + t.Run("click outside help closes modal", func(t *testing.T) { + clickModel := newTestModel() + clickModel.width = 100 + clickModel.height = 24 + clickModel.openHelpModal() + + newModel, cmd := clickModel.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 0, Y: 0}) + assert.Nil(t, cmd) + + updated := newModel.(*topModel) + assert.Equal(t, viewModeTable, updated.mode) + assert.Nil(t, updated.modal) + }) +} + +func TestView_SearchMode(t *testing.T) { + model := newTestModel() + model.width = 100 + model.mode = viewModeSearch + model.searchQuery = "node" + model.searchInput.SetValue("node") + model.searchInput.Focus() + + t.Run("search prompt shows query", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "node") + assert.Contains(t, output, ">") + assert.Contains(t, output, "Name (1)") + }) + + t.Run("empty search shows inline input", func(t *testing.T) { + model.searchQuery = "" + model.searchInput.SetValue("") + output := model.View().Content + assert.Contains(t, output, ">") + }) +} + +func TestView_SelectedRow(t *testing.T) { + model := newTestModel() + model.width = 120 + model.mode = viewModeTable + model.selected = 0 + + t.Run("view renders without error", func(t *testing.T) { + assert.NotPanics(t, func() { + _ = model.View() + }) + }) + + t.Run("output is not empty", func(t *testing.T) { + output := model.View().Content + assert.NotEmpty(t, output) + }) +} + +func TestView_ManagedServiceSelection(t *testing.T) { + model := newTestModel() + model.width = 120 + model.mode = viewModeTable + model.focus = focusManaged + + t.Run("managed focus shows in context", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "Managed Services") + }) + + t.Run("tab switch hint available for focus change", func(t *testing.T) { + output := model.View().Content + assert.Contains(t, output, "switch list") + }) +} + +func TestView_ResponsiveWidth(t *testing.T) { + tests := []struct { + name string + width int + shouldPanic bool + }{ + {"narrow terminal 80", 80, false}, + {"standard terminal 100", 100, false}, + {"wide terminal 120", 120, false}, + {"very wide 200", 200, false}, + {"edge case zero", 0, false}, + {"edge case small", 40, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + model := newTestModel() + model.width = tt.width + model.height = 40 + + if tt.shouldPanic { + assert.Panics(t, func() { model.View() }) + } else { + assert.NotPanics(t, func() { + output := model.View().Content + assert.NotEmpty(t, output) + }) + } + }) + } +} + +func TestView_ResponsiveHeight(t *testing.T) { + tests := []struct { + name string + height int + }{ + {"short terminal 10", 10}, + {"standard terminal 24", 24}, + {"tall terminal 40", 40}, + {"very tall 100", 100}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + model := newTestModel() + model.width = 100 + model.height = tt.height + + assert.NotPanics(t, func() { + output := model.View().Content + assert.NotEmpty(t, output) + }) + }) + } +} + +func TestView_TextWrapping(t *testing.T) { + model := newTestModel() + model.width = 80 + + t.Run("long footer wraps to width", func(t *testing.T) { + output := model.View().Content + lines := strings.Split(output, "\n") + for _, line := range lines { + if strings.Contains(line, "switch list") || strings.Contains(line, "filter") || strings.Contains(line, ">") { + visibleWidth := calculateVisibleWidth(line) + assert.LessOrEqual(t, visibleWidth, model.width+10) + } + } + }) +} + +func TestView_EmptyStates(t *testing.T) { + t.Run("empty servers list shows message", func(t *testing.T) { + model := newTestModel() + model.servers = []*models.ServerInfo{} + model.width = 100 + output := model.View().Content + assert.Contains(t, output, "(no matching servers") + }) + + t.Run("empty filter shows message", func(t *testing.T) { + model := newTestModel() + model.servers = []*models.ServerInfo{} + model.searchQuery = "nonexistent" + model.width = 100 + output := model.View().Content + assert.Contains(t, output, "(no matching servers for filter") + }) +} + +func TestView_ModeTransitions(t *testing.T) { + model := newTestModel() + model.width = 100 + model.height = 40 + + t.Run("table mode renders", func(t *testing.T) { + model.mode = viewModeTable + output := model.View().Content + assert.NotEmpty(t, output) + assert.Contains(t, output, "Dev Process Tracker") + assert.Contains(t, output, "Name (1)") + }) + + t.Run("logs mode renders", func(t *testing.T) { + model.mode = viewModeLogs + output := model.View().Content + assert.NotEmpty(t, output) + assert.Contains(t, output, "Logs:") + }) + + t.Run("command mode renders", func(t *testing.T) { + model.mode = viewModeCommand + output := model.View().Content + assert.NotEmpty(t, output) + assert.Contains(t, output, ":") + }) + + t.Run("search mode renders", func(t *testing.T) { + model.mode = viewModeSearch + model.searchInput.SetValue("") + model.searchInput.Focus() + output := model.View().Content + assert.NotEmpty(t, output) + assert.Contains(t, output, ">") + assert.Contains(t, output, "Name (1)") + }) + + t.Run("help mode renders", func(t *testing.T) { + model.openHelpModal() + output := model.View().Content + assert.NotEmpty(t, output) + assert.Contains(t, output, "Help") + assert.Contains(t, output, "switch list") + }) +} + +func TestView_StatusMessage(t *testing.T) { + model := newTestModel() + model.width = 100 + + t.Run("status message appears", func(t *testing.T) { + model.cmdStatus = "Service started" + output := model.View().Content + assert.Contains(t, output, "Service started") + }) + + t.Run("empty status does not appear", func(t *testing.T) { + model.cmdStatus = "" + output := model.View().Content + assert.NotEmpty(t, output) + }) +} + +func TestView_StatusAndFooterClampToWidth(t *testing.T) { + model := newTestModel() + model.width = 40 + model.height = 20 + model.mode = viewModeTable + model.cmdStatus = `Restarted "mdt-be" because the previous health check timed out on localhost:3001` + + output := model.View().Content + lines := strings.Split(output, "\n") + var statusLine, footerLine string + + for _, line := range lines { + if strings.Contains(line, `Restarted "mdt-be"`) { + statusLine = line + } + if strings.Contains(line, "switch list") { + footerLine = line + } + } + + assert.NotEmpty(t, statusLine) + assert.NotEmpty(t, footerLine) + assert.LessOrEqual(t, calculateVisibleWidth(statusLine), model.width) + assert.LessOrEqual(t, calculateVisibleWidth(footerLine), model.width) + assert.Contains(t, statusLine, `Restarted "mdt-be" because the previo`) + assert.NotContains(t, statusLine, "localhost:3001") +} + +func TestView_SortModeDisplay(t *testing.T) { + model := newTestModel() + model.width = 100 + + tests := []struct { + name string + sortMode sortMode + }{ + {"sort by recent", sortRecent}, + {"sort by name", sortName}, + {"sort by project", sortProject}, + {"sort by port", sortPort}, + {"sort by health", sortHealth}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + model.sortBy = tt.sortMode + output := model.View().Content + assert.Contains(t, output, "switch list") + assert.Contains(t, output, "Name (1)") + }) + } +} + +func TestView_ManagedCrashContextAndSymbols(t *testing.T) { + stoppedAt := time.Date(2026, 3, 27, 21, 54, 25, 0, time.UTC) + deps := &fakeAppDeps{ + services: []*models.ManagedService{ + { + Name: "test-go-basic-fake", + CWD: "/Users/kirby/.config/dev-process-tracker/sandbox/servers/go-basic", + Command: "go run .", + Ports: []int{3401}, + LastStop: &stoppedAt, + }, + }, + servers: []*models.ServerInfo{ + { + ManagedService: &models.ManagedService{Name: "test-go-basic-fake", CWD: "/Users/kirby/.config/dev-process-tracker/sandbox/servers/go-basic", Command: "go run .", Ports: []int{3401}}, + Status: "crashed", + Source: models.SourceManaged, + CrashReason: "exit status 1", + CrashLogTail: []string{ + "2026/03/27 21:54:25 [go-basic] listening on http://localhost:3400", + "2026/03/27 21:54:25 listen tcp :3400: bind: address already in use", + "exit status 1", + }, + }, + }, + logPaths: map[string]string{ + "test-go-basic-fake": "~/.config/devpt/logs/test-go-basic-fake/2026-03-12T22-14-37.log", + }, + } + + model := newTopModel(deps) + model.width = 180 + model.height = 30 + model.mode = viewModeTable + model.focus = focusManaged + model.managedSel = 0 + + output := model.View().Content + assert.Contains(t, output, "✘") + assert.Contains(t, output, "test-go-basic-fake [crashed]") + assert.Contains(t, output, "Headline: exit status 1") + assert.Contains(t, output, "Log: ~/.config/devpt/logs/test-go-basic-fake/2026-03-12T22-14-37.log") + assert.Contains(t, output, "listen tcp :3400: bind: address already in use") + assert.Contains(t, output, "Source: managed") +} + +func findLineContaining(lines []string, pattern string) string { + for _, line := range lines { + if strings.Contains(line, pattern) { + return line + } + } + return "" +} + +func calculateVisibleWidth(s string) int { + inEscape := false + visible := 0 + for i := 0; i < len(s); i++ { + c := s[i] + if c == 0x1b { + inEscape = true + } else if inEscape { + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') { + inEscape = false + } + } else { + visible++ + } + } + return visible +} diff --git a/pkg/cli/tui/tui_viewport_test.go b/pkg/cli/tui/tui_viewport_test.go new file mode 100644 index 0000000..e976fe7 --- /dev/null +++ b/pkg/cli/tui/tui_viewport_test.go @@ -0,0 +1,608 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + "time" + + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/assert" + + "github.com/devports/devpt/pkg/models" +) + +func TestViewportMouseClickNavigation(t *testing.T) { + model := newTestModel() + + t.Run("gutter click jumps to clicked line", func(t *testing.T) { + model.mode = viewModeLogs + model.logLines = make([]string, 1000) + for i := 0; i < 1000; i++ { + model.logLines[i] = fmt.Sprintf("Log line %d", i) + } + + model.viewport = viewport.New() + model.viewport.SetWidth(80) + model.viewport.SetHeight(24) + model.viewport.SetContent(strings.Join(model.logLines, "\n")) + initialOffset := model.viewport.YOffset() + clickedLine := 5 + gutterWidth := model.calculateGutterWidth() + + mouseMsg := tea.MouseClickMsg{Button: tea.MouseLeft, X: gutterWidth - 1, Y: clickedLine} + newModel, cmd := model.Update(mouseMsg) + assert.Nil(t, cmd) + + updatedModel := newModel.(*topModel) + assert.Equal(t, clickedLine, updatedModel.viewport.YOffset()) + assert.NotEqual(t, initialOffset, updatedModel.viewport.YOffset()) + }) + + t.Run("text click repositions viewport to center", func(t *testing.T) { + model.mode = viewModeLogs + model.logLines = make([]string, 1000) + for i := 0; i < 1000; i++ { + model.logLines[i] = fmt.Sprintf("Log line %d", i) + } + + model.viewport = viewport.New() + model.viewport.SetWidth(80) + model.viewport.SetHeight(24) + model.viewport.SetContent(strings.Join(model.logLines, "\n")) + + initialOffset := model.viewport.YOffset() + visibleLines := model.viewport.VisibleLineCount() + gutterWidth := model.calculateGutterWidth() + clickedAbsoluteLine := 100 + model.viewport.SetYOffset(clickedAbsoluteLine - 5) + + mouseMsg := tea.MouseClickMsg{Button: tea.MouseLeft, X: gutterWidth + 10, Y: 5} + newModel, cmd := model.Update(mouseMsg) + assert.Nil(t, cmd) + + updatedModel := newModel.(*topModel) + expectedOffset := clickedAbsoluteLine - (visibleLines / 2) + if expectedOffset < 0 { + expectedOffset = 0 + } + + assert.Equal(t, expectedOffset, updatedModel.viewport.YOffset()) + assert.NotEqual(t, initialOffset, updatedModel.viewport.YOffset()) + }) + + t.Run("click with no content is no-op", func(t *testing.T) { + model.mode = viewModeLogs + model.logLines = nil + model.viewport = viewport.New() + initialOffset := model.viewport.YOffset() + + mouseMsg := tea.MouseClickMsg{Button: tea.MouseLeft, X: 10, Y: 10} + newModel, cmd := model.Update(mouseMsg) + assert.Nil(t, cmd) + + updatedModel := newModel.(*topModel) + assert.NotNil(t, updatedModel) + assert.Equal(t, initialOffset, updatedModel.viewport.YOffset()) + }) +} + +func TestViewportHighlightCycling(t *testing.T) { + model := newTestModel() + + t.Run("n key advances to next highlight", func(t *testing.T) { + model.mode = viewModeLogs + model.highlightMatches = []int{10, 20, 30, 40, 50} + model.highlightIndex = 0 + newModel, cmd := model.Update(tea.KeyPressMsg{Text: "n", Code: 'n'}) + assert.Nil(t, cmd) + updatedModel := newModel.(*topModel) + assert.Equal(t, 1, updatedModel.highlightIndex) + }) + + t.Run("N key moves to previous highlight", func(t *testing.T) { + model.mode = viewModeLogs + model.highlightMatches = []int{10, 20, 30, 40, 50} + model.highlightIndex = 3 + newModel, cmd := model.Update(tea.KeyPressMsg{Text: "N", Code: 'N'}) + assert.Nil(t, cmd) + updatedModel := newModel.(*topModel) + assert.Equal(t, 2, updatedModel.highlightIndex) + }) + + t.Run("highlight cycling wraps from last to first", func(t *testing.T) { + model.mode = viewModeLogs + model.highlightMatches = []int{10, 20, 30} + model.highlightIndex = 2 + newModel, cmd := model.Update(tea.KeyPressMsg{Text: "n", Code: 'n'}) + assert.Nil(t, cmd) + updatedModel := newModel.(*topModel) + assert.Equal(t, 0, updatedModel.highlightIndex) + }) + + t.Run("highlight cycling wraps from first to last", func(t *testing.T) { + model.mode = viewModeLogs + model.highlightMatches = []int{10, 20, 30} + model.highlightIndex = 0 + newModel, cmd := model.Update(tea.KeyPressMsg{Text: "N", Code: 'N'}) + assert.Nil(t, cmd) + updatedModel := newModel.(*topModel) + assert.Equal(t, 2, updatedModel.highlightIndex) + }) + + t.Run("highlight keys ignored when no highlights exist", func(t *testing.T) { + model.mode = viewModeLogs + model.highlightMatches = []int{} + model.highlightIndex = 0 + newModel, cmd := model.Update(tea.KeyPressMsg{Text: "n", Code: 'n'}) + assert.Nil(t, cmd) + updatedModel := newModel.(*topModel) + assert.Equal(t, 0, updatedModel.highlightIndex) + }) +} + +func TestViewportMatchCounter(t *testing.T) { + t.Run("footer shows match counter when highlights active", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.highlightMatches = []int{10, 20, 30, 40, 50} + model.highlightIndex = 2 + view := model.View().Content + assert.Contains(t, view, "Match 3/5") + }) + + t.Run("footer shows correct format for first match", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.highlightMatches = []int{10, 20, 30} + model.highlightIndex = 0 + view := model.View().Content + assert.Contains(t, view, "Match 1/3") + }) +} + +func TestViewportResizePersistence(t *testing.T) { + t.Run("terminal resize preserves highlight index", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.highlightMatches = []int{10, 20, 30, 40, 50} + model.highlightIndex = 3 + + newModel, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + updatedModel := newModel.(*topModel) + assert.Equal(t, 3, updatedModel.highlightIndex) + }) + + t.Run("terminal resize preserves highlight matches", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.highlightMatches = []int{10, 20, 30, 40, 50} + model.highlightIndex = 3 + + newModel, _ := model.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + updatedModel := newModel.(*topModel) + assert.Equal(t, 3, updatedModel.highlightIndex) + assert.Equal(t, []int{10, 20, 30, 40, 50}, updatedModel.highlightMatches) + }) + + t.Run("terminal resize with no highlights is safe", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.highlightMatches = []int{} + model.highlightIndex = 0 + + newModel, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + updatedModel := newModel.(*topModel) + assert.NotNil(t, updatedModel) + assert.Equal(t, 0, updatedModel.highlightIndex) + assert.Equal(t, []int{}, updatedModel.highlightMatches) + }) + + t.Run("terminal resize updates width and height", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.width = 100 + model.height = 30 + + newModel, _ := model.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + updatedModel := newModel.(*topModel) + assert.Equal(t, 120, updatedModel.width) + assert.Equal(t, 40, updatedModel.height) + }) +} + +func TestViewportIntegration(t *testing.T) { + t.Run("viewport component is initialized in topModel", func(t *testing.T) { + model := newTestModel() + assert.Equal(t, 0, model.viewport.YOffset()) + }) + + t.Run("viewport receives updates when in logs mode", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.width = 80 + model.height = 24 + model.logLines = []string{"Line 1", "Line 2", "Line 3"} + model.viewport.SetContent(strings.Join(model.logLines, "\n")) + + newModel, cmd := model.Update(tickMsg(time.Now())) + updatedModel := newModel.(*topModel) + assert.NotNil(t, updatedModel) + assert.NotNil(t, cmd) + + _ = updatedModel.View() + viewOutput := model.viewport.View() + assert.Contains(t, viewOutput, "Line 1") + }) + + t.Run("viewport sizing responds to terminal resize", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + + newModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + updatedModel := newModel.(*topModel) + assert.Equal(t, 100, updatedModel.width) + assert.Equal(t, 40, updatedModel.height) + _ = updatedModel.View() + }) + + t.Run("viewport content is updated from log messages", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.width = 80 + model.height = 24 + + newModel, _ := model.Update(logMsg{lines: []string{"Log line 1", "Log line 2", "Log line 3"}}) + updatedModel := newModel.(*topModel) + assert.Equal(t, []string{"Log line 1", "Log line 2", "Log line 3"}, updatedModel.logLines) + assert.NoError(t, updatedModel.logErr) + assert.True(t, strings.Contains(updatedModel.viewport.View(), "Log line 1") || len(updatedModel.logLines) > 0) + }) + + t.Run("viewport handles empty log content gracefully", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.width = 80 + model.height = 24 + + newModel, _ := model.Update(logMsg{lines: []string{}, err: nil}) + updatedModel := newModel.(*topModel) + _ = updatedModel.View() + viewOutput := updatedModel.viewport.View() + assert.Contains(t, viewOutput, "(no logs yet)") + }) + + t.Run("viewport handles log errors gracefully", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.width = 80 + model.height = 24 + + newModel, _ := model.Update(logMsg{lines: nil, err: fmt.Errorf("test error")}) + updatedModel := newModel.(*topModel) + _ = updatedModel.View() + assert.Error(t, updatedModel.logErr) + viewOutput := updatedModel.viewport.View() + assert.Contains(t, viewOutput, "Error:") + }) +} + +func TestMouseModeEnabled(t *testing.T) { + t.Run("TopCmd enables mouse cell motion", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeLogs + model.logLines = []string{"Line 1", "Line 2", "Line 3"} + model.viewport.SetContent(strings.Join(model.logLines, "\n")) + + newModel, cmd := model.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 5, Y: 5}) + assert.NotNil(t, newModel) + assert.Nil(t, cmd) + }) + + t.Run("mouse messages in non-logs mode are ignored", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeTable + + newModel, cmd := model.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 5, Y: 5}) + assert.NotNil(t, newModel) + assert.Nil(t, cmd) + }) +} + +func findRunningRowClickY(model *topModel, needle string) int { + _ = model.View() + viewportLines := strings.Split(model.table.runningVP.View(), "\n") + for i, line := range viewportLines { + if strings.Contains(line, needle) { + return model.tableTopLines(model.width) + i - 1 + } + } + return -1 +} + +func findManagedRowClickY(model *topModel, needle string) int { + _ = model.View() + viewportLines := strings.Split(model.table.managedVP.View(), "\n") + for i, line := range viewportLines { + if strings.Contains(line, needle) { + return model.tableTopLines(model.width) + model.table.lastRunningHeight + i + } + } + return -1 +} + +func clickTableAt(model *topModel, y int) *topModel { + newModel, _ := model.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 10, Y: y}) + return newModel.(*topModel) +} + +func TestTableMouseClickSelection(t *testing.T) { + t.Run("click on running service row selects it", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeTable + model.servers = []*models.ServerInfo{ + {ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node server.js"}}, + {ProcessRecord: &models.ProcessRecord{PID: 1002, Port: 3001, Command: "go run ."}}, + {ProcessRecord: &models.ProcessRecord{PID: 1003, Port: 3002, Command: "python app.py"}}, + } + + model.viewport = viewport.New() + _ = model.View() + model.selected = 0 + model.focus = focusRunning + + viewportLines := strings.Split(model.table.runningVP.View(), "\n") + clickY := -1 + for i, line := range viewportLines { + if strings.Contains(line, "3001") { + clickY = model.tableTopLines(model.width) + i - 1 + break + } + } + assert.NotEqual(t, -1, clickY) + mouseMsg := tea.MouseClickMsg{Button: tea.MouseLeft, X: 10, Y: clickY} + newModel, cmd := model.Update(mouseMsg) + assert.NotNil(t, newModel) + assert.Nil(t, cmd) + + m := newModel.(*topModel) + assert.Equal(t, 1, m.selected) + assert.Equal(t, focusRunning, m.focus) + }) + + t.Run("click with viewport offset adjusts selection correctly", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeTable + model.servers = make([]*models.ServerInfo, 20) + for i := 0; i < 20; i++ { + model.servers[i] = &models.ServerInfo{ + ProcessRecord: &models.ProcessRecord{PID: 1000 + i, Port: 3000 + i, Command: fmt.Sprintf("node server%d.js", i)}, + } + } + + model.table.runningVP = viewport.New() + model.table.runningVP.SetWidth(80) + model.table.runningVP.SetHeight(10) + _ = model.View() + model.table.runningVP.SetYOffset(5) + + targetAbsoluteLine := 2 + 5 + clickY := model.tableTopLines(model.width) + (targetAbsoluteLine - model.table.runningVP.YOffset()) - 1 + newModel, _ := model.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 10, Y: clickY}) + m := newModel.(*topModel) + assert.Equal(t, 5, m.selected) + }) + + t.Run("click on managed service row selects it and activates managed focus", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeTable + model.width = 100 + model.height = 20 + model.focus = focusRunning + model.selected = 0 + model.managedSel = 0 + model.app = &fakeAppDeps{ + servers: []*models.ServerInfo{ + { + ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node server.js", CWD: "/tmp/app", ProjectRoot: "/tmp/app"}, + Status: "running", + }, + }, + services: []*models.ManagedService{ + {Name: "alpha", CWD: "/tmp/alpha", Command: "npm run dev", Ports: []int{4100}}, + {Name: "beta", CWD: "/tmp/beta", Command: "npm run dev", Ports: []int{4200}}, + {Name: "gamma", CWD: "/tmp/gamma", Command: "npm run dev", Ports: []int{4300}}, + }, + } + model.servers = []*models.ServerInfo{ + { + ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node server.js", CWD: "/tmp/app", ProjectRoot: "/tmp/app"}, + Status: "running", + }, + } + + _ = model.View() + viewportLines := strings.Split(model.table.managedVP.View(), "\n") + clickY := -1 + for i, line := range viewportLines { + if strings.Contains(line, "beta [stopped]") { + clickY = model.tableTopLines(model.width) + model.table.lastRunningHeight + i + break + } + } + assert.NotEqual(t, -1, clickY) + + newModel, cmd := model.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 10, Y: clickY}) + assert.Nil(t, cmd) + + m := newModel.(*topModel) + assert.Equal(t, focusManaged, m.focus) + assert.Equal(t, 1, m.managedSel) + }) + + t.Run("red-green running rows map to clicked visible server", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeTable + model.servers = []*models.ServerInfo{ + {ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node server.js"}}, + {ProcessRecord: &models.ProcessRecord{PID: 1002, Port: 3001, Command: "go run ."}}, + {ProcessRecord: &models.ProcessRecord{PID: 1003, Port: 3002, Command: "python app.py"}}, + } + + cases := []struct { + needle string + wantPort int + }{ + {needle: "3000", wantPort: 3000}, + {needle: "3001", wantPort: 3001}, + {needle: "3002", wantPort: 3002}, + } + + for _, tc := range cases { + t.Run(tc.needle, func(t *testing.T) { + y := findRunningRowClickY(model, tc.needle) + assert.NotEqual(t, -1, y) + m := clickTableAt(model, y) + assert.Equal(t, focusRunning, m.focus) + visible := m.visibleServers() + if assert.Greater(t, len(visible), m.selected) { + assert.Equal(t, tc.wantPort, visible[m.selected].ProcessRecord.Port) + } + }) + } + }) + + t.Run("red-green managed rows map to exact selected index", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeTable + model.width = 100 + model.height = 20 + model.focus = focusRunning + model.selected = 0 + model.managedSel = 0 + model.app = &fakeAppDeps{ + servers: []*models.ServerInfo{{ + ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node server.js", CWD: "/tmp/app", ProjectRoot: "/tmp/app"}, + Status: "running", + }}, + services: []*models.ManagedService{ + {Name: "alpha", CWD: "/tmp/alpha", Command: "npm run dev", Ports: []int{4100}}, + {Name: "beta", CWD: "/tmp/beta", Command: "npm run dev", Ports: []int{4200}}, + {Name: "gamma", CWD: "/tmp/gamma", Command: "npm run dev", Ports: []int{4300}}, + }, + } + model.servers = []*models.ServerInfo{{ + ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node server.js", CWD: "/tmp/app", ProjectRoot: "/tmp/app"}, + Status: "running", + }} + + cases := []struct { + needle string + want int + }{ + {needle: "alpha [stopped]", want: 0}, + {needle: "beta [stopped]", want: 1}, + {needle: "gamma [stopped]", want: 2}, + } + + for _, tc := range cases { + t.Run(tc.needle, func(t *testing.T) { + y := findManagedRowClickY(model, tc.needle) + assert.NotEqual(t, -1, y) + m := clickTableAt(model, y) + assert.Equal(t, focusManaged, m.focus) + assert.Equal(t, tc.want, m.managedSel) + }) + } + }) + + t.Run("wheel events are passed to viewport for scrolling", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeTable + model.width = 80 + model.height = 12 + model.focus = focusManaged + model.app = &fakeAppDeps{ + servers: []*models.ServerInfo{ + { + ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node server.js", CWD: "/tmp/app", ProjectRoot: "/tmp/app"}, + Status: "running", + }, + }, + } + model.servers = []*models.ServerInfo{ + { + ProcessRecord: &models.ProcessRecord{PID: 1001, Port: 3000, Command: "node server.js", CWD: "/tmp/app", ProjectRoot: "/tmp/app"}, + Status: "running", + }, + } + fakeDeps := model.app.(*fakeAppDeps) + for i := 0; i < 30; i++ { + fakeDeps.services = append(fakeDeps.services, &models.ManagedService{ + Name: fmt.Sprintf("svc-%02d", i), + CWD: fmt.Sprintf("/tmp/svc-%02d", i), + Command: "npm run dev", + Ports: []int{4000 + i}, + }) + } + + _ = model.View() + initialManagedOffset := model.table.managedVP.YOffset() + runningOffset := model.table.runningVP.YOffset() + mouseY := 2 + model.table.lastRunningHeight + 2 + + newModel, cmd := model.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown, X: 10, Y: mouseY}) + assert.NotNil(t, newModel) + assert.Nil(t, cmd) + + updatedModel := newModel.(*topModel) + assert.False(t, updatedModel.tableFollowSelection) + + _ = updatedModel.View() + assert.Greater(t, updatedModel.table.managedVP.YOffset(), initialManagedOffset) + assert.Equal(t, runningOffset, updatedModel.table.runningVP.YOffset()) + }) + + t.Run("wheel scrolling in top grid only moves running section", func(t *testing.T) { + model := newTestModel() + model.mode = viewModeTable + model.width = 80 + model.height = 12 + model.focus = focusRunning + model.selected = 0 + model.servers = make([]*models.ServerInfo, 30) + for i := 0; i < 30; i++ { + model.servers[i] = &models.ServerInfo{ + ProcessRecord: &models.ProcessRecord{ + PID: 1001 + i, + Port: 3000 + i, + Command: fmt.Sprintf("node server-%d.js", i), + }, + } + } + model.app = &fakeAppDeps{ + servers: model.servers, + services: []*models.ManagedService{ + {Name: "alpha", CWD: "/tmp/alpha", Command: "npm run dev", Ports: []int{4100}}, + {Name: "beta", CWD: "/tmp/beta", Command: "npm run dev", Ports: []int{4200}}, + }, + } + + _ = model.View() + initialRunningOffset := model.table.runningVP.YOffset() + managedOffset := model.table.managedVP.YOffset() + mouseY := 4 + + newModel, cmd := model.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown, X: 10, Y: mouseY}) + assert.NotNil(t, newModel) + assert.Nil(t, cmd) + + updatedModel := newModel.(*topModel) + assert.False(t, updatedModel.tableFollowSelection) + + _ = updatedModel.View() + assert.Greater(t, updatedModel.table.runningVP.YOffset(), initialRunningOffset) + assert.Equal(t, managedOffset, updatedModel.table.managedVP.YOffset()) + }) +} diff --git a/pkg/cli/tui/update.go b/pkg/cli/tui/update.go new file mode 100644 index 0000000..68bd626 --- /dev/null +++ b/pkg/cli/tui/update.go @@ -0,0 +1,378 @@ +package tui + +import ( + "errors" + "fmt" + "strings" + "time" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + + "github.com/devports/devpt/pkg/process" +) + +func (m *topModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + m.lastInput = time.Now() + + if m.mode == viewModeCommand { + switch msg.String() { + case "esc": + m.mode = viewModeTable + m.cmdInput = "" + return m, nil + case "enter": + m.cmdStatus = m.runCommand(strings.TrimSpace(m.cmdInput)) + m.cmdInput = "" + m.mode = viewModeTable + m.refresh() + return m, nil + case "backspace": + if len(m.cmdInput) > 0 { + m.cmdInput = m.cmdInput[:len(m.cmdInput)-1] + } + return m, nil + } + for _, r := range []rune(msg.Text) { + if r >= 32 && r != 127 { + m.cmdInput += string(r) + } + } + return m, nil + } + + if m.mode == viewModeSearch { + switch msg.String() { + case "esc": + m.searchInput.SetValue(m.searchQuery) + m.searchInput.Blur() + m.mode = viewModeTable + return m, nil + case "enter": + m.searchQuery = m.searchInput.Value() + m.searchInput.Blur() + m.mode = viewModeTable + return m, nil + } + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + return m, cmd + } + + if m.mode == viewModeLogs { + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case key.Matches(msg, m.keys.Back): + m.clearLogsView() + return m, nil + case key.Matches(msg, m.keys.Follow): + m.followLogs = !m.followLogs + return m, nil + case key.Matches(msg, m.keys.NextMatch): + if len(m.highlightMatches) > 0 { + m.highlightIndex = (m.highlightIndex + 1) % len(m.highlightMatches) + } + return m, nil + case key.Matches(msg, m.keys.PrevMatch): + if len(m.highlightMatches) > 0 { + m.highlightIndex = (m.highlightIndex - 1 + len(m.highlightMatches)) % len(m.highlightMatches) + } + return m, nil + default: + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + } + + if m.mode == viewModeLogsDebug { + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case key.Matches(msg, m.keys.Back): + m.mode = viewModeTable + return m, nil + default: + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + } + + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case m.modal != nil && key.Matches(msg, m.keys.Help): + m.closeModal() + return m, nil + case key.Matches(msg, m.keys.Tab): + if m.focus == focusRunning { + m.focus = focusManaged + m.tableFollowSelection = true + managed := m.managedServices() + if m.managedSel < 0 && len(managed) > 0 { + m.managedSel = 0 + } + } else { + m.focus = focusRunning + m.tableFollowSelection = true + visible := m.visibleServers() + if m.selected < 0 && len(visible) > 0 { + m.selected = 0 + } + } + return m, nil + case key.Matches(msg, m.keys.Help): + m.openHelpModal() + return m, nil + case key.Matches(msg, m.keys.Search): + m.searchInput.SetValue(m.searchQuery) + m.searchInput.CursorEnd() + m.mode = viewModeSearch + return m, m.searchInput.Focus() + case key.Matches(msg, m.keys.ClearFilter): + m.searchQuery = "" + m.searchInput.SetValue("") + m.cmdStatus = "Filter cleared" + return m, nil + case key.Matches(msg, m.keys.Sort): + // Cycle to next sort mode, reset reverse + m.sortBy = (m.sortBy + 1) % sortModeCount + m.sortReverse = false + return m, nil + case key.Matches(msg, m.keys.SortReverse): + m.toggleSortDirection() + return m, nil + case key.Matches(msg, m.keys.Health): + m.showHealthDetail = !m.showHealthDetail + return m, nil + case key.Matches(msg, m.keys.Debug): + m.mode = viewModeLogsDebug + m.initDebugViewport() + return m, nil + case key.Matches(msg, m.keys.Add): + m.mode = viewModeCommand + m.cmdInput = "add " + return m, nil + case key.Matches(msg, m.keys.Restart): + m.cmdStatus = m.restartSelected() + m.refresh() + return m, nil + case key.Matches(msg, m.keys.Stop): + m.prepareStopConfirm() + return m, nil + case key.Matches(msg, m.keys.Remove): + if m.focus == focusManaged { + managed := m.managedServices() + if m.managedSel >= 0 && m.managedSel < len(managed) { + name := managed[m.managedSel].Name + m.openConfirmModal(&confirmState{ + kind: confirmRemoveService, + prompt: fmt.Sprintf("Remove %q from registry?", name), + name: name, + }) + } else { + m.cmdStatus = "No managed service selected" + } + } + return m, nil + case msg.String() == ":" || msg.String() == "shift+;" || msg.String() == ";" || msg.String() == "c": + m.mode = viewModeCommand + m.cmdInput = "" + return m, nil + case msg.String() == "esc": + if m.modal != nil { + m.closeModal() + return m, nil + } + switch m.mode { + case viewModeTable: + return m, tea.Quit + case viewModeLogs: + m.clearLogsView() + } + return m, nil + case msg.String() == "b": + if m.mode == viewModeLogs { + m.clearLogsView() + } + return m, nil + case msg.String() == "backspace": + return m, nil + case key.Matches(msg, m.keys.Up): + if m.focus == focusRunning && m.selected > 0 { + m.selected-- + m.tableFollowSelection = true + } + if m.focus == focusManaged && m.managedSel > 0 { + m.managedSel-- + m.tableFollowSelection = true + } + return m, nil + case key.Matches(msg, m.keys.Down): + if m.focus == focusRunning { + if m.selected < len(m.visibleServers())-1 { + m.selected++ + m.tableFollowSelection = true + } + } + if m.focus == focusManaged { + if m.managedSel < len(m.managedServices())-1 { + m.managedSel++ + m.tableFollowSelection = true + } + } + return m, nil + case key.Matches(msg, m.keys.Enter): + switch m.mode { + case viewModeTable: + if m.activeModalKind() == modalConfirm { + cmd := m.executeConfirm(true) + return m, cmd + } + return m.handleEnterKey() + } + return m, nil + case key.Matches(msg, m.keys.Confirm): + if m.activeModalKind() == modalConfirm { + cmd := m.executeConfirm(true) + return m, cmd + } + return m, nil + case key.Matches(msg, m.keys.Cancel): + if m.activeModalKind() == modalConfirm { + cmd := m.executeConfirm(false) + return m, cmd + } + if m.mode == viewModeLogs && len(m.highlightMatches) > 0 { + m.highlightIndex = (m.highlightIndex + 1) % len(m.highlightMatches) + } + return m, nil + case msg.String() == "pgup" || msg.String() == "pgdown" || msg.String() == "home" || msg.String() == "end": + m.tableFollowSelection = false + cmd := m.table.updateFocusedViewport(m.focus, msg) + return m, cmd + default: + return m, nil + } + case tea.MouseMsg: + mouse := msg.Mouse() + if m.modal != nil { + if _, ok := msg.(tea.MouseClickMsg); ok && mouse.Button == tea.MouseLeft { + bounds := m.activeModalBounds(m.width, m.baseViewContent(m.width)) + if !bounds.contains(mouse.X, mouse.Y) { + if m.activeModalKind() == modalConfirm { + cmd := m.executeConfirm(false) + return m, cmd + } + m.closeModal() + return m, nil + } + return m, nil + } + return m, nil + } + if m.mode == viewModeTable { + if _, ok := msg.(tea.MouseClickMsg); ok && mouse.Button == tea.MouseLeft { + return m.handleTableMouseClick(msg) + } + m.tableFollowSelection = false + viewportY := mouse.Y - m.tableTopLines(m.width) + 1 + cmd := m.table.updateViewportForTableY(viewportY, msg) + return m, cmd + } + if m.mode == viewModeLogs { + if _, ok := msg.(tea.MouseClickMsg); ok { + return m.handleMouseClick(msg) + } + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + if m.mode == viewModeLogsDebug { + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + return m, nil + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.help.SetWidth(msg.Width) + case tickMsg: + m.refresh() + if m.mode == viewModeLogs && m.followLogs { + return m, m.tailLogsCmd() + } + if m.mode == viewModeTable && !m.healthBusy && time.Since(m.healthLast) > 2*time.Second && time.Since(m.lastInput) > 900*time.Millisecond { + m.healthBusy = true + return m, m.healthCmd() + } + return m, tickCmd() + case logMsg: + oldYOffset := m.viewport.YOffset() + totalLines := m.viewport.TotalLineCount() + visibleLines := m.viewport.VisibleLineCount() + wasAtBottom := (oldYOffset+visibleLines >= totalLines) || totalLines == 0 + + m.logLines = msg.lines + m.logErr = msg.err + if m.logErr != nil { + var content string + if errors.Is(m.logErr, process.ErrNoLogs) { + content = "No devpt logs for this service yet.\nLogs are only captured when started by devpt.\n" + } else if errors.Is(m.logErr, process.ErrNoProcessLogs) { + content = "No accessible logs for this process.\nIf it writes only to a terminal, there may be nothing to tail here.\n" + } else { + content = fmt.Sprintf("Error: %v\n", m.logErr) + } + m.viewport.SetContent(content) + m.viewport.GotoTop() + } else if len(m.logLines) == 0 { + m.viewport.SetContent("(no logs yet)\n") + m.viewport.GotoTop() + } else { + content := strings.Join(m.logLines, "\n") + m.viewport.SetContent(content) + if m.followLogs || wasAtBottom { + newTotalLines := m.viewport.TotalLineCount() + newVisibleLines := m.viewport.VisibleLineCount() + if newTotalLines > newVisibleLines { + m.viewport.SetYOffset(newTotalLines - newVisibleLines) + } + } else { + m.viewport.SetYOffset(oldYOffset) + } + } + return m, tickCmd() + case healthMsg: + m.healthBusy = false + if msg.err == nil { + m.health = msg.icons + m.healthDetails = msg.details + m.healthLast = time.Now() + } + return m, tickCmd() + } + + if m.mode == viewModeLogs || m.mode == viewModeLogsDebug { + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + if cmd != nil { + return m, cmd + } + } + + return m, nil +} + +func (m *topModel) clearLogsView() { + m.mode = viewModeTable + m.logLines = nil + m.logErr = nil + m.logSvc = nil + m.logPID = 0 +} diff --git a/pkg/cli/tui/view.go b/pkg/cli/tui/view.go new file mode 100644 index 0000000..d4ded60 --- /dev/null +++ b/pkg/cli/tui/view.go @@ -0,0 +1,214 @@ +package tui + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/devports/devpt/pkg/buildinfo" +) + +func (m *topModel) View() tea.View { + if m.err != nil { + return tea.NewView(fmt.Sprintf("Error: %v\nPress 'q' to quit\n", m.err)) + } + + width := m.width + if width <= 0 { + width = 120 + } + if m.height <= 0 { + m.height = 24 + } + + content := m.baseViewContent(width) + if m.modal != nil { + content = overlayModal(content, m.activeModalOverlay(width), width) + } + + v := tea.NewView(content) + v.AltScreen = true + v.MouseMode = tea.MouseModeCellMotion + return v +} + +func (m *topModel) baseViewContent(width int) string { + var b strings.Builder + headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true) + versionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + switch m.mode { + case viewModeLogs: + b.WriteString(headerStyle.Render(m.logsHeaderView())) + b.WriteString("\n") + case viewModeLogsDebug: + b.WriteString(headerStyle.Render("Viewport Debug Mode (b back, q quit)")) + b.WriteString("\n") + default: + b.WriteString(headerStyle.Render("Dev Process Tracker - Health Monitor")) + b.WriteString(" ") + b.WriteString(versionStyle.Render(buildinfo.Version)) + } + + switch m.mode { + case viewModeLogs: + b.WriteString(m.renderLogs(width)) + b.WriteString("\n") + case viewModeLogsDebug: + b.WriteString(m.renderLogsDebug(width)) + b.WriteString("\n") + case viewModeTable, viewModeSearch: + b.WriteString("\n") + b.WriteString(m.table.Render(m, width)) + b.WriteString("\n") + } + + if m.mode == viewModeCommand { + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render(fitLine(":"+m.cmdInput, width))) + b.WriteString("\n") + hint := `Example: add my-app ~/projects/my-app "npm run dev" 3000` + if strings.HasPrefix(strings.TrimSpace(m.cmdInput), "add") { + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(fitLine(hint, width))) + b.WriteString("\n") + } + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(fitLine("Esc to go back", width))) + b.WriteString("\n") + } + if m.mode == viewModeTable || m.mode == viewModeSearch { + if sl := m.renderStatusLine(width); sl != "" { + b.WriteString(sl) + b.WriteString("\n") + } + b.WriteString(m.renderFooter(width)) + b.WriteString("\n") + } else { + var footer string + var statusLine string + + if m.cmdStatus != "" { + statusLine = m.cmdStatus + } + + if m.mode == viewModeLogs && len(m.highlightMatches) > 0 { + matchCounter := fmt.Sprintf("Match %d/%d", m.highlightIndex+1, len(m.highlightMatches)) + footer = fmt.Sprintf("%s | b back | f follow:%t | n/N next/prev highlight", matchCounter, m.followLogs) + } else if m.mode == viewModeLogs { + footer = fmt.Sprintf("b back | f follow:%t | ↑↓ scroll | Page Up/Down", m.followLogs) + } else if m.mode == viewModeLogsDebug { + footer = "b back | q quit | ↑↓ scroll | Page Up/Down" + } else { + footer = fmt.Sprintf("Last updated: %s | Services: %d | Tab switch | Enter logs/start | x remove managed | / filter | ^L clear filter | s sort | ? help | ^A add ^R restart ^E stop | D debug", m.lastUpdate.Format("15:04:05"), m.countVisible()) + } + footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Italic(true) + + if statusLine != "" { + statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("208")) + b.WriteString(statusStyle.Render(fitLine(statusLine, width))) + b.WriteString("\n") + } + + b.WriteString(footerStyle.Render(fitLine(footer, width))) + b.WriteString("\n") + } + + return b.String() +} + +func (m *topModel) renderLogs(width int) string { + headerLines := renderedLineCount(m.logsHeaderView()) + footerLines := renderedLineCount(m.logsFooterView()) + availableHeight := m.height - headerLines - footerLines + if availableHeight < 5 { + availableHeight = 5 + } + + m.viewport.SetWidth(width) + m.viewport.SetHeight(availableHeight) + + if m.viewportNeedsTop { + m.viewport.GotoTop() + m.viewportNeedsTop = false + } + + return m.viewport.View() +} + +func (m *topModel) initDebugViewport() { + var lines []string + for i := 1; i <= 100; i++ { + lines = append(lines, fmt.Sprintf("Debug Line %d: This is test content for viewport scrolling. Use arrow keys, page up/down, or mouse wheel to scroll. Press 'b' to exit debug mode.", i)) + } + content := strings.Join(lines, "\n") + m.viewport.SetContent(content) + m.viewport.GotoTop() +} + +func (m *topModel) renderLogsDebug(width int) string { + headerHeight := renderedLineCount("Viewport Debug Mode (b back, q quit)") + footerHeight := renderedLineCount("b back | q quit | ↑↓ scroll | Page Up/Down") + m.viewport.SetWidth(width) + height := m.height - headerHeight - footerHeight + if height < 5 { + height = 5 + } + m.viewport.SetHeight(height) + return m.viewport.View() +} + +func (m *topModel) logsHeaderView() string { + name := "-" + port := "-" + pid := "-" + if m.logSvc != nil { + name = m.logSvc.Name + for _, srv := range m.servers { + if srv.ManagedService != nil && srv.ManagedService.Name == m.logSvc.Name && srv.ProcessRecord != nil { + if srv.ProcessRecord.Port > 0 { + port = fmt.Sprintf("%d", srv.ProcessRecord.Port) + } + if srv.ProcessRecord.PID > 0 { + pid = fmt.Sprintf("%d", srv.ProcessRecord.PID) + } + break + } + } + if port == "-" && len(m.logSvc.Ports) > 0 && m.logSvc.Ports[0] > 0 { + port = fmt.Sprintf("%d", m.logSvc.Ports[0]) + } + } else if m.logPID > 0 { + pid = fmt.Sprintf("%d", m.logPID) + for _, srv := range m.servers { + if srv.ProcessRecord != nil && srv.ProcessRecord.PID == m.logPID { + if srv.ProcessRecord.Port > 0 { + port = fmt.Sprintf("%d", srv.ProcessRecord.Port) + } + if srv.ManagedService != nil && srv.ManagedService.Name != "" { + name = srv.ManagedService.Name + } + break + } + } + if name == "-" { + name = fmt.Sprintf("pid:%d", m.logPID) + } + } + return fmt.Sprintf("Logs: %s | Port: %s | PID: %s", name, port, pid) +} + +func (m *topModel) logsFooterView() string { + if len(m.highlightMatches) > 0 { + matchCounter := fmt.Sprintf("Match %d/%d", m.highlightIndex+1, len(m.highlightMatches)) + return fmt.Sprintf("%s | b back | f follow:%t | n/N next/prev highlight", matchCounter, m.followLogs) + } + return fmt.Sprintf("b back | f follow:%t | ↑↓ scroll | Page Up/Down", m.followLogs) +} + +func renderedLineCount(s string) int { + if s == "" { + return 0 + } + return 1 + strings.Count(s, "\n") +} diff --git a/pkg/cli/tui_adapter.go b/pkg/cli/tui_adapter.go new file mode 100644 index 0000000..56d3a12 --- /dev/null +++ b/pkg/cli/tui_adapter.go @@ -0,0 +1,69 @@ +package cli + +import ( + "io" + "time" + + tuipkg "github.com/devports/devpt/pkg/cli/tui" + "github.com/devports/devpt/pkg/models" +) + +type tuiAdapter struct { + app *App +} + +func NewTUIAdapter(app *App) tuipkg.AppDeps { + return tuiAdapter{app: app.withOutput(io.Discard, io.Discard)} +} + +func (a tuiAdapter) DiscoverServers() ([]*models.ServerInfo, error) { + return a.app.discoverServers() +} + +func (a tuiAdapter) ListServices() []*models.ManagedService { + return a.app.registry.ListServices() +} + +func (a tuiAdapter) GetService(name string) *models.ManagedService { + return a.app.registry.GetService(name) +} + +func (a tuiAdapter) ClearServicePID(name string) error { + return a.app.registry.ClearServicePID(name) +} + +func (a tuiAdapter) AddCmd(name, cwd, command string, ports []int) error { + return a.app.AddCmd(name, cwd, command, ports) +} + +func (a tuiAdapter) RemoveCmd(name string) error { + return a.app.RemoveCmd(name) +} + +func (a tuiAdapter) StartCmd(name string) error { + return a.app.StartCmd(name) +} + +func (a tuiAdapter) StopCmd(identifier string) error { + return a.app.StopCmd(identifier) +} + +func (a tuiAdapter) RestartCmd(name string) error { + return a.app.RestartCmd(name) +} + +func (a tuiAdapter) StopProcess(pid int, timeout time.Duration) error { + return a.app.processManager.Stop(pid, timeout) +} + +func (a tuiAdapter) TailServiceLogs(name string, lines int) ([]string, error) { + return a.app.processManager.Tail(name, lines) +} + +func (a tuiAdapter) TailProcessLogs(pid int, lines int) ([]string, error) { + return a.app.processManager.TailProcess(pid, lines) +} + +func (a tuiAdapter) LatestServiceLogPath(name string) (string, error) { + return a.app.processManager.LatestLogPath(name) +} diff --git a/pkg/cli/tui_adapter_test.go b/pkg/cli/tui_adapter_test.go new file mode 100644 index 0000000..f3916ec --- /dev/null +++ b/pkg/cli/tui_adapter_test.go @@ -0,0 +1,182 @@ +package cli + +import ( + "bytes" + "fmt" + "net" + "path/filepath" + "testing" + "time" + + "github.com/devports/devpt/pkg/models" + "github.com/devports/devpt/pkg/process" + "github.com/devports/devpt/pkg/registry" + "github.com/devports/devpt/pkg/scanner" +) + +func TestTUIAdapterLatestServiceLogPath_ReturnsManagedLogFile(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + reg := registry.NewRegistry(filepath.Join(tmp, "registry.json")) + if err := reg.Load(); err != nil { + t.Fatalf("load registry: %v", err) + } + + now := time.Now() + port := reserveTestPort(t) + if err := reg.AddService(&models.ManagedService{ + Name: "worker", + CWD: tmp, + Command: fmt.Sprintf("/usr/bin/python3 -m http.server %d --bind 127.0.0.1", port), + Ports: []int{port}, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("add service: %v", err) + } + + app := &App{ + registry: reg, + scanner: scanner.NewProcessScanner(), + resolver: scanner.NewProjectResolver(), + detector: scanner.NewAgentDetector(), + processManager: process.NewManager(filepath.Join(tmp, "logs")), + } + + if err := app.StartCmd("worker"); err != nil { + t.Fatalf("start service: %v", err) + } + waitForTCPListener(t, port) + + adapter, ok := NewTUIAdapter(app).(tuiAdapter) + if !ok { + t.Fatalf("expected tuiAdapter type") + } + + logPath, err := adapter.LatestServiceLogPath("worker") + if err != nil { + t.Fatalf("latest log path: %v", err) + } + if logPath == "" { + t.Fatalf("expected non-empty log path") + } + + svc := reg.GetService("worker") + if svc == nil || svc.LastPID == nil || *svc.LastPID <= 0 { + t.Fatalf("expected started service PID, got %#v", svc) + } + if err := app.processManager.Stop(*svc.LastPID, 2*time.Second); err != nil && err != process.ErrNeedSudo { + t.Fatalf("cleanup stop: %v", err) + } +} + +func TestTUIAdapterRestartCmd_SuppressesCLIProgressOutput(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + reg := registry.NewRegistry(filepath.Join(tmp, "registry.json")) + if err := reg.Load(); err != nil { + t.Fatalf("load registry: %v", err) + } + + now := time.Now() + port := reserveTestPort(t) + if err := reg.AddService(&models.ManagedService{ + Name: "worker", + CWD: tmp, + Command: fmt.Sprintf("/usr/bin/python3 -m http.server %d --bind 127.0.0.1", port), + Ports: []int{port}, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("add service: %v", err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + app := &App{ + registry: reg, + scanner: scanner.NewProcessScanner(), + resolver: scanner.NewProjectResolver(), + detector: scanner.NewAgentDetector(), + processManager: process.NewManager(filepath.Join(tmp, "logs")), + stdout: &stdout, + stderr: &stderr, + } + + if err := app.StartCmd("worker"); err != nil { + t.Fatalf("start service: %v", err) + } + waitForTCPListener(t, port) + + svc := reg.GetService("worker") + if svc == nil || svc.LastPID == nil || *svc.LastPID <= 0 { + t.Fatalf("expected started service PID, got %#v", svc) + } + startPID := *svc.LastPID + + stdout.Reset() + stderr.Reset() + + adapter, ok := NewTUIAdapter(app).(tuiAdapter) + if !ok { + t.Fatalf("expected tuiAdapter type") + } + if err := adapter.RestartCmd("worker"); err != nil { + t.Fatalf("restart via TUI adapter: %v", err) + } + + if stdout.Len() != 0 { + t.Fatalf("expected no stdout leakage during TUI restart, got: %q", stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("expected no stderr leakage during TUI restart, got: %q", stderr.String()) + } + + svc = reg.GetService("worker") + if svc == nil || svc.LastPID == nil || *svc.LastPID <= 0 { + t.Fatalf("expected restarted service PID, got %#v", svc) + } + if *svc.LastPID == startPID { + t.Fatalf("expected restart to update PID, still %d", *svc.LastPID) + } + + // Best-effort cleanup; ignore ErrNeedSudo on CI/protected environments + if err := app.processManager.Stop(*svc.LastPID, 2*time.Second); err != nil && err != process.ErrNeedSudo { + t.Fatalf("cleanup stop: %v", err) + } +} + +func reserveTestPort(t *testing.T) int { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve port: %v", err) + } + defer ln.Close() + + addr, ok := ln.Addr().(*net.TCPAddr) + if !ok { + t.Fatalf("unexpected listener address type: %T", ln.Addr()) + } + return addr.Port +} + +func waitForTCPListener(t *testing.T, port int) { + t.Helper() + + deadline := time.Now().Add(3 * time.Second) + address := fmt.Sprintf("127.0.0.1:%d", port) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", address, 100*time.Millisecond) + if err == nil { + _ = conn.Close() + return + } + time.Sleep(50 * time.Millisecond) + } + + t.Fatalf("listener on %s did not become ready", address) +} diff --git a/pkg/cli/tui_key_input_test.go b/pkg/cli/tui_key_input_test.go deleted file mode 100644 index 489fd4b..0000000 --- a/pkg/cli/tui_key_input_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package cli - -import ( - "testing" - - tea "github.com/charmbracelet/bubbletea" -) - -func TestCommandModeAcceptsRuneKeys(t *testing.T) { - t.Parallel() - - for _, key := range []string{"b", "q", "s", "n"} { - m := topModel{ - mode: viewModeCommand, - } - - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) - updated, ok := next.(topModel) - if !ok { - t.Fatalf("expected topModel, got %T", next) - } - if updated.cmdInput != key { - t.Fatalf("expected command input to include rune key %q, got %q", key, updated.cmdInput) - } - } -} - -func TestSearchModeAcceptsRuneKeys(t *testing.T) { - t.Parallel() - - m := topModel{ - mode: viewModeSearch, - } - - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("s")}) - updated, ok := next.(topModel) - if !ok { - t.Fatalf("expected topModel, got %T", next) - } - if updated.searchQuery != "s" { - t.Fatalf("expected search query to include rune key, got %q", updated.searchQuery) - } -} diff --git a/pkg/process/manager.go b/pkg/process/manager.go index 599f8c0..ecb74f2 100644 --- a/pkg/process/manager.go +++ b/pkg/process/manager.go @@ -10,7 +10,6 @@ import ( "sort" "strconv" "strings" - "syscall" "time" "github.com/devports/devpt/pkg/models" @@ -60,10 +59,8 @@ func (m *Manager) Start(service *models.ManagedService) (int, error) { cmd := exec.Command(argv[0], argv[1:]...) cmd.Dir = service.CWD - // Set up process group to manage all child processes - cmd.SysProcAttr = &syscall.SysProcAttr{ - Setpgid: true, - } + // Set up process group to manage all child processes (platform-specific) + setProcessGroup(cmd) // Redirect output to log file cmd.Stdout = logFile @@ -88,34 +85,33 @@ func (m *Manager) Stop(pid int, timeout time.Duration) error { // First attempt graceful termination. For non-child processes we cannot use Wait(), // so we send signals and poll for liveness. - if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil { - if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { - return fmt.Errorf("failed to send SIGTERM: %w", err) + if err := terminateProcess(pid); err != nil { + if err := terminateProcessFallback(pid); err != nil { + return fmt.Errorf("failed to send termination signal: %w", err) } } deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { - if !m.isAlive(pid) { + if !isProcessAlive(pid) { return nil } time.Sleep(120 * time.Millisecond) } // Escalate to hard kill. - if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil { - _ = syscall.Kill(pid, syscall.SIGKILL) + if err := killProcess(pid); err != nil { + _ = killProcessFallback(pid) } time.Sleep(200 * time.Millisecond) - if m.isAlive(pid) { + if isProcessAlive(pid) { return ErrNeedSudo } return nil } func (m *Manager) isAlive(pid int) bool { - err := syscall.Kill(pid, syscall.Signal(0)) - if err != nil { + if !isProcessAlive(pid) { return false } if st, stateErr := m.processState(pid); stateErr == nil { diff --git a/pkg/process/proc_unix.go b/pkg/process/proc_unix.go new file mode 100644 index 0000000..b7b46ed --- /dev/null +++ b/pkg/process/proc_unix.go @@ -0,0 +1,34 @@ +//go:build !windows + +package process + +import ( + "os/exec" + "syscall" +) + +func setProcessGroup(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } +} + +func terminateProcess(pid int) error { + return syscall.Kill(-pid, syscall.SIGTERM) +} + +func terminateProcessFallback(pid int) error { + return syscall.Kill(pid, syscall.SIGTERM) +} + +func killProcess(pid int) error { + return syscall.Kill(-pid, syscall.SIGKILL) +} + +func killProcessFallback(pid int) error { + return syscall.Kill(pid, syscall.SIGKILL) +} + +func isProcessAlive(pid int) bool { + return syscall.Kill(pid, syscall.Signal(0)) == nil +} diff --git a/pkg/process/proc_windows.go b/pkg/process/proc_windows.go new file mode 100644 index 0000000..1d88398 --- /dev/null +++ b/pkg/process/proc_windows.go @@ -0,0 +1,37 @@ +//go:build windows + +package process + +import ( + "os/exec" + "strconv" +) + +func setProcessGroup(cmd *exec.Cmd) { + // Windows: no special process group setup needed for basic use + // The process will be managed by its PID +} + +func terminateProcess(pid int) error { + return terminateProcessFallback(pid) +} + +func terminateProcessFallback(pid int) error { + // On Windows, use taskkill for graceful termination + return exec.Command("taskkill", "/PID", strconv.Itoa(pid)).Run() +} + +func killProcess(pid int) error { + return killProcessFallback(pid) +} + +func killProcessFallback(pid int) error { + // On Windows, use taskkill /F for forceful termination + return exec.Command("taskkill", "/F", "/PID", strconv.Itoa(pid)).Run() +} + +func isProcessAlive(pid int) bool { + // Check if process exists using tasklist + err := exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid)).Run() + return err == nil +} diff --git a/pkg/scanner/filter.go b/pkg/scanner/filter.go index b32ce96..20183c7 100644 --- a/pkg/scanner/filter.go +++ b/pkg/scanner/filter.go @@ -67,6 +67,7 @@ func IsDevProcess(record *models.ProcessRecord, commandInfo string) bool { "pytest", "jest", "vitest", + "cloudflared", // Cloudflare tunnel for dev exposure } for _, pattern := range devPatterns { @@ -78,8 +79,9 @@ func IsDevProcess(record *models.ProcessRecord, commandInfo string) bool { return false } -// FilterDevProcesses keeps only development-related processes -func FilterDevProcesses(records []*models.ProcessRecord, commandMap map[int]string) []*models.ProcessRecord { +// FilterDevProcesses keeps only development-related processes. +// Processes with PIDs in managedPIDs are always kept (they belong to managed services). +func FilterDevProcesses(records []*models.ProcessRecord, commandMap map[int]string, managedPIDs map[int]bool) []*models.ProcessRecord { filtered := make([]*models.ProcessRecord, 0) for _, record := range records { @@ -87,6 +89,12 @@ func FilterDevProcesses(records []*models.ProcessRecord, commandMap map[int]stri continue } + // Always keep processes that belong to managed services + if managedPIDs[record.PID] { + filtered = append(filtered, record) + continue + } + cmd := commandMap[record.PID] if IsDevProcess(record, cmd) { filtered = append(filtered, record) diff --git a/pkg/scanner/scanner.go b/pkg/scanner/scanner.go index 7f7fdff..bdb33a7 100644 --- a/pkg/scanner/scanner.go +++ b/pkg/scanner/scanner.go @@ -81,6 +81,7 @@ if len(fields) < 9 { return nil, fmt.Errorf("insufficient fields") } +command := fields[0] pidStr := fields[1] nameField := fields[8] @@ -97,7 +98,7 @@ return nil, fmt.Errorf("no port") return &models.ProcessRecord{ PID: pid, Port: port, -Command: "", // Will be enriched later +Command: command, // Preserve lsof command name as fallback if ps lookup fails CWD: "", // Skip for now - was causing hangs Protocol: "tcp", }, nil @@ -129,7 +130,9 @@ func (ps *ProcessScanner) enrichWithCommands(records []*models.ProcessRecord) { cmd := exec.Command("ps", "-p", fmt.Sprintf("%d", record.PID), "-o", "command=") output, err := cmd.Output() if err == nil { - record.Command = strings.TrimSpace(string(output)) + if fullCmd := strings.TrimSpace(string(output)); fullCmd != "" { + record.Command = fullCmd + } } if record.CWD == "" { diff --git a/pkg/scanner/scanner_test.go b/pkg/scanner/scanner_test.go new file mode 100644 index 0000000..4114508 --- /dev/null +++ b/pkg/scanner/scanner_test.go @@ -0,0 +1,21 @@ +package scanner + +import "testing" + +func TestParseLsofLine_PreservesCommandFallback(t *testing.T) { + ps := NewProcessScanner() + + record, err := ps.parseLsofLine("node 12345 kirby 22u IPv4 0x1234567890 0t0 TCP *:5173 (LISTEN)") + if err != nil { + t.Fatalf("parseLsofLine returned error: %v", err) + } + if record == nil { + t.Fatal("expected record") + } + if record.Command != "node" { + t.Fatalf("expected command fallback %q, got %q", "node", record.Command) + } + if record.Port != 5173 { + t.Fatalf("expected port 5173, got %d", record.Port) + } +} diff --git a/scripts/set-version.sh b/scripts/set-version.sh new file mode 100755 index 0000000..5d93936 --- /dev/null +++ b/scripts/set-version.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# Set version, commit, and create tag +# Usage: ./scripts/set-version.sh 0.2.1 + +set -e + +VERSION_FILE="pkg/buildinfo/version.go" + +if [ -z "$1" ]; then + echo "Usage: $0 " + echo " Example: $0 0.2.1" + exit 1 +fi + +NEW_VERSION="$1" +TAG="v$NEW_VERSION" + +# Validate version format (semver) +if ! [[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "❌ Invalid version format. Use: X.Y.Z (e.g., 0.2.1)" + exit 1 +fi + +# Check for uncommitted changes +if ! git diff --quiet || ! git diff --cached --quiet; then + echo "❌ You have uncommitted changes. Commit or stash them first." + exit 1 +fi + +# Check if tag already exists +if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "❌ Tag $TAG already exists." + echo " Delete it first: git tag -d $TAG && git push --delete origin $TAG" + exit 1 +fi + +# Update version file +sed -i '' "s/const Version = \"[^\"]*\"/const Version = \"$NEW_VERSION\"/" "$VERSION_FILE" + +echo "📝 Updated $VERSION_FILE to $NEW_VERSION" + +# Commit +git add "$VERSION_FILE" +git commit -m "chore: bump version to $NEW_VERSION" + +echo "✅ Committed version bump" + +# Create tag +git tag "$TAG" + +echo "🏷️ Created tag $TAG" +echo "" +echo "Next steps:" +echo " git push && git push origin $TAG"