Skip to content

Commit 514737b

Browse files
committed
feat(ui): add PathSelector component with project directory autocomplete
Replace hardcoded symlink candidate list with a generic directory browser backed by a new ListProjectEntries IPC call. The component provides autocomplete with Tab navigation into subdirectories.
1 parent 52c3be8 commit 514737b

5 files changed

Lines changed: 353 additions & 74 deletions

File tree

electron/ipc/channels.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export enum IPC {
2121
GetAllFileDiffsFromBranch = 'get_all_file_diffs_from_branch',
2222
GetFileDiff = 'get_file_diff',
2323
GetFileDiffFromBranch = 'get_file_diff_from_branch',
24-
GetGitignoredDirs = 'get_gitignored_dirs',
24+
ListProjectEntries = 'list_project_entries',
2525
GetWorktreeStatus = 'get_worktree_status',
2626
CheckMergeStatus = 'check_merge_status',
2727
MergeTask = 'merge_task',
@@ -82,6 +82,9 @@ export enum IPC {
8282
PlanContent = 'plan_content',
8383
ReadPlanContent = 'read_plan_content',
8484

85+
// Setup
86+
RunSetupCommands = 'run_setup_commands',
87+
8588
// Ask about code
8689
AskAboutCode = 'ask_about_code',
8790
CancelAskAboutCode = 'cancel_ask_about_code',

electron/ipc/git.ts

Lines changed: 24 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -64,23 +64,6 @@ function withWorktreeLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
6464
return next;
6565
}
6666

67-
// --- Symlink candidates ---
68-
69-
const SYMLINK_CANDIDATES = [
70-
'.claude',
71-
'.cursor',
72-
'.aider',
73-
'.copilot',
74-
'.codeium',
75-
'.continue',
76-
'.windsurf',
77-
'.env',
78-
'node_modules',
79-
];
80-
81-
/** Entries inside `.claude` that must NOT be symlinked (kept per-worktree). */
82-
const CLAUDE_DIR_EXCLUDE = new Set(['plans', 'settings.local.json']);
83-
8467
// --- Internal helpers ---
8568

8669
async function detectMainBranch(repoRoot: string): Promise<string> {
@@ -303,33 +286,6 @@ async function computeBranchDiffStats(
303286
return { linesAdded, linesRemoved };
304287
}
305288

306-
/**
307-
* "Shallow-symlink" a directory: create a real directory at `target` and
308-
* symlink each entry from `source` into it, EXCEPT entries in `exclude`.
309-
*/
310-
function shallowSymlinkDir(source: string, target: string, exclude: Set<string>): void {
311-
fs.mkdirSync(target, { recursive: true });
312-
let entries: fs.Dirent[];
313-
try {
314-
entries = fs.readdirSync(source, { withFileTypes: true });
315-
} catch (err) {
316-
console.warn(`Failed to read directory ${source} for shallow-symlink:`, err);
317-
return;
318-
}
319-
for (const entry of entries) {
320-
if (exclude.has(entry.name)) continue;
321-
const src = path.join(source, entry.name);
322-
const dst = path.join(target, entry.name);
323-
try {
324-
if (!fs.existsSync(dst)) {
325-
fs.symlinkSync(src, dst);
326-
}
327-
} catch {
328-
/* ignore */
329-
}
330-
}
331-
}
332-
333289
// --- Public functions (used by tasks.ts and register.ts) ---
334290

335291
export async function createWorktree(
@@ -365,21 +321,19 @@ export async function createWorktree(
365321
await exec('git', ['worktree', 'add', '-b', branchName, worktreePath], { cwd: repoRoot });
366322

367323
// Symlink selected directories
324+
const resolvedRoot = path.resolve(repoRoot) + path.sep;
325+
const resolvedWorktree = path.resolve(worktreePath) + path.sep;
368326
for (const name of symlinkDirs) {
369-
// Reject names that could escape the worktree directory
370-
if (name.includes('/') || name.includes('\\') || name.includes('..') || name === '.') continue;
327+
if (name.includes('..')) continue;
371328
const source = path.join(repoRoot, name);
372329
const target = path.join(worktreePath, name);
330+
if (!path.resolve(source).startsWith(resolvedRoot)) continue;
331+
if (!path.resolve(target).startsWith(resolvedWorktree)) continue;
373332
try {
374333
if (!fs.existsSync(source)) continue;
375334
if (fs.existsSync(target)) continue;
376-
377-
if (name === '.claude') {
378-
// Shallow-symlink: real dir with per-entry symlinks, excluding per-worktree entries
379-
shallowSymlinkDir(source, target, CLAUDE_DIR_EXCLUDE);
380-
} else {
381-
fs.symlinkSync(source, target);
382-
}
335+
fs.mkdirSync(path.dirname(target), { recursive: true });
336+
fs.symlinkSync(source, target);
383337
} catch {
384338
/* ignore */
385339
}
@@ -425,23 +379,24 @@ export async function removeWorktree(
425379

426380
// --- IPC command functions ---
427381

428-
export async function getGitIgnoredDirs(projectRoot: string): Promise<string[]> {
429-
const results: string[] = [];
430-
for (const name of SYMLINK_CANDIDATES) {
431-
const dirPath = path.join(projectRoot, name);
432-
try {
433-
fs.statSync(dirPath); // throws if entry doesn't exist
434-
} catch {
435-
continue;
436-
}
437-
try {
438-
await exec('git', ['check-ignore', '-q', name], { cwd: projectRoot });
439-
results.push(name);
440-
} catch {
441-
/* not ignored */
442-
}
382+
export function listProjectEntries(
383+
projectRoot: string,
384+
subpath?: string,
385+
): { name: string; isDir: boolean }[] {
386+
const HIDDEN = new Set(['.git', '.worktrees']);
387+
const dir = subpath ? path.join(projectRoot, subpath) : projectRoot;
388+
// Prevent traversal outside project root
389+
const resolvedDir = path.resolve(dir);
390+
const resolvedRoot = path.resolve(projectRoot);
391+
if (resolvedDir !== resolvedRoot && !resolvedDir.startsWith(resolvedRoot + path.sep)) return [];
392+
try {
393+
return fs
394+
.readdirSync(dir, { withFileTypes: true })
395+
.filter((e) => !HIDDEN.has(e.name))
396+
.map((e) => ({ name: e.name, isDir: e.isDirectory() }));
397+
} catch {
398+
return [];
443399
}
444-
return results;
445400
}
446401

447402
export async function getMainBranch(projectRoot: string): Promise<string> {

electron/ipc/register.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
import { ensurePlansDirectory, startPlanWatcher, readPlanForWorktree } from './plans.js';
1717
import { startRemoteServer } from '../remote/server.js';
1818
import {
19-
getGitIgnoredDirs,
19+
listProjectEntries,
2020
getMainBranch,
2121
getCurrentBranch,
2222
getChangedFiles,
@@ -41,6 +41,7 @@ import { listAgents } from './agents.js';
4141
import { saveAppState, loadAppState } from './persistence.js';
4242
import { spawn } from 'child_process';
4343
import { askAboutCode, cancelAskAboutCode } from './ask-code.js';
44+
import { runSetupCommands } from './setup.js';
4445
import path from 'path';
4546
import {
4647
assertString,
@@ -173,9 +174,9 @@ export function registerAllHandlers(win: BrowserWindow): void {
173174
validateRelativePath(args.filePath, 'filePath');
174175
return getFileDiffFromBranch(args.projectRoot, args.branchName, args.filePath);
175176
});
176-
ipcMain.handle(IPC.GetGitignoredDirs, (_e, args) => {
177+
ipcMain.handle(IPC.ListProjectEntries, (_e, args) => {
177178
validatePath(args.projectRoot, 'projectRoot');
178-
return getGitIgnoredDirs(args.projectRoot);
179+
return listProjectEntries(args.projectRoot, args.subpath);
179180
});
180181
ipcMain.handle(IPC.GetWorktreeStatus, (_e, args) => {
181182
validatePath(args.worktreePath, 'worktreePath');
@@ -304,6 +305,20 @@ export function registerAllHandlers(win: BrowserWindow): void {
304305
return readPlanForWorktree(args.worktreePath, fileName);
305306
});
306307

308+
// --- Setup commands ---
309+
ipcMain.handle(IPC.RunSetupCommands, (_e, args) => {
310+
validatePath(args.worktreePath, 'worktreePath');
311+
validatePath(args.projectRoot, 'projectRoot');
312+
assertStringArray(args.commands, 'commands');
313+
assertString(args.onOutput?.__CHANNEL_ID__, 'channelId');
314+
return runSetupCommands(win, {
315+
worktreePath: args.worktreePath,
316+
projectRoot: args.projectRoot,
317+
commands: args.commands,
318+
channelId: args.onOutput.__CHANNEL_ID__,
319+
});
320+
});
321+
307322
// --- Ask about code ---
308323
ipcMain.handle(IPC.AskAboutCode, (_e, args) => {
309324
assertString(args.requestId, 'requestId');

electron/preload.cjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const ALLOWED_CHANNELS = new Set([
2424
'get_file_diff_from_branch',
2525
'get_all_file_diffs',
2626
'get_all_file_diffs_from_branch',
27-
'get_gitignored_dirs',
27+
'list_project_entries',
2828
'get_worktree_status',
2929
'commit_all',
3030
'discard_uncommitted',
@@ -76,6 +76,8 @@ const ALLOWED_CHANNELS = new Set([
7676
'get_remote_status',
7777
// Plan
7878
'plan_content',
79+
// Setup
80+
'run_setup_commands',
7981
// Ask about code
8082
'ask_about_code',
8183
'cancel_ask_about_code',

0 commit comments

Comments
 (0)