Skip to content

Commit d36666e

Browse files
authored
feat(dashboard): add project removal action (#1436)
* feat(dashboard): add project from dashboard * feat(dashboard): remove project from card menu
1 parent 222b974 commit d36666e

9 files changed

Lines changed: 1003 additions & 90 deletions

File tree

apps/cli/src/commands/results/serve.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
*/
3636

3737
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
38+
import { homedir } from 'node:os';
3839
import path from 'node:path';
3940
import { fileURLToPath } from 'node:url';
4041
import { command, flag, number, option, optional, positional, string } from 'cmd-ts';
@@ -1282,6 +1283,120 @@ function handleFeedbackRead(c: C, { searchDir }: DataContext) {
12821283
return c.json(readFeedback(existsSync(resultsDir) ? resultsDir : searchDir));
12831284
}
12841285

1286+
function expandHomePath(inputPath: string): string {
1287+
if (inputPath === '~') return homedir();
1288+
if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
1289+
return path.join(homedir(), inputPath.slice(2));
1290+
}
1291+
return inputPath;
1292+
}
1293+
1294+
function resolveBrowsePath(inputPath: string | undefined, cwd: string): string {
1295+
const trimmed = inputPath?.trim() ?? '';
1296+
const expanded = trimmed.length > 0 ? expandHomePath(trimmed) : cwd;
1297+
return path.resolve(cwd, expanded);
1298+
}
1299+
1300+
function hasAgentvDir(dirPath: string): boolean {
1301+
try {
1302+
return statSync(path.join(dirPath, '.agentv')).isDirectory();
1303+
} catch {
1304+
return false;
1305+
}
1306+
}
1307+
1308+
interface DirectoryBrowseEntry {
1309+
name: string;
1310+
path: string;
1311+
hasAgentv: boolean;
1312+
}
1313+
1314+
interface DirectoryBrowseResult {
1315+
path: string;
1316+
parentPath?: string;
1317+
current: DirectoryBrowseEntry;
1318+
entries: DirectoryBrowseEntry[];
1319+
}
1320+
1321+
function directoryBrowseEntry(dirPath: string): DirectoryBrowseEntry {
1322+
return {
1323+
name: path.basename(dirPath) || dirPath,
1324+
path: dirPath,
1325+
hasAgentv: hasAgentvDir(dirPath),
1326+
};
1327+
}
1328+
1329+
function browseFilesystemDirectories(
1330+
inputPath: string | undefined,
1331+
cwd: string,
1332+
): DirectoryBrowseResult {
1333+
const browsePath = resolveBrowsePath(inputPath, cwd);
1334+
1335+
if (!existsSync(browsePath)) {
1336+
throw new Error(`Directory not found: ${browsePath}`);
1337+
}
1338+
1339+
let stats: ReturnType<typeof statSync>;
1340+
try {
1341+
stats = statSync(browsePath);
1342+
} catch (err) {
1343+
throw new Error(`Unable to read directory: ${(err as Error).message}`);
1344+
}
1345+
1346+
if (!stats.isDirectory()) {
1347+
throw new Error(`Not a directory: ${browsePath}`);
1348+
}
1349+
1350+
let entries: DirectoryBrowseEntry[];
1351+
try {
1352+
entries = readdirSync(browsePath, { withFileTypes: true })
1353+
.map((entry) => {
1354+
const entryPath = path.join(browsePath, entry.name);
1355+
if (entry.isDirectory()) return directoryBrowseEntry(entryPath);
1356+
if (entry.isSymbolicLink()) {
1357+
try {
1358+
return statSync(entryPath).isDirectory() ? directoryBrowseEntry(entryPath) : null;
1359+
} catch {
1360+
return null;
1361+
}
1362+
}
1363+
return null;
1364+
})
1365+
.filter((entry): entry is ReturnType<typeof directoryBrowseEntry> => entry !== null)
1366+
.sort((a, b) => {
1367+
if (a.hasAgentv !== b.hasAgentv) return a.hasAgentv ? -1 : 1;
1368+
return a.name.localeCompare(b.name);
1369+
});
1370+
} catch (err) {
1371+
throw new Error(`Unable to read directory: ${(err as Error).message}`);
1372+
}
1373+
1374+
const parentPath = path.dirname(browsePath);
1375+
return {
1376+
path: browsePath,
1377+
parentPath: parentPath !== browsePath ? parentPath : undefined,
1378+
current: directoryBrowseEntry(browsePath),
1379+
entries,
1380+
};
1381+
}
1382+
1383+
function directoryBrowseEntryToWire(entry: DirectoryBrowseEntry) {
1384+
return {
1385+
name: entry.name,
1386+
path: entry.path,
1387+
has_agentv: entry.hasAgentv,
1388+
};
1389+
}
1390+
1391+
function directoryBrowseResultToWire(result: DirectoryBrowseResult) {
1392+
return {
1393+
path: result.path,
1394+
...(result.parentPath !== undefined && { parent_path: result.parentPath }),
1395+
current: directoryBrowseEntryToWire(result.current),
1396+
entries: result.entries.map(directoryBrowseEntryToWire),
1397+
};
1398+
}
1399+
12851400
async function handleRunTagsPut(c: C, { searchDir, projectId }: DataContext) {
12861401
const filename = c.req.param('filename') ?? '';
12871402
const meta = await findRunById(searchDir, filename, projectId);
@@ -1603,6 +1718,16 @@ export function createApp(
16031718
return c.json({ projects });
16041719
});
16051720

1721+
app.get('/api/filesystem/browse', (c) => {
1722+
try {
1723+
return c.json(
1724+
directoryBrowseResultToWire(browseFilesystemDirectories(c.req.query('path'), searchDir)),
1725+
);
1726+
} catch (err) {
1727+
return c.json({ error: (err as Error).message }, 400);
1728+
}
1729+
});
1730+
16061731
app.post('/api/projects', async (c) => {
16071732
if (readOnly) {
16081733
return c.json({ error: 'Dashboard is running in read-only mode' }, 403);

apps/cli/test/commands/results/serve.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,138 @@ describe('serve app', () => {
495495
});
496496
});
497497

498+
// ── GET /api/filesystem/browse ────────────────────────────────────────
499+
500+
describe('GET /api/filesystem/browse', () => {
501+
it('lists child directories and marks AgentV project folders', async () => {
502+
const projectDir = path.join(tempDir, 'project-with-agentv');
503+
const bareDir = path.join(tempDir, 'plain-folder');
504+
mkdirSync(path.join(projectDir, '.agentv'), { recursive: true });
505+
mkdirSync(bareDir, { recursive: true });
506+
writeFileSync(path.join(tempDir, 'not-a-folder.txt'), 'ignored');
507+
508+
const app = makeApp();
509+
const res = await app.request(`/api/filesystem/browse?path=${encodeURIComponent(tempDir)}`);
510+
511+
expect(res.status).toBe(200);
512+
const data = (await res.json()) as {
513+
path: string;
514+
parent_path?: string;
515+
current: { name: string; path: string; has_agentv: boolean };
516+
entries: Array<{ name: string; path: string; has_agentv: boolean }>;
517+
};
518+
expect(data.path).toBe(tempDir);
519+
expect(data.parent_path).toBe(path.dirname(tempDir));
520+
expect(data.current).toMatchObject({
521+
name: path.basename(tempDir),
522+
path: tempDir,
523+
has_agentv: false,
524+
});
525+
expect(data.entries).toEqual([
526+
{ name: 'project-with-agentv', path: projectDir, has_agentv: true },
527+
{ name: 'plain-folder', path: bareDir, has_agentv: false },
528+
{ name: 'studio-dist', path: studioDir, has_agentv: false },
529+
]);
530+
});
531+
532+
it('returns an understandable error for non-directory paths', async () => {
533+
const filePath = path.join(tempDir, 'not-a-folder.txt');
534+
writeFileSync(filePath, 'not a directory');
535+
536+
const app = makeApp();
537+
const res = await app.request(`/api/filesystem/browse?path=${encodeURIComponent(filePath)}`);
538+
539+
expect(res.status).toBe(400);
540+
const data = (await res.json()) as { error: string };
541+
expect(data.error).toContain('Not a directory');
542+
expect(data.error).toContain(filePath);
543+
});
544+
});
545+
546+
// ── POST /api/projects ────────────────────────────────────────────────
547+
548+
describe('POST /api/projects', () => {
549+
it('registers a selected AgentV project directory', async () => {
550+
const previousHome = process.env.AGENTV_HOME;
551+
process.env.AGENTV_HOME = path.join(tempDir, 'agentv-home-register');
552+
553+
try {
554+
const projectDir = path.join(tempDir, 'project-to-register');
555+
mkdirSync(path.join(projectDir, '.agentv'), { recursive: true });
556+
557+
const app = makeApp();
558+
const create = await app.request('/api/projects', {
559+
method: 'POST',
560+
headers: { 'Content-Type': 'application/json' },
561+
body: JSON.stringify({ path: projectDir }),
562+
});
563+
564+
expect(create.status).toBe(201);
565+
const created = (await create.json()) as {
566+
id: string;
567+
name: string;
568+
path: string;
569+
added_at: string;
570+
last_opened_at: string;
571+
};
572+
expect(created).toMatchObject({
573+
id: 'project-to-register',
574+
name: 'project-to-register',
575+
path: projectDir,
576+
});
577+
expect(created.added_at).toBeTruthy();
578+
expect(created.last_opened_at).toBeTruthy();
579+
580+
const list = await app.request('/api/projects');
581+
const data = (await list.json()) as { projects: Array<{ id: string; path: string }> };
582+
expect(data.projects).toEqual([
583+
expect.objectContaining({ id: 'project-to-register', path: projectDir }),
584+
]);
585+
} finally {
586+
if (previousHome === undefined) {
587+
process.env.AGENTV_HOME = undefined;
588+
} else {
589+
process.env.AGENTV_HOME = previousHome;
590+
}
591+
}
592+
});
593+
});
594+
595+
// ── DELETE /api/projects/:projectId ───────────────────────────────────
596+
597+
describe('DELETE /api/projects/:projectId', () => {
598+
it('unregisters a project without deleting its directory', async () => {
599+
const previousHome = process.env.AGENTV_HOME;
600+
process.env.AGENTV_HOME = path.join(tempDir, 'agentv-home-remove');
601+
602+
try {
603+
const projectDir = path.join(tempDir, 'project-to-remove');
604+
mkdirSync(path.join(projectDir, '.agentv'), { recursive: true });
605+
const entry = addProject(projectDir);
606+
607+
const app = makeApp();
608+
const remove = await app.request(`/api/projects/${encodeURIComponent(entry.id)}`, {
609+
method: 'DELETE',
610+
});
611+
612+
expect(remove.status).toBe(200);
613+
expect(await remove.json()).toEqual({ ok: true });
614+
expect(existsSync(projectDir)).toBe(true);
615+
expect(existsSync(path.join(projectDir, '.agentv'))).toBe(true);
616+
617+
const list = await app.request('/api/projects');
618+
const data = (await list.json()) as { projects: Array<{ id: string }> };
619+
expect(data.projects).toEqual([]);
620+
} finally {
621+
if (previousHome === undefined) {
622+
process.env.AGENTV_HOME = undefined;
623+
} else {
624+
process.env.AGENTV_HOME = previousHome;
625+
}
626+
}
627+
});
628+
});
629+
498630
// ── GET /api/feedback ──────────────────────────────────────────────────
499631

500632
describe('GET /api/feedback', () => {

0 commit comments

Comments
 (0)