Skip to content

Commit 27e4e88

Browse files
feat: unpushed signal + nested-repo scan + open-in-editor (DECK-13/12/10)
- DECK-13: GitInfo.ahead via `git rev-list --count @{upstream}..HEAD` (parseAheadCount; null when no upstream) -> ↑N on the card meta line. - DECK-12: scanner walks up to 2 levels (org/repo), stopping at the first repo so it never descends into one; node_modules ignored. - DECK-10: openInEditor / editorSpec (posix args-array = injection-safe; Windows quotes the path through a shell for code.cmd) + project:openEditor IPC + a { } card button. - i18n proj.unpushed / proj.open_editor across all 4 locales. 91 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 22133e3 commit 27e4e88

20 files changed

Lines changed: 166 additions & 17 deletions

src/main/gitInfo.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getGitInfo } from './gitInfo';
55
function fakeRunner(map: Record<string, string>) {
66
return async (args: string[]): Promise<string> => {
77
if (args.includes('rev-parse')) return map.branch ?? '';
8+
if (args.includes('rev-list')) return map.ahead ?? '';
89
if (args.includes('log')) return map.log ?? '';
910
if (args.includes('status')) return map.status ?? '';
1011
return '';
@@ -17,15 +18,26 @@ describe('getGitInfo', () => {
1718
branch: 'main\n',
1819
log: '1717287840|scaffold\n',
1920
status: ' M a.ts\n?? b.ts\n',
21+
ahead: '2\n',
2022
});
2123
expect(await getGitInfo('C:\\g\\x', run)).toEqual({
2224
branch: 'main',
2325
lastCommitMs: 1717287840000,
2426
lastSubject: 'scaffold',
2527
uncommitted: 2,
28+
ahead: 2,
2629
});
2730
});
2831

32+
it('reports null ahead when there is no upstream (rev-list throws)', async () => {
33+
const run = async (args: string[]): Promise<string> => {
34+
if (args.includes('rev-list')) throw new Error('no upstream');
35+
if (args.includes('rev-parse')) return 'main\n';
36+
return '';
37+
};
38+
expect((await getGitInfo('C:\\g\\x', run)).ahead).toBeNull();
39+
});
40+
2941
it('handles a repo with no commits', async () => {
3042
const run = fakeRunner({ branch: 'main\n', log: '', status: '' });
3143
const info = await getGitInfo('C:\\g\\x', run);

src/main/gitInfo.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { execFile } from 'node:child_process';
22
import { promisify } from 'node:util';
33
import type { GitInfo } from '../shared/types';
4-
import { parseBranch, parseLastCommit, parsePorcelainCount } from '../shared/gitParse';
4+
import { parseBranch, parseLastCommit, parsePorcelainCount, parseAheadCount } from '../shared/gitParse';
55

66
const execFileAsync = promisify(execFile);
77

@@ -21,16 +21,18 @@ async function safe(run: GitRunner, args: string[]): Promise<string | null> {
2121
}
2222

2323
export async function getGitInfo(dir: string, run: GitRunner = defaultRunner): Promise<GitInfo> {
24-
const [branchOut, logOut, statusOut] = await Promise.all([
24+
const [branchOut, logOut, statusOut, aheadOut] = await Promise.all([
2525
safe(run, ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD']),
2626
safe(run, ['-C', dir, 'log', '-1', '--format=%ct|%s']),
2727
safe(run, ['-C', dir, 'status', '--porcelain']),
28+
safe(run, ['-C', dir, 'rev-list', '--count', '@{upstream}..HEAD']),
2829
]);
2930
const { lastCommitMs, lastSubject } = parseLastCommit(logOut ?? '');
3031
return {
3132
branch: branchOut == null ? null : parseBranch(branchOut),
3233
lastCommitMs,
3334
lastSubject,
3435
uncommitted: parsePorcelainCount(statusOut ?? ''),
36+
ahead: aheadOut == null ? null : parseAheadCount(aheadOut),
3537
};
3638
}

src/main/ipc.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { scanRepos } from './scanner';
66
import { getGitInfo } from './gitInfo';
77
import { listSessions, isValidSessionId, lastUserMessageForSession } from './sessions';
88
import { buildProjectList } from './projects';
9-
import { openProjects } from './launcher';
9+
import { openProjects, openInEditor } from './launcher';
1010
import type { WtTab } from '../shared/wtArgs';
1111
import { scanUsage } from './usageScan';
1212
import { DEFAULT_THRESHOLDS } from '../shared/staleness';
@@ -121,6 +121,15 @@ export function registerIpc(cfg: IpcConfig): void {
121121
if (err) cfg.sendError(err);
122122
});
123123

124+
// Open the project in VS Code (`code <path>`).
125+
ipcMain.handle('project:openEditor', (_e, p: string) => {
126+
if (!isUnderBase(effBaseDir(), p)) {
127+
cfg.sendError(`Path outside base dir: ${p}`);
128+
return;
129+
}
130+
openInEditor(p, { onError: cfg.sendError });
131+
});
132+
124133
// Frameless-window controls (the title bar draws its own buttons).
125134
ipcMain.handle('win:minimize', () => cfg.win.minimize());
126135
ipcMain.handle('win:toggleMaximize', () => {

src/main/launcher.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { openProjects, resolveShell, resolveWtPath, detectLinuxTerminal } from './launcher';
2+
import { openProjects, resolveShell, resolveWtPath, detectLinuxTerminal, editorSpec, openInEditor } from './launcher';
33
import type { WtTab } from '../shared/wtArgs';
44

55
type Call = { file: string; args: string[] };
@@ -65,6 +65,24 @@ describe('detectLinuxTerminal', () => {
6565
});
6666
});
6767

68+
describe('editorSpec', () => {
69+
it('posix: code with the dir as an args-array entry (no shell)', () => {
70+
expect(editorSpec('/g/a', 'darwin')).toEqual({ command: 'code', args: ['/g/a'], shell: false });
71+
expect(editorSpec('/g/a', 'linux')).toEqual({ command: 'code', args: ['/g/a'], shell: false });
72+
});
73+
it('windows: quoted path through a shell (code is code.cmd)', () => {
74+
expect(editorSpec('C:\\g\\a', 'win32')).toEqual({ command: 'code "C:\\g\\a"', args: [], shell: true });
75+
});
76+
});
77+
78+
describe('openInEditor', () => {
79+
it('dispatches the editor spec to spawnFn', () => {
80+
let got: { c: string; a: string[]; s: boolean } | null = null;
81+
openInEditor('/g/a', { platform: 'linux', spawnFn: (c, a, s) => { got = { c, a, s }; } });
82+
expect(got).toEqual({ c: 'code', a: ['/g/a'], s: false });
83+
});
84+
});
85+
6886
describe('resolveShell', () => {
6987
it('returns pwsh when present', () => { expect(resolveShell(() => true)).toBe('pwsh'); });
7088
it('falls back to powershell when absent', () => { expect(resolveShell(() => false)).toBe('powershell'); });

src/main/launcher.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,37 @@ function defaultSpawn(onError?: (msg: string) => void): SpawnFn {
8888
};
8989
}
9090

91+
export interface EditorSpec { command: string; args: string[]; shell: boolean; }
92+
93+
/**
94+
* How to launch VS Code at a directory. On POSIX `code` is a plain script — pass the
95+
* dir as an args-array entry (no shell = injection-safe). On Windows `code` is
96+
* `code.cmd`, which Node can only run through a shell, so quote the path (Windows
97+
* paths cannot contain `"`, so the quoting is safe).
98+
*/
99+
export function editorSpec(dir: string, platform: NodeJS.Platform = process.platform): EditorSpec {
100+
if (platform === 'win32') return { command: `code "${dir}"`, args: [], shell: true };
101+
return { command: 'code', args: [dir], shell: false };
102+
}
103+
104+
export interface EditorOptions {
105+
platform?: NodeJS.Platform;
106+
spawnFn?: (command: string, args: string[], shell: boolean) => void;
107+
onError?: (msg: string) => void;
108+
}
109+
110+
/** Open a directory in VS Code (`code <dir>`). */
111+
export function openInEditor(dir: string, opts: EditorOptions = {}): void {
112+
const spec = editorSpec(dir, opts.platform ?? process.platform);
113+
if (opts.spawnFn) { opts.spawnFn(spec.command, spec.args, spec.shell); return; }
114+
const child = spawn(spec.command, spec.args, { detached: true, stdio: 'ignore', shell: spec.shell, windowsHide: false });
115+
child.on('error', (err) => {
116+
console.error('DevDeck: failed to open editor', err);
117+
opts.onError?.(`Failed to open editor (is the \`code\` CLI on your PATH?): ${(err as Error).message}`);
118+
});
119+
child.unref();
120+
}
121+
91122
/** Open each project in a platform-appropriate terminal running its command. */
92123
export function openProjects(tabs: WtTab[], opts: OpenOptions = {}): void {
93124
if (tabs.length === 0) return;

src/main/projects.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ function deps(over: Partial<BuildDeps>): BuildDeps {
2020
lastCommitMs: dir.endsWith('fresh') ? NOW - 3_600_000 : NOW - 10 * DAY,
2121
lastSubject: 'x',
2222
uncommitted: 0,
23+
ahead: null,
2324
}),
2425
sessions: () => [],
2526
resumeCue: () => null,
@@ -59,7 +60,7 @@ describe('buildProjectList', () => {
5960
it('wires sessions + sessionCount and uses the latest session for activity', async () => {
6061
const NOW2 = NOW;
6162
const list = await buildProjectList(deps({
62-
git: async (): Promise<GitInfo> => ({ branch: 'main', lastCommitMs: null, lastSubject: null, uncommitted: 0 }),
63+
git: async (): Promise<GitInfo> => ({ branch: 'main', lastCommitMs: null, lastSubject: null, uncommitted: 0, ahead: 4 }),
6364
sessions: (p) => p.endsWith('fresh')
6465
? [{ id: 'x', mtimeMs: NOW2 - 3_600_000, firstMessage: 'hi' }]
6566
: [],
@@ -69,6 +70,7 @@ describe('buildProjectList', () => {
6970
expect(fresh.sessions[0].firstMessage).toBe('hi');
7071
expect(fresh.lastSessionMs).toBe(NOW2 - 3_600_000);
7172
expect(fresh.stale.level).toBe('fresh');
73+
expect(fresh.ahead).toBe(4); // unpushed count threaded through
7274
});
7375

7476
it('derives resumeCue from the newest session via the dep', async () => {

src/main/projects.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export async function buildProjectList(deps: BuildDeps): Promise<ProjectViewMode
3434
name: r.name,
3535
branch: git.branch,
3636
uncommitted: git.uncommitted,
37+
ahead: git.ahead,
3738
lastCommitMs: git.lastCommitMs,
3839
lastSubject: git.lastSubject,
3940
lastSessionMs,

src/main/scanner.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,19 @@ describe('scanRepos', () => {
2424
const repos = await scanRepos(base);
2525
expect(repos[0].path.startsWith(base)).toBe(true);
2626
});
27+
28+
it('finds org/repo (depth 2) but not repos inside a repo or beyond maxDepth', async () => {
29+
mkdirSync(join(base, 'org', 'repoX', '.git'), { recursive: true }); // depth-2 repo under a non-repo
30+
mkdirSync(join(base, 'org', 'repoY', '.git'), { recursive: true });
31+
mkdirSync(join(base, 'projA', 'nested', '.git'), { recursive: true }); // inside a repo -> NOT scanned
32+
mkdirSync(join(base, 'deep', 'a', 'b', '.git'), { recursive: true }); // depth 3 -> NOT scanned
33+
const repos = (await scanRepos(base)).map((p) => p.name).sort();
34+
expect(repos).toEqual(['projA', 'projB', 'repoX', 'repoY']);
35+
});
36+
37+
it('respects an explicit maxDepth of 1 (top level only)', async () => {
38+
mkdirSync(join(base, 'org', 'repoX', '.git'), { recursive: true });
39+
const repos = (await scanRepos(base, 1)).map((p) => p.name).sort();
40+
expect(repos).toEqual(['projA', 'projB']);
41+
});
2742
});

src/main/scanner.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,46 @@
11
import { readdir, access } from 'node:fs/promises';
22
import { join } from 'node:path';
33

4-
const IGNORE = new Set(['__pycache__', '.pytest_cache', '.claude', '.playwright-mcp']);
4+
const IGNORE = new Set(['__pycache__', '.pytest_cache', '.claude', '.playwright-mcp', 'node_modules']);
55

66
export interface RawProject {
77
path: string;
88
name: string;
99
}
1010

11-
export async function scanRepos(baseDir: string): Promise<RawProject[]> {
12-
const out: RawProject[] = [];
11+
async function isRepo(dir: string): Promise<boolean> {
12+
try {
13+
await access(join(dir, '.git'));
14+
return true;
15+
} catch {
16+
return false;
17+
}
18+
}
19+
20+
async function walk(dir: string, depth: number, maxDepth: number, out: RawProject[]): Promise<void> {
1321
let entries;
1422
try {
15-
entries = await readdir(baseDir, { withFileTypes: true });
23+
entries = await readdir(dir, { withFileTypes: true });
1624
} catch {
17-
return out;
25+
return;
1826
}
1927
await Promise.all(
2028
entries
2129
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && !IGNORE.has(e.name))
2230
.map(async (entry) => {
23-
const full = join(baseDir, entry.name);
24-
try {
25-
await access(join(full, '.git'));
26-
out.push({ path: full, name: entry.name });
27-
} catch { /* no .git */ }
31+
const full = join(dir, entry.name);
32+
if (await isRepo(full)) {
33+
out.push({ path: full, name: entry.name }); // a repo — include it, don't descend into it
34+
} else if (depth < maxDepth) {
35+
await walk(full, depth + 1, maxDepth, out); // not a repo — look one level deeper (org/repo layouts)
36+
}
2837
}),
2938
);
39+
}
40+
41+
/** Find git repos under baseDir, scanning up to maxDepth levels (default 2 = org/repo). */
42+
export async function scanRepos(baseDir: string, maxDepth = 2): Promise<RawProject[]> {
43+
const out: RawProject[] = [];
44+
await walk(baseDir, 1, maxDepth, out);
3045
return out;
3146
}

src/preload/preload.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ contextBridge.exposeInMainWorld('devdeck', {
1616
setThresholds: (t: { freshDays: number; warnDays: number; neglectedDays: number }) => ipcRenderer.invoke('settings:setThresholds', t),
1717
pickFolder: () => ipcRenderer.invoke('settings:pickFolder'),
1818
openFolder: (path: string) => ipcRenderer.invoke('project:openFolder', path),
19+
openEditor: (path: string) => ipcRenderer.invoke('project:openEditor', path),
1920
windowControls: {
2021
minimize: () => ipcRenderer.invoke('win:minimize'),
2122
toggleMaximize: () => ipcRenderer.invoke('win:toggleMaximize'),

0 commit comments

Comments
 (0)