From 6f8f44b6724322b371a17a09051acfa2b626dd73 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Tue, 5 May 2026 09:27:57 +0200 Subject: [PATCH] Add a real project picker overlay to the gx cockpit Phase 3 of the dmux-style cockpit plan: replace the placeholder projects panel with a navigable list of git repos discovered under configurable roots (default: parent of repo, ~/Documents, ~/code, ~/src, ~/projects, override via GUARDEX_PROJECT_ROOTS). The picker walks roots up to depth 2, skips noise like node_modules and dist, de-duplicates, and sorts alphabetically. Up/down keys wrap-navigate, r rescans, Enter emits a project:switch intent and returns to main, Esc cancels. The scanner is filesystem-injectable for unit tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../proposal.md | 45 +++++ .../specs/cockpit-projects/spec.md | 60 ++++++ .../tasks.md | 30 +++ src/cockpit/control.js | 87 ++++++++- src/cockpit/projects-finder.js | 178 +++++++++++++++++ test/cockpit-projects.test.js | 179 ++++++++++++++++++ 6 files changed, 570 insertions(+), 9 deletions(-) create mode 100644 openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase3-projects-2026-05-05-09-23/proposal.md create mode 100644 openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase3-projects-2026-05-05-09-23/specs/cockpit-projects/spec.md create mode 100644 openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase3-projects-2026-05-05-09-23/tasks.md create mode 100644 src/cockpit/projects-finder.js create mode 100644 test/cockpit-projects.test.js diff --git a/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase3-projects-2026-05-05-09-23/proposal.md b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase3-projects-2026-05-05-09-23/proposal.md new file mode 100644 index 0000000..d1578ea --- /dev/null +++ b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase3-projects-2026-05-05-09-23/proposal.md @@ -0,0 +1,45 @@ +# dmux-style cockpit — Phase 3: project picker + +## Why + +Phase 1 wired the `[p]rojects` hotkey, phase 2 added it to the welcome +screen — but pressing `p` still shows a placeholder panel. Phase 3 +turns the picker into a real, navigable list of git repos under the +user's workspace, mirroring dmux's "Select Project" overlay. + +## What changes + +- New `src/cockpit/projects-finder.js`: + - `findProjects({ roots, repoRoot, fs, env })` — walks roots up to + depth 2, collects directories that contain a `.git` entry, skips + common noise (`node_modules`, `.cache`, `.next`, `dist`, etc.), + de-duplicates, sorts alphabetically by name. + - `defaultRoots()` — `GUARDEX_PROJECT_ROOTS` env override + (`:`-separated), else parent-of-repo, then `~/Documents`, + `~/code`, `~/src`, `~/projects`. + - `expandHome()` helper for `~` and `~/...`. +- Real `renderProjectsPanel`: + - Lists all discovered projects with a `>` cursor on the selected + row and a `*` marker on the row matching the current `repoPath`. + - Shows the configured root paths and an empty-state hint pointing + at `GUARDEX_PROJECT_ROOTS`. + - Footer hints: `Enter: switch`, `r: rescan`, `Esc: back to main`. +- Control state hooks: + - Pressing `p` (with no lane selected) populates `state.projects` / + `state.projectsRoots` lazily on first entry; later entries reuse + the cache. + - `up`/`down`/`j`/`k` wrap-navigate the list. + - `Enter` emits `lastIntent = { type: 'project:switch', path, name }` + and returns to `main` mode. + - `r` rescans (clears cache and re-walks the roots). + +## Impact + +- Picker is read-only at the cockpit layer — emitting the intent is + enough; the host shell or phase-6 wiring can act on the intent + (re-launch `gx cockpit --target `, etc.). +- New module is fully unit-testable via injectable `fs` (no real disk + I/O required in CI). +- No safety-model change: branches, worktrees, locks, PR-only finish + flow are untouched. +- ASCII-only renderer; no unicode glyphs. diff --git a/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase3-projects-2026-05-05-09-23/specs/cockpit-projects/spec.md b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase3-projects-2026-05-05-09-23/specs/cockpit-projects/spec.md new file mode 100644 index 0000000..e32e76c --- /dev/null +++ b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase3-projects-2026-05-05-09-23/specs/cockpit-projects/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: Cockpit ships a project picker module +The cockpit SHALL expose a `projects-finder` module that scans +configured workspace roots for git repositories and returns a stable, +de-duplicated list ready for rendering. + +#### Scenario: findProjects walks roots and skips ignored dirs +- **WHEN** `findProjects({ roots, fs })` is called against a tree that + contains git repos under both `roots[0]/recodee` and + `roots[0]/tools/cli`, plus a `.git` inside `node_modules` +- **THEN** the returned `projects` list contains the two real repos +- **AND** the result does NOT contain any entry under `node_modules` + (which is in the skip list). + +#### Scenario: defaultRoots respects the env override +- **WHEN** `defaultRoots({ env: { GUARDEX_PROJECT_ROOTS: '/a:/b:/a' } })` + is called +- **THEN** the returned roots are `['/a', '/b']` (de-duplicated, in + order). + +#### Scenario: expandHome expands ~ paths +- **WHEN** `expandHome('~')` is called and `HOME` is set +- **THEN** the result equals `process.env.HOME`. +- **AND** `expandHome('~/projects')` resolves to `/projects`. +- **AND** absolute paths are returned unchanged. + +### Requirement: Projects mode renders a navigable list with cursor and current markers +The cockpit `projects` mode panel SHALL render every discovered repo +as a row, mark the cursor row with `>`, and mark the row whose path +matches `state.repoPath` with `*`. The footer SHALL list the +`Enter`/`r`/`Esc` hints. + +#### Scenario: Project list renders cursor, current marker, and footer +- **WHEN** the cockpit is in `projects` mode with two known projects + and `repoPath` matching the first project +- **THEN** the rendered panel contains a row matching `> * alpha` +- **AND** the second row exists without a cursor (` beta`) +- **AND** the rendered panel contains `r:` `rescan` and `Esc:` `back to + main` footer hints. + +### Requirement: Projects mode key handlers navigate, rescan, and emit a switch intent +The cockpit key handler SHALL respond to `up`/`down`/`j`/`k` to wrap +through the projects list, to `r` to rescan, to `Enter` to emit a +`project:switch` intent and return to `main`, and to `Esc` to return +to `main` without emitting any intent. + +#### Scenario: j and k navigate with wrap-around +- **WHEN** the cockpit is in `projects` mode with three projects and + `projectsIndex === 0`, and the user presses `j` then `j` then `j` +- **THEN** `projectsIndex` becomes `1`, then `2`, then wraps back to + `0`. +- **AND** pressing `k` from index `0` wraps to the last project. + +#### Scenario: Enter emits project:switch and returns to main +- **WHEN** the cockpit is in `projects` mode with `projectsIndex` on a + valid project and the user presses `Enter` +- **THEN** the resulting state has `mode === 'main'` +- **AND** `lastIntent` equals `{ type: 'project:switch', path, + name }` for the selected project. diff --git a/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase3-projects-2026-05-05-09-23/tasks.md b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase3-projects-2026-05-05-09-23/tasks.md new file mode 100644 index 0000000..765dbc3 --- /dev/null +++ b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase3-projects-2026-05-05-09-23/tasks.md @@ -0,0 +1,30 @@ +# Tasks + +## 1. Spec +- [x] 1.1 Capture proposal in `proposal.md` +- [x] 1.2 Capture spec delta in `specs/cockpit-projects/spec.md` + +## 2. Tests +- [x] 2.1 Add `test/cockpit-projects.test.js` covering + `findProjects`, `defaultRoots`, `expandHome`, projects-mode + navigation, enter-emits-intent, and the rendered panel. +- [x] 2.2 Verify existing cockpit-control tests still pass. + +## 3. Implementation +- [x] 3.1 Add `src/cockpit/projects-finder.js` with `findProjects`, + `defaultRoots`, `expandHome`, `walkRoot`, `uniqueRoots`, + `SKIP_NAMES`. +- [x] 3.2 Replace placeholder `renderProjectsPanel` in + `src/cockpit/control.js` with a real list view (cursor, current + marker, empty state, root listing, footer hints). +- [x] 3.3 Add `loadProjectsState` helper and call it from + `openActionRow('projects')` so the list is hydrated lazily. +- [x] 3.4 In `applyKey`, add `up`/`down`/`j`/`k` navigation, `r` + rescan, and `enter` `project:switch` intent emission for + `projects` mode. + +## 4. Cleanup +- [ ] 4.1 Commit changes on the agent branch. +- [ ] 4.2 Push branch and open a PR. +- [ ] 4.3 Run `gx branch finish ... --via-pr --wait-for-merge --cleanup`. +- [ ] 4.4 Record PR URL and `MERGED` evidence. diff --git a/src/cockpit/control.js b/src/cockpit/control.js index 6693d81..2b17f4b 100644 --- a/src/cockpit/control.js +++ b/src/cockpit/control.js @@ -7,6 +7,7 @@ const { CONTROL_KEY_HELP } = require('./shortcuts'); const { stripAnsi } = require('./theme'); const { renderWelcomePage } = require('./welcome'); const { runCockpitAction } = require('./action-runner'); +const { findProjects } = require('./projects-finder'); const { PANE_MENU_ITEMS, applyPaneMenuKey, @@ -350,7 +351,12 @@ function openActionRow(state, actionId) { return normalizeControlState({ ...current, mode: 'logs', lastIntent: null }); } if (actionId === 'projects') { - return normalizeControlState({ ...current, mode: 'projects', lastIntent: null }); + const withProjects = loadProjectsState(current); + return normalizeControlState({ + ...withProjects, + mode: 'projects', + lastIntent: null, + }); } return normalizeControlState({ ...current, lastIntent: null }); } @@ -469,6 +475,20 @@ function applyKey(state, rawKey) { lastIntent: buildIntent(current, 'terminal:open'), }); } + if (mode === 'projects') { + const projects = Array.isArray(current.projects) ? current.projects : []; + const project = projects[current.projectsIndex] || null; + if (!project) return current; + return normalizeControlState({ + ...current, + mode: 'main', + lastIntent: { + type: 'project:switch', + path: project.path, + name: project.name, + }, + }); + } if (current.sessions.length === 0 && current.selectedScope === 'action') { return openSelectedActionRow(current); } @@ -485,6 +505,12 @@ function applyKey(state, rawKey) { if (mode === 'settings') { return normalizeControlState({ ...current, settingsIndex: current.settingsIndex + 1, lastIntent: null }); } + if (mode === 'projects') { + const projects = Array.isArray(current.projects) ? current.projects : []; + if (projects.length === 0) return current; + const next = (current.projectsIndex + 1) % projects.length; + return normalizeControlState({ ...current, projectsIndex: next, lastIntent: null }); + } return moveSelection(current, 1); } if (key === 'up' || key === 'k') { @@ -494,8 +520,18 @@ function applyKey(state, rawKey) { if (mode === 'settings') { return normalizeControlState({ ...current, settingsIndex: current.settingsIndex - 1, lastIntent: null }); } + if (mode === 'projects') { + const projects = Array.isArray(current.projects) ? current.projects : []; + if (projects.length === 0) return current; + const next = (current.projectsIndex - 1 + projects.length) % projects.length; + return normalizeControlState({ ...current, projectsIndex: next, lastIntent: null }); + } return moveSelection(current, -1); } + if (mode === 'projects' && key === 'r') { + const refreshed = loadProjectsState(current, { refresh: true }); + return normalizeControlState({ ...refreshed, lastIntent: null }); + } return current; } @@ -689,20 +725,53 @@ function renderLogsPanel(state) { ].join('\n'); } +function loadProjectsState(current, options = {}) { + if (Array.isArray(current.projects) && current.projects.length > 0 && options.refresh !== true) { + return current; + } + const result = findProjects({ + repoRoot: current.repoPath, + env: options.env || process.env, + fs: options.fs, + }); + return { + ...current, + projects: result.projects, + projectsRoots: result.roots, + projectsIndex: 0, + }; +} + function renderProjectsPanel(state) { const current = normalizeControlState(state); - return [ + const projects = Array.isArray(current.projects) ? current.projects : []; + const roots = Array.isArray(current.projectsRoots) ? current.projectsRoots : []; + const index = Math.max(0, Math.min(current.projectsIndex || 0, Math.max(projects.length - 1, 0))); + const lines = [ 'projects', '', `current: ${current.repoPath || '(none)'}`, + `roots: ${roots.join(' | ') || '(none)'}`, '', - 'Enter: switch to selected project', - 'Esc: back to main', - '', - 'Picker scans for git repos under your workspace and switches the', - 'cockpit target to the chosen one.', - '', - ].join('\n'); + ]; + + if (projects.length === 0) { + lines.push(' no git repos found under any configured root'); + lines.push(' set GUARDEX_PROJECT_ROOTS=/path/a:/path/b to override'); + } else { + projects.forEach((project, i) => { + const cursor = i === index ? '>' : ' '; + const here = project.path === current.repoPath ? '*' : ' '; + lines.push(`${cursor} ${here} ${project.name}`); + }); + } + + lines.push(''); + lines.push('Enter: switch to selected project'); + lines.push('r: rescan'); + lines.push('Esc: back to main'); + lines.push(''); + return lines.join('\n'); } function renderMenuPanel(state) { diff --git a/src/cockpit/projects-finder.js b/src/cockpit/projects-finder.js new file mode 100644 index 0000000..0230231 --- /dev/null +++ b/src/cockpit/projects-finder.js @@ -0,0 +1,178 @@ +'use strict'; + +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const DEFAULT_DEPTH = 2; +const DEFAULT_LIMIT = 50; +const SKIP_NAMES = new Set([ + '.git', + '.svn', + '.hg', + 'node_modules', + '.npm-cache', + '.npm-logs', + '.omc', + '.omx', + 'dist', + 'build', + 'target', + '.cache', + '.next', + '.venv', + 'venv', + '__pycache__', +]); + +function text(value, fallback = '') { + if (typeof value === 'string') return value.trim() || fallback; + if (value === null || value === undefined) return fallback; + return String(value).trim() || fallback; +} + +function expandHome(input) { + const value = text(input); + if (!value) return ''; + if (value === '~') return os.homedir(); + if (value.startsWith('~/')) return path.join(os.homedir(), value.slice(2)); + return value; +} + +function uniqueRoots(values) { + const seen = new Set(); + const result = []; + for (const value of values) { + const expanded = expandHome(value); + if (!expanded) continue; + const resolved = path.resolve(expanded); + if (seen.has(resolved)) continue; + seen.add(resolved); + result.push(resolved); + } + return result; +} + +function defaultRoots(options = {}) { + const env = options.env || process.env; + const explicit = text(env.GUARDEX_PROJECT_ROOTS); + if (explicit) { + return uniqueRoots(explicit.split(path.delimiter).map((entry) => entry.trim()).filter(Boolean)); + } + + const seeds = []; + const repoRoot = text(options.repoRoot); + if (repoRoot) { + seeds.push(path.dirname(repoRoot)); + } + seeds.push(path.join(os.homedir(), 'Documents')); + seeds.push(path.join(os.homedir(), 'code')); + seeds.push(path.join(os.homedir(), 'src')); + seeds.push(path.join(os.homedir(), 'projects')); + return uniqueRoots(seeds); +} + +function isGitRepo(dir, fsImpl) { + try { + const gitEntry = path.join(dir, '.git'); + const stat = fsImpl.statSync(gitEntry, { throwIfNoEntry: false }); + if (!stat) return false; + return stat.isDirectory() || stat.isFile(); + } catch (_error) { + return false; + } +} + +function listDirectories(dir, fsImpl) { + try { + const entries = fsImpl.readdirSync(dir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ name: entry.name, fullPath: path.join(dir, entry.name) })); + } catch (_error) { + return []; + } +} + +function projectName(repoPath, root) { + const rel = path.relative(root, repoPath); + return rel && !rel.startsWith('..') ? rel : path.basename(repoPath); +} + +function walkRoot(root, options = {}) { + const depth = Number.isFinite(options.depth) && options.depth >= 0 ? options.depth : DEFAULT_DEPTH; + const limit = Number.isFinite(options.limit) && options.limit > 0 ? options.limit : DEFAULT_LIMIT; + const fsImpl = options.fs || fs; + + if (!isAccessibleDirectory(root, fsImpl)) return []; + + const results = []; + const stack = [{ dir: root, level: 0 }]; + + while (stack.length > 0 && results.length < limit) { + const { dir, level } = stack.pop(); + if (SKIP_NAMES.has(path.basename(dir))) continue; + + if (isGitRepo(dir, fsImpl)) { + results.push({ + path: dir, + name: projectName(dir, root), + root, + }); + continue; + } + + if (level >= depth) continue; + const children = listDirectories(dir, fsImpl).reverse(); + for (const child of children) { + if (SKIP_NAMES.has(child.name)) continue; + stack.push({ dir: child.fullPath, level: level + 1 }); + } + } + + return results; +} + +function isAccessibleDirectory(dir, fsImpl) { + try { + const stat = fsImpl.statSync(dir, { throwIfNoEntry: false }); + return Boolean(stat && stat.isDirectory()); + } catch (_error) { + return false; + } +} + +function findProjects(options = {}) { + const fsImpl = options.fs || fs; + const roots = options.roots && options.roots.length > 0 + ? uniqueRoots(options.roots) + : defaultRoots(options); + const limit = Number.isFinite(options.limit) && options.limit > 0 ? options.limit : DEFAULT_LIMIT; + const seen = new Set(); + const results = []; + + for (const root of roots) { + const found = walkRoot(root, { ...options, fs: fsImpl, limit: limit - results.length }); + for (const project of found) { + if (seen.has(project.path)) continue; + seen.add(project.path); + results.push(project); + if (results.length >= limit) break; + } + if (results.length >= limit) break; + } + + results.sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path)); + return { roots, projects: results }; +} + +module.exports = { + DEFAULT_DEPTH, + DEFAULT_LIMIT, + SKIP_NAMES, + defaultRoots, + expandHome, + findProjects, + uniqueRoots, + walkRoot, +}; diff --git a/test/cockpit-projects.test.js b/test/cockpit-projects.test.js new file mode 100644 index 0000000..69328ed --- /dev/null +++ b/test/cockpit-projects.test.js @@ -0,0 +1,179 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { findProjects, expandHome, defaultRoots } = require('../src/cockpit/projects-finder'); +const { applyCockpitAction } = require('../src/cockpit/control'); + +function fakeFs(tree) { + function lookup(p) { + const norm = p.replace(/\/+$/, ''); + return tree[norm] || null; + } + return { + statSync(p, options = {}) { + const node = lookup(p); + if (!node) { + if (options.throwIfNoEntry === false) return undefined; + const err = new Error(`ENOENT: ${p}`); + err.code = 'ENOENT'; + throw err; + } + return { + isDirectory: () => node.kind === 'dir', + isFile: () => node.kind === 'file', + }; + }, + readdirSync(p) { + const node = lookup(p); + if (!node || node.kind !== 'dir') { + const err = new Error(`ENOENT: ${p}`); + err.code = 'ENOENT'; + throw err; + } + return (node.entries || []).map((entry) => ({ + name: entry.name, + isDirectory: () => entry.kind === 'dir', + })); + }, + }; +} + +test('findProjects discovers nested git repos and skips ignored dirs', () => { + const tree = { + '/work': { kind: 'dir', entries: [ + { name: 'recodee', kind: 'dir' }, + { name: 'node_modules', kind: 'dir' }, + { name: 'tools', kind: 'dir' }, + ] }, + '/work/recodee': { kind: 'dir', entries: [ + { name: '.git', kind: 'dir' }, + { name: 'src', kind: 'dir' }, + ] }, + '/work/recodee/.git': { kind: 'dir' }, + '/work/recodee/src': { kind: 'dir', entries: [] }, + '/work/node_modules': { kind: 'dir', entries: [ + { name: 'lodash', kind: 'dir' }, + ] }, + '/work/node_modules/lodash': { kind: 'dir', entries: [ + { name: '.git', kind: 'dir' }, + ] }, + '/work/node_modules/lodash/.git': { kind: 'dir' }, + '/work/tools': { kind: 'dir', entries: [ + { name: 'cli', kind: 'dir' }, + ] }, + '/work/tools/cli': { kind: 'dir', entries: [ + { name: '.git', kind: 'dir' }, + ] }, + '/work/tools/cli/.git': { kind: 'dir' }, + }; + + const result = findProjects({ roots: ['/work'], fs: fakeFs(tree) }); + const paths = result.projects.map((p) => p.path).sort(); + assert.deepEqual(paths, ['/work/recodee', '/work/tools/cli']); + assert.deepEqual(result.roots, ['/work']); +}); + +test('findProjects honors GUARDEX_PROJECT_ROOTS env override via defaultRoots', () => { + const roots = defaultRoots({ env: { GUARDEX_PROJECT_ROOTS: '/a:/b:/a' } }); + assert.deepEqual(roots, ['/a', '/b']); +}); + +test('expandHome resolves ~ and ~/ paths', () => { + const home = process.env.HOME || ''; + if (!home) return; // skip when HOME is unset + assert.equal(expandHome('~'), home); + assert.match(expandHome('~/projects'), new RegExp(`^${home}/projects`)); + assert.equal(expandHome('/abs/path'), '/abs/path'); + assert.equal(expandHome(''), ''); +}); + +test('pressing p with no lanes opens projects mode and populates the list', () => { + const tree = { + '/repos': { kind: 'dir', entries: [ + { name: 'alpha', kind: 'dir' }, + { name: 'beta', kind: 'dir' }, + ] }, + '/repos/alpha': { kind: 'dir', entries: [{ name: '.git', kind: 'dir' }] }, + '/repos/alpha/.git': { kind: 'dir' }, + '/repos/beta': { kind: 'dir', entries: [{ name: '.git', kind: 'dir' }] }, + '/repos/beta/.git': { kind: 'dir' }, + }; + + // We can't easily inject fs via applyCockpitAction; test the underlying picker module + // and trust the control flow's loadProjectsState wires it through. The control test + // below exercises mode transitions without scanning the real filesystem. + const result = findProjects({ roots: ['/repos'], fs: fakeFs(tree) }); + assert.equal(result.projects.length, 2); + assert.deepEqual(result.projects.map((p) => p.name).sort(), ['alpha', 'beta']); +}); + +test('up/down keys navigate the projects list with wrap-around', () => { + const baseState = applyCockpitAction({}, { + type: 'refresh', + cockpitState: { repoPath: '/repo/gitguardex', sessions: [] }, + }); + // Inject a known projects list so we don't rely on the filesystem scan. + const seeded = { + ...baseState, + mode: 'projects', + projects: [ + { path: '/a', name: 'a', root: '/' }, + { path: '/b', name: 'b', root: '/' }, + { path: '/c', name: 'c', root: '/' }, + ], + projectsRoots: ['/'], + projectsIndex: 0, + }; + + const down1 = applyCockpitAction(seeded, { type: 'key', key: 'j' }); + assert.equal(down1.projectsIndex, 1); + const down2 = applyCockpitAction(down1, { type: 'key', key: 'down' }); + assert.equal(down2.projectsIndex, 2); + const wrap = applyCockpitAction(down2, { type: 'key', key: 'j' }); + assert.equal(wrap.projectsIndex, 0); + + const up1 = applyCockpitAction(seeded, { type: 'key', key: 'k' }); + assert.equal(up1.projectsIndex, 2, 'up from 0 wraps to last'); +}); + +test('enter on projects mode emits a project:switch intent and returns to main', () => { + const seeded = { + mode: 'projects', + projects: [ + { path: '/repos/alpha', name: 'alpha', root: '/repos' }, + { path: '/repos/beta', name: 'beta', root: '/repos' }, + ], + projectsRoots: ['/repos'], + projectsIndex: 1, + sessions: [], + }; + const result = applyCockpitAction(seeded, { type: 'key', key: 'enter' }); + assert.equal(result.mode, 'main'); + assert.deepEqual(result.lastIntent, { + type: 'project:switch', + path: '/repos/beta', + name: 'beta', + }); +}); + +test('renderProjectsPanel includes the cursor, current marker, and rescan hint', () => { + const { renderControlFrame } = require('../src/cockpit/control'); + const seeded = { + mode: 'projects', + repoPath: '/repos/alpha', + projects: [ + { path: '/repos/alpha', name: 'alpha', root: '/repos' }, + { path: '/repos/beta', name: 'beta', root: '/repos' }, + ], + projectsRoots: ['/repos'], + projectsIndex: 0, + sessions: [], + }; + const frame = renderControlFrame(seeded).replace(/\x1b\[[0-9;]*m/g, ''); + assert.match(frame, />\s+\*\s+alpha/); + assert.match(frame, /\s+beta/); + assert.match(frame, /r:\s+rescan/); + assert.match(frame, /Esc:\s+back to main/); +});