Skip to content

Commit 4187fc2

Browse files
NagyViktNagyViktclaude
authored
Add a real project picker overlay to the gx cockpit (#526)
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: NagyVikt <nagy.viktordp@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a968cdc commit 4187fc2

6 files changed

Lines changed: 570 additions & 9 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# dmux-style cockpit — Phase 3: project picker
2+
3+
## Why
4+
5+
Phase 1 wired the `[p]rojects` hotkey, phase 2 added it to the welcome
6+
screen — but pressing `p` still shows a placeholder panel. Phase 3
7+
turns the picker into a real, navigable list of git repos under the
8+
user's workspace, mirroring dmux's "Select Project" overlay.
9+
10+
## What changes
11+
12+
- New `src/cockpit/projects-finder.js`:
13+
- `findProjects({ roots, repoRoot, fs, env })` — walks roots up to
14+
depth 2, collects directories that contain a `.git` entry, skips
15+
common noise (`node_modules`, `.cache`, `.next`, `dist`, etc.),
16+
de-duplicates, sorts alphabetically by name.
17+
- `defaultRoots()``GUARDEX_PROJECT_ROOTS` env override
18+
(`:`-separated), else parent-of-repo, then `~/Documents`,
19+
`~/code`, `~/src`, `~/projects`.
20+
- `expandHome()` helper for `~` and `~/...`.
21+
- Real `renderProjectsPanel`:
22+
- Lists all discovered projects with a `>` cursor on the selected
23+
row and a `*` marker on the row matching the current `repoPath`.
24+
- Shows the configured root paths and an empty-state hint pointing
25+
at `GUARDEX_PROJECT_ROOTS`.
26+
- Footer hints: `Enter: switch`, `r: rescan`, `Esc: back to main`.
27+
- Control state hooks:
28+
- Pressing `p` (with no lane selected) populates `state.projects` /
29+
`state.projectsRoots` lazily on first entry; later entries reuse
30+
the cache.
31+
- `up`/`down`/`j`/`k` wrap-navigate the list.
32+
- `Enter` emits `lastIntent = { type: 'project:switch', path, name }`
33+
and returns to `main` mode.
34+
- `r` rescans (clears cache and re-walks the roots).
35+
36+
## Impact
37+
38+
- Picker is read-only at the cockpit layer — emitting the intent is
39+
enough; the host shell or phase-6 wiring can act on the intent
40+
(re-launch `gx cockpit --target <path>`, etc.).
41+
- New module is fully unit-testable via injectable `fs` (no real disk
42+
I/O required in CI).
43+
- No safety-model change: branches, worktrees, locks, PR-only finish
44+
flow are untouched.
45+
- ASCII-only renderer; no unicode glyphs.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Cockpit ships a project picker module
4+
The cockpit SHALL expose a `projects-finder` module that scans
5+
configured workspace roots for git repositories and returns a stable,
6+
de-duplicated list ready for rendering.
7+
8+
#### Scenario: findProjects walks roots and skips ignored dirs
9+
- **WHEN** `findProjects({ roots, fs })` is called against a tree that
10+
contains git repos under both `roots[0]/recodee` and
11+
`roots[0]/tools/cli`, plus a `.git` inside `node_modules`
12+
- **THEN** the returned `projects` list contains the two real repos
13+
- **AND** the result does NOT contain any entry under `node_modules`
14+
(which is in the skip list).
15+
16+
#### Scenario: defaultRoots respects the env override
17+
- **WHEN** `defaultRoots({ env: { GUARDEX_PROJECT_ROOTS: '/a:/b:/a' } })`
18+
is called
19+
- **THEN** the returned roots are `['/a', '/b']` (de-duplicated, in
20+
order).
21+
22+
#### Scenario: expandHome expands ~ paths
23+
- **WHEN** `expandHome('~')` is called and `HOME` is set
24+
- **THEN** the result equals `process.env.HOME`.
25+
- **AND** `expandHome('~/projects')` resolves to `<HOME>/projects`.
26+
- **AND** absolute paths are returned unchanged.
27+
28+
### Requirement: Projects mode renders a navigable list with cursor and current markers
29+
The cockpit `projects` mode panel SHALL render every discovered repo
30+
as a row, mark the cursor row with `>`, and mark the row whose path
31+
matches `state.repoPath` with `*`. The footer SHALL list the
32+
`Enter`/`r`/`Esc` hints.
33+
34+
#### Scenario: Project list renders cursor, current marker, and footer
35+
- **WHEN** the cockpit is in `projects` mode with two known projects
36+
and `repoPath` matching the first project
37+
- **THEN** the rendered panel contains a row matching `> * alpha`
38+
- **AND** the second row exists without a cursor (` beta`)
39+
- **AND** the rendered panel contains `r:` `rescan` and `Esc:` `back to
40+
main` footer hints.
41+
42+
### Requirement: Projects mode key handlers navigate, rescan, and emit a switch intent
43+
The cockpit key handler SHALL respond to `up`/`down`/`j`/`k` to wrap
44+
through the projects list, to `r` to rescan, to `Enter` to emit a
45+
`project:switch` intent and return to `main`, and to `Esc` to return
46+
to `main` without emitting any intent.
47+
48+
#### Scenario: j and k navigate with wrap-around
49+
- **WHEN** the cockpit is in `projects` mode with three projects and
50+
`projectsIndex === 0`, and the user presses `j` then `j` then `j`
51+
- **THEN** `projectsIndex` becomes `1`, then `2`, then wraps back to
52+
`0`.
53+
- **AND** pressing `k` from index `0` wraps to the last project.
54+
55+
#### Scenario: Enter emits project:switch and returns to main
56+
- **WHEN** the cockpit is in `projects` mode with `projectsIndex` on a
57+
valid project and the user presses `Enter`
58+
- **THEN** the resulting state has `mode === 'main'`
59+
- **AND** `lastIntent` equals `{ type: 'project:switch', path,
60+
name }` for the selected project.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Tasks
2+
3+
## 1. Spec
4+
- [x] 1.1 Capture proposal in `proposal.md`
5+
- [x] 1.2 Capture spec delta in `specs/cockpit-projects/spec.md`
6+
7+
## 2. Tests
8+
- [x] 2.1 Add `test/cockpit-projects.test.js` covering
9+
`findProjects`, `defaultRoots`, `expandHome`, projects-mode
10+
navigation, enter-emits-intent, and the rendered panel.
11+
- [x] 2.2 Verify existing cockpit-control tests still pass.
12+
13+
## 3. Implementation
14+
- [x] 3.1 Add `src/cockpit/projects-finder.js` with `findProjects`,
15+
`defaultRoots`, `expandHome`, `walkRoot`, `uniqueRoots`,
16+
`SKIP_NAMES`.
17+
- [x] 3.2 Replace placeholder `renderProjectsPanel` in
18+
`src/cockpit/control.js` with a real list view (cursor, current
19+
marker, empty state, root listing, footer hints).
20+
- [x] 3.3 Add `loadProjectsState` helper and call it from
21+
`openActionRow('projects')` so the list is hydrated lazily.
22+
- [x] 3.4 In `applyKey`, add `up`/`down`/`j`/`k` navigation, `r`
23+
rescan, and `enter` `project:switch` intent emission for
24+
`projects` mode.
25+
26+
## 4. Cleanup
27+
- [ ] 4.1 Commit changes on the agent branch.
28+
- [ ] 4.2 Push branch and open a PR.
29+
- [ ] 4.3 Run `gx branch finish ... --via-pr --wait-for-merge --cleanup`.
30+
- [ ] 4.4 Record PR URL and `MERGED` evidence.

src/cockpit/control.js

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const { CONTROL_KEY_HELP } = require('./shortcuts');
77
const { stripAnsi } = require('./theme');
88
const { renderWelcomePage } = require('./welcome');
99
const { runCockpitAction } = require('./action-runner');
10+
const { findProjects } = require('./projects-finder');
1011
const {
1112
PANE_MENU_ITEMS,
1213
applyPaneMenuKey,
@@ -350,7 +351,12 @@ function openActionRow(state, actionId) {
350351
return normalizeControlState({ ...current, mode: 'logs', lastIntent: null });
351352
}
352353
if (actionId === 'projects') {
353-
return normalizeControlState({ ...current, mode: 'projects', lastIntent: null });
354+
const withProjects = loadProjectsState(current);
355+
return normalizeControlState({
356+
...withProjects,
357+
mode: 'projects',
358+
lastIntent: null,
359+
});
354360
}
355361
return normalizeControlState({ ...current, lastIntent: null });
356362
}
@@ -469,6 +475,20 @@ function applyKey(state, rawKey) {
469475
lastIntent: buildIntent(current, 'terminal:open'),
470476
});
471477
}
478+
if (mode === 'projects') {
479+
const projects = Array.isArray(current.projects) ? current.projects : [];
480+
const project = projects[current.projectsIndex] || null;
481+
if (!project) return current;
482+
return normalizeControlState({
483+
...current,
484+
mode: 'main',
485+
lastIntent: {
486+
type: 'project:switch',
487+
path: project.path,
488+
name: project.name,
489+
},
490+
});
491+
}
472492
if (current.sessions.length === 0 && current.selectedScope === 'action') {
473493
return openSelectedActionRow(current);
474494
}
@@ -485,6 +505,12 @@ function applyKey(state, rawKey) {
485505
if (mode === 'settings') {
486506
return normalizeControlState({ ...current, settingsIndex: current.settingsIndex + 1, lastIntent: null });
487507
}
508+
if (mode === 'projects') {
509+
const projects = Array.isArray(current.projects) ? current.projects : [];
510+
if (projects.length === 0) return current;
511+
const next = (current.projectsIndex + 1) % projects.length;
512+
return normalizeControlState({ ...current, projectsIndex: next, lastIntent: null });
513+
}
488514
return moveSelection(current, 1);
489515
}
490516
if (key === 'up' || key === 'k') {
@@ -494,8 +520,18 @@ function applyKey(state, rawKey) {
494520
if (mode === 'settings') {
495521
return normalizeControlState({ ...current, settingsIndex: current.settingsIndex - 1, lastIntent: null });
496522
}
523+
if (mode === 'projects') {
524+
const projects = Array.isArray(current.projects) ? current.projects : [];
525+
if (projects.length === 0) return current;
526+
const next = (current.projectsIndex - 1 + projects.length) % projects.length;
527+
return normalizeControlState({ ...current, projectsIndex: next, lastIntent: null });
528+
}
497529
return moveSelection(current, -1);
498530
}
531+
if (mode === 'projects' && key === 'r') {
532+
const refreshed = loadProjectsState(current, { refresh: true });
533+
return normalizeControlState({ ...refreshed, lastIntent: null });
534+
}
499535

500536
return current;
501537
}
@@ -689,20 +725,53 @@ function renderLogsPanel(state) {
689725
].join('\n');
690726
}
691727

728+
function loadProjectsState(current, options = {}) {
729+
if (Array.isArray(current.projects) && current.projects.length > 0 && options.refresh !== true) {
730+
return current;
731+
}
732+
const result = findProjects({
733+
repoRoot: current.repoPath,
734+
env: options.env || process.env,
735+
fs: options.fs,
736+
});
737+
return {
738+
...current,
739+
projects: result.projects,
740+
projectsRoots: result.roots,
741+
projectsIndex: 0,
742+
};
743+
}
744+
692745
function renderProjectsPanel(state) {
693746
const current = normalizeControlState(state);
694-
return [
747+
const projects = Array.isArray(current.projects) ? current.projects : [];
748+
const roots = Array.isArray(current.projectsRoots) ? current.projectsRoots : [];
749+
const index = Math.max(0, Math.min(current.projectsIndex || 0, Math.max(projects.length - 1, 0)));
750+
const lines = [
695751
'projects',
696752
'',
697753
`current: ${current.repoPath || '(none)'}`,
754+
`roots: ${roots.join(' | ') || '(none)'}`,
698755
'',
699-
'Enter: switch to selected project',
700-
'Esc: back to main',
701-
'',
702-
'Picker scans for git repos under your workspace and switches the',
703-
'cockpit target to the chosen one.',
704-
'',
705-
].join('\n');
756+
];
757+
758+
if (projects.length === 0) {
759+
lines.push(' no git repos found under any configured root');
760+
lines.push(' set GUARDEX_PROJECT_ROOTS=/path/a:/path/b to override');
761+
} else {
762+
projects.forEach((project, i) => {
763+
const cursor = i === index ? '>' : ' ';
764+
const here = project.path === current.repoPath ? '*' : ' ';
765+
lines.push(`${cursor} ${here} ${project.name}`);
766+
});
767+
}
768+
769+
lines.push('');
770+
lines.push('Enter: switch to selected project');
771+
lines.push('r: rescan');
772+
lines.push('Esc: back to main');
773+
lines.push('');
774+
return lines.join('\n');
706775
}
707776

708777
function renderMenuPanel(state) {

0 commit comments

Comments
 (0)