Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <path>`, 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.
Original file line number Diff line number Diff line change
@@ -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 `<HOME>/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.
Original file line number Diff line number Diff line change
@@ -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.
87 changes: 78 additions & 9 deletions src/cockpit/control.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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);
}
Expand All @@ -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') {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading