Skip to content

Commit 9cd078b

Browse files
feat: multi-folder project scanning (scan roots + individual repos) (#9)
* feat(store): folders list with legacy baseDir migration * fix(store): explicit empty folders is authoritative; copy on read Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(scanner): scanFolders merges roots + direct repos, dedups by path Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(scanner): parallelize scanFolders to match walk's Promise.all * feat(guard): isAllowedPath pure guard over folder list Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(projects): scan() takes no base, deck spans multiple folders Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ipc): folders handlers, scanFolders for list/usage, isAllowedPath guard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(ipc): validate added folder exists; snapshot folders in open loop - settings:addFolder now stat-checks the path and rejects non-existent paths or plain files before storing, preventing garbage/non-directory paths from widening the security allow-set. - projects:open hoists effFolders() to a single call before the loop instead of re-evaluating it on every iteration. - effBaseDir comment clarifies it is legacy/back-compat only. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ipc): expose folders methods to the renderer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * i18n: scan-locations list strings (ko/en/ja/zh) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(renderer): scan-locations folder list with add/remove Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * polish(renderer): use Folder type, nowrap kind label, drop dead set.browse key Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * polish(ipc): error message reflects folder allow-set, not single base dir * test(guard): use OS-native paths in pathGuard test so it passes on POSIX CI Hardcoded Windows paths (C:\work) made resolve()/sep behave differently on ubuntu/macOS, failing the under-root assertion. Build paths via node:path so the test exercises the platform's real separator semantics. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Si Hyeong Lee <writingdeveloper@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 87a4c91 commit 9cd078b

18 files changed

Lines changed: 261 additions & 44 deletions

src/main/ipc.ts

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { ipcMain, dialog, shell, type BrowserWindow } from 'electron';
22
import { homedir } from 'node:os';
3-
import { join, resolve, sep } from 'node:path';
3+
import { join } from 'node:path';
4+
import { stat } from 'node:fs/promises';
45
import type { Store } from './store';
5-
import { scanRepos } from './scanner';
6+
import { scanFolders, isRepo } from './scanner';
67
import { getGitInfo } from './gitInfo';
78
import { getProvider, availableAgents } from './agents';
8-
import type { AgentId } from '../shared/types';
9+
import type { AgentId, Folder } from '../shared/types';
10+
import { isAllowedPath } from '../shared/pathGuard';
911
import { buildProjectList } from './projects';
1012
import { openProjects, openInEditor } from './launcher';
1113
import type { WtTab } from '../shared/wtArgs';
@@ -22,15 +24,14 @@ export interface IpcConfig {
2224
defaultLanguage: string;
2325
}
2426

25-
function isUnderBase(base: string, incoming: string): boolean {
26-
const r = resolve(incoming);
27-
const b = resolve(base);
28-
return r === b || r.startsWith(b + sep);
29-
}
30-
3127
export function registerIpc(cfg: IpcConfig): void {
28+
// Legacy single-base value, retained only for the settings:get response (back-compat); not used for scanning or the security guard.
3229
const effBaseDir = () => cfg.store.getBaseDir() ?? cfg.defaultBaseDir;
3330
const effThresholds = () => cfg.store.getThresholds() ?? DEFAULT_THRESHOLDS;
31+
const effFolders = (): Folder[] => {
32+
const f = cfg.store.getFolders();
33+
return f.length ? f : [{ path: cfg.defaultBaseDir, kind: 'root' }];
34+
};
3435

3536
const activeAgent = (): AgentId => {
3637
const a = cfg.store.getAgent();
@@ -40,10 +41,9 @@ export function registerIpc(cfg: IpcConfig): void {
4041

4142
ipcMain.handle('projects:list', async () => {
4243
return buildProjectList({
43-
baseDir: effBaseDir(),
4444
nowMs: Date.now(),
4545
thresholds: effThresholds(),
46-
scan: (base) => scanRepos(base),
46+
scan: () => scanFolders(effFolders()),
4747
git: (dir) => getGitInfo(dir),
4848
sessions: (p) => agent().listSessions(p),
4949
resumeCue: (p, sessionId) => agent().lastUserMessage(p, sessionId),
@@ -63,7 +63,7 @@ export function registerIpc(cfg: IpcConfig): void {
6363

6464
ipcMain.handle('usage:report', async (_e, sinceMs: number) => {
6565
const ms = (Number.isFinite(sinceMs) || sinceMs === Infinity) ? sinceMs : 0;
66-
const repos = await scanRepos(effBaseDir());
66+
const repos = await scanFolders(effFolders());
6767
return scanUsage(repos, CLAUDE_PROJECTS, ms);
6868
});
6969
ipcMain.handle('settings:getLanguage', () => cfg.store.getLanguage() ?? cfg.defaultLanguage);
@@ -78,6 +78,21 @@ export function registerIpc(cfg: IpcConfig): void {
7878
baseDir: effBaseDir(), thresholds: effThresholds(), language: cfg.store.getLanguage() ?? cfg.defaultLanguage,
7979
}));
8080
ipcMain.handle('settings:setBaseDir', (_e, dir: string) => cfg.store.setBaseDir(String(dir).slice(0, 2000)));
81+
ipcMain.handle('settings:getFolders', () => effFolders());
82+
ipcMain.handle('settings:addFolder', async (_e, p: string) => {
83+
const path = String(p).trim().slice(0, 2000);
84+
let isDir = false;
85+
try { isDir = (await stat(path)).isDirectory(); } catch { isDir = false; }
86+
if (isDir) {
87+
const kind: Folder['kind'] = (await isRepo(path)) ? 'repo' : 'root';
88+
cfg.store.addFolder({ path, kind });
89+
}
90+
return effFolders();
91+
});
92+
ipcMain.handle('settings:removeFolder', (_e, p: string) => {
93+
cfg.store.removeFolder(String(p).slice(0, 2000));
94+
return effFolders();
95+
});
8196
ipcMain.handle('settings:setThresholds', (_e, t: { freshDays: number; warnDays: number; neglectedDays: number }) => {
8297
const { freshDays, warnDays, neglectedDays } = t ?? {};
8398
if (
@@ -93,12 +108,12 @@ export function registerIpc(cfg: IpcConfig): void {
93108
});
94109

95110
ipcMain.handle('projects:open', (_e, items: { path: string; sessionId: string | null }[]) => {
96-
const base = effBaseDir();
97111
const now = new Date().toISOString();
112+
const folders = effFolders();
98113
const tabs: WtTab[] = [];
99114
for (const it of items) {
100-
if (!isUnderBase(base, it.path)) {
101-
cfg.sendError(`Path outside base dir: ${it.path}`);
115+
if (!isAllowedPath(folders, it.path)) {
116+
cfg.sendError(`Path outside allowed folders: ${it.path}`);
102117
continue;
103118
}
104119
const a = agent();
@@ -119,8 +134,8 @@ export function registerIpc(cfg: IpcConfig): void {
119134

120135
// Open the project folder in the OS file manager.
121136
ipcMain.handle('project:openFolder', async (_e, p: string) => {
122-
if (!isUnderBase(effBaseDir(), p)) {
123-
cfg.sendError(`Path outside base dir: ${p}`);
137+
if (!isAllowedPath(effFolders(), p)) {
138+
cfg.sendError(`Path outside allowed folders: ${p}`);
124139
return;
125140
}
126141
const err = await shell.openPath(p);
@@ -129,8 +144,8 @@ export function registerIpc(cfg: IpcConfig): void {
129144

130145
// Open the project in VS Code (`code <path>`).
131146
ipcMain.handle('project:openEditor', (_e, p: string) => {
132-
if (!isUnderBase(effBaseDir(), p)) {
133-
cfg.sendError(`Path outside base dir: ${p}`);
147+
if (!isAllowedPath(effFolders(), p)) {
148+
cfg.sendError(`Path outside allowed folders: ${p}`);
134149
return;
135150
}
136151
openInEditor(p, { onError: cfg.sendError });

src/main/projects.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ const DAY = 86_400_000;
88

99
function deps(over: Partial<BuildDeps>): BuildDeps {
1010
return {
11-
baseDir: 'C:\\g',
1211
nowMs: NOW,
1312
thresholds: DEFAULT_THRESHOLDS,
1413
scan: async () => [

src/main/projects.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@ import type { RawProject } from './scanner';
33
import { classifyStaleness } from '../shared/staleness';
44

55
export interface BuildDeps {
6-
baseDir: string;
76
nowMs: number;
87
thresholds: StaleThresholds;
9-
scan: (baseDir: string) => Promise<RawProject[]>;
8+
scan: () => Promise<RawProject[]>;
109
git: (dir: string) => Promise<GitInfo>;
1110
sessions: (projectPath: string) => SessionMeta[];
1211
resumeCue: (projectPath: string, sessionId: string) => string | null;
@@ -20,7 +19,7 @@ function maxMs(a: number | null, b: number | null): number | null {
2019
}
2120

2221
export async function buildProjectList(deps: BuildDeps): Promise<ProjectViewModel[]> {
23-
const raw = await deps.scan(deps.baseDir);
22+
const raw = await deps.scan();
2423
const models = await Promise.all(
2524
raw.map(async (r): Promise<ProjectViewModel> => {
2625
const git = await deps.git(r.path);

src/main/scanner.test.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
22
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
33
import { tmpdir } from 'node:os';
4-
import { join } from 'node:path';
5-
import { scanRepos } from './scanner';
4+
import { join, basename } from 'node:path';
5+
import { scanRepos, scanFolders } from './scanner';
66

77
let base: string;
88
beforeEach(() => {
@@ -40,3 +40,39 @@ describe('scanRepos', () => {
4040
expect(repos).toEqual(['projA', 'projB']);
4141
});
4242
});
43+
44+
describe('scanFolders', () => {
45+
it('merges multiple roots, includes a direct repo, and dedupes by path', async () => {
46+
// second root
47+
const base2 = mkdtempSync(join(tmpdir(), 'devdeck-scan2-'));
48+
mkdirSync(join(base2, 'projC', '.git'), { recursive: true });
49+
// a standalone repo dir to add directly
50+
const repoDir = mkdtempSync(join(tmpdir(), 'devdeck-repo-'));
51+
mkdirSync(join(repoDir, '.git'), { recursive: true });
52+
try {
53+
const out = await scanFolders([
54+
{ path: base, kind: 'root' }, // projA, projB (from beforeEach)
55+
{ path: base2, kind: 'root' }, // projC
56+
{ path: repoDir, kind: 'repo' }, // the standalone repo itself
57+
{ path: join(base, 'projA'), kind: 'repo' }, // duplicate of projA -> deduped
58+
]);
59+
expect(out.map((p) => p.name).sort()).toEqual(['projA', 'projB', 'projC', basename(repoDir)].sort());
60+
} finally {
61+
rmSync(base2, { recursive: true, force: true });
62+
rmSync(repoDir, { recursive: true, force: true });
63+
}
64+
});
65+
66+
it('skips a repo entry that has no .git, and a non-existent root', async () => {
67+
const noGit = mkdtempSync(join(tmpdir(), 'devdeck-nogit-'));
68+
try {
69+
const out = await scanFolders([
70+
{ path: noGit, kind: 'repo' }, // no .git -> skipped
71+
{ path: join(noGit, 'does-not-exist'), kind: 'root' }, // missing -> skipped
72+
]);
73+
expect(out).toEqual([]);
74+
} finally {
75+
rmSync(noGit, { recursive: true, force: true });
76+
}
77+
});
78+
});

src/main/scanner.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { readdir, access } from 'node:fs/promises';
2-
import { join } from 'node:path';
2+
import { join, basename, resolve } from 'node:path';
3+
import type { Folder } from '../shared/types';
34

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

@@ -8,7 +9,7 @@ export interface RawProject {
89
name: string;
910
}
1011

11-
async function isRepo(dir: string): Promise<boolean> {
12+
export async function isRepo(dir: string): Promise<boolean> {
1213
try {
1314
await access(join(dir, '.git'));
1415
return true;
@@ -44,3 +45,27 @@ export async function scanRepos(baseDir: string, maxDepth = 2): Promise<RawProje
4445
await walk(baseDir, 1, maxDepth, out);
4546
return out;
4647
}
48+
49+
function dedupeByResolvedPath(items: RawProject[]): RawProject[] {
50+
const seen = new Set<string>();
51+
const out: RawProject[] = [];
52+
for (const it of items) {
53+
const key = resolve(it.path);
54+
if (seen.has(key)) continue;
55+
seen.add(key);
56+
out.push(it);
57+
}
58+
return out;
59+
}
60+
61+
/** Scan every configured folder: roots walked depth-2, repos included directly; deduped by resolved path. */
62+
export async function scanFolders(folders: Folder[]): Promise<RawProject[]> {
63+
const chunks = await Promise.all(
64+
folders.map((f) =>
65+
f.kind === 'repo'
66+
? isRepo(f.path).then((ok) => (ok ? [{ path: f.path, name: basename(f.path) }] : []))
67+
: scanRepos(f.path),
68+
),
69+
);
70+
return dedupeByResolvedPath(chunks.flat());
71+
}

src/main/store.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,38 @@ describe('Store', () => {
6565
const re = new Store(file);
6666
expect(re.getAgent()).toBe('codex');
6767
});
68+
69+
it('round-trips folders: add, dedupe, remove', () => {
70+
const s = new Store(file);
71+
expect(s.getFolders()).toEqual([]);
72+
s.addFolder({ path: 'C:\\work', kind: 'root' });
73+
s.addFolder({ path: 'C:\\work', kind: 'root' }); // duplicate -> ignored
74+
s.addFolder({ path: 'E:\\spike', kind: 'repo' });
75+
const re = new Store(file);
76+
expect(re.getFolders()).toEqual([
77+
{ path: 'C:\\work', kind: 'root' },
78+
{ path: 'E:\\spike', kind: 'repo' },
79+
]);
80+
re.removeFolder('C:\\work');
81+
expect(new Store(file).getFolders()).toEqual([{ path: 'E:\\spike', kind: 'repo' }]);
82+
});
83+
84+
it('migrates a legacy baseDir into a single root folder', () => {
85+
const s = new Store(file);
86+
s.setBaseDir('C:\\repos');
87+
expect(s.getFolders()).toEqual([{ path: 'C:\\repos', kind: 'root' }]);
88+
// first explicit add persists the migrated entry plus the new one
89+
s.addFolder({ path: 'D:\\more', kind: 'root' });
90+
expect(new Store(file).getFolders()).toEqual([
91+
{ path: 'C:\\repos', kind: 'root' },
92+
{ path: 'D:\\more', kind: 'root' },
93+
]);
94+
});
95+
96+
it('keeps a migrated baseDir folder removed after reload', () => {
97+
const s = new Store(file);
98+
s.setBaseDir('C:\\repos'); // legacy single base, no explicit folders
99+
s.removeFolder('C:\\repos'); // remove the migrated entry
100+
expect(new Store(file).getFolders()).toEqual([]); // must stay empty after reload
101+
});
68102
});

src/main/store.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs';
2-
import type { StoreEntry } from '../shared/types';
2+
import { resolve } from 'node:path';
3+
import type { StoreEntry, Folder } from '../shared/types';
34

45
interface StateFile {
56
projects: Record<string, StoreEntry>;
6-
settings?: { language?: string; baseDir?: string; thresholds?: { freshDays: number; warnDays: number; neglectedDays: number }; agent?: string };
7+
settings?: { language?: string; baseDir?: string; folders?: Folder[]; thresholds?: { freshDays: number; warnDays: number; neglectedDays: number }; agent?: string };
78
}
89

910
const EMPTY: StoreEntry = {
@@ -56,6 +57,27 @@ export class Store {
5657

5758
getBaseDir(): string | null { return this.state.settings?.baseDir ?? null; }
5859
setBaseDir(baseDir: string): void { this.state.settings = { ...(this.state.settings ?? {}), baseDir }; this.save(); }
60+
61+
getFolders(): Folder[] {
62+
const f = this.state.settings?.folders;
63+
if (f !== undefined) return [...f];
64+
const b = this.state.settings?.baseDir;
65+
return b ? [{ path: b, kind: 'root' }] : [];
66+
}
67+
addFolder(folder: Folder): void {
68+
const cur = this.state.settings?.folders ?? this.getFolders();
69+
const exists = cur.some((x) => resolve(x.path) === resolve(folder.path));
70+
const folders = exists ? cur : [...cur, folder];
71+
this.state.settings = { ...(this.state.settings ?? {}), folders };
72+
this.save();
73+
}
74+
removeFolder(path: string): void {
75+
const cur = this.state.settings?.folders ?? this.getFolders();
76+
const folders = cur.filter((x) => resolve(x.path) !== resolve(path));
77+
this.state.settings = { ...(this.state.settings ?? {}), folders };
78+
this.save();
79+
}
80+
5981
getThresholds(): { freshDays: number; warnDays: number; neglectedDays: number } | null { return this.state.settings?.thresholds ?? null; }
6082
setThresholds(thresholds: { freshDays: number; warnDays: number; neglectedDays: number }): void { this.state.settings = { ...(this.state.settings ?? {}), thresholds }; this.save(); }
6183

src/preload/preload.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ contextBridge.exposeInMainWorld('devdeck', {
1616
availableAgents: () => ipcRenderer.invoke('settings:availableAgents'),
1717
getSettings: () => ipcRenderer.invoke('settings:get'),
1818
setBaseDir: (dir: string) => ipcRenderer.invoke('settings:setBaseDir', dir),
19+
getFolders: () => ipcRenderer.invoke('settings:getFolders'),
20+
addFolder: (path: string) => ipcRenderer.invoke('settings:addFolder', path),
21+
removeFolder: (path: string) => ipcRenderer.invoke('settings:removeFolder', path),
1922
setThresholds: (t: { freshDays: number; warnDays: number; neglectedDays: number }) => ipcRenderer.invoke('settings:setThresholds', t),
2023
pickFolder: () => ipcRenderer.invoke('settings:pickFolder'),
2124
openFolder: (path: string) => ipcRenderer.invoke('project:openFolder', path),

src/renderer/global.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ declare global {
1717
availableAgents(): Promise<import('../shared/types').AgentId[]>;
1818
getSettings(): Promise<{ baseDir: string; thresholds: { freshDays: number; warnDays: number; neglectedDays: number }; language: string }>;
1919
setBaseDir(dir: string): Promise<void>;
20+
getFolders(): Promise<import('../shared/types').Folder[]>;
21+
addFolder(path: string): Promise<import('../shared/types').Folder[]>;
22+
removeFolder(path: string): Promise<import('../shared/types').Folder[]>;
2023
setThresholds(t: { freshDays: number; warnDays: number; neglectedDays: number }): Promise<void>;
2124
pickFolder(): Promise<string | null>;
2225
openFolder(path: string): Promise<void>;

src/renderer/locales/en.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,11 @@
4848
"usage.col_project": "Project",
4949
"usage.col_cost": "Est. cost",
5050
"usage.disclaimer": "* Some models have no price card — excluded from cost. Cost is a public-rate API-equivalent estimate and may differ from your (subscription) bill.",
51-
"set.scan_dir": "Scan folder",
52-
"set.browse": "Browse…",
51+
"set.scan_locations": "Scan locations",
52+
"set.kind_root": "scans for repos",
53+
"set.kind_repo": "single repo",
54+
"set.add_folder": "+ Add folder…",
55+
"set.remove_folder": "Remove",
5356
"set.thresholds": "Staleness thresholds (days)",
5457
"set.fresh": "Fresh",
5558
"set.warn": "Warn",

0 commit comments

Comments
 (0)