diff --git a/desktop/src/api/projects.test.ts b/desktop/src/api/projects.test.ts new file mode 100644 index 00000000..2d859a8a --- /dev/null +++ b/desktop/src/api/projects.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import '@testing-library/jest-dom' + +const apiPostMock = vi.fn() + +vi.mock('./client', () => ({ + api: { + post: (url: string, body?: unknown) => apiPostMock(url, body), + get: vi.fn(), + }, +})) + +import { projectsApi } from './projects' + +beforeEach(() => { + apiPostMock.mockReset() +}) + +describe('projectsApi.clearSessions', () => { + it('posts to /api/projects/sessions/clear with workDir in body', async () => { + apiPostMock.mockResolvedValue({ ok: true, deletedSessions: 3, projectDirRemoved: true }) + + const result = await projectsApi.clearSessions('/work/alpha') + + expect(apiPostMock).toHaveBeenCalledTimes(1) + expect(apiPostMock).toHaveBeenCalledWith('/api/projects/sessions/clear', { workDir: '/work/alpha' }) + expect(result.deletedSessions).toBe(3) + expect(result.projectDirRemoved).toBe(true) + }) + + it('propagates server errors so the caller can show a toast', async () => { + apiPostMock.mockRejectedValue(new Error('Forbidden')) + await expect(projectsApi.clearSessions('/work/beta')).rejects.toThrow('Forbidden') + }) +}) diff --git a/desktop/src/api/projects.ts b/desktop/src/api/projects.ts index 0a5c70c2..aaf8d032 100644 --- a/desktop/src/api/projects.ts +++ b/desktop/src/api/projects.ts @@ -121,4 +121,18 @@ export const projectsApi = { return null } }, + + /** + * Permanently delete every .jsonl session file under the given project. + * Pass an absolute workDir (the desktop sidebar's project key) — the + * backend sanitizes it. If the project directory becomes empty, it is + * removed so the project drops off the sidebar entirely. Non-.jsonl files + * (memory/, user notes) are preserved. + */ + clearSessions(workDir: string): Promise<{ ok: true; deletedSessions: number; projectDirRemoved: boolean }> { + return api.post<{ ok: true; deletedSessions: number; projectDirRemoved: boolean }>( + '/api/projects/sessions/clear', + { workDir }, + ) + }, } diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 7e8d2057..27eacab8 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useCallback, useMemo, useRef } from 'react' -import { Folder, FolderOpen, MoreHorizontal, Pin, PinOff, RefreshCw, RotateCcw, SquarePen, X } from 'lucide-react' +import { Folder, FolderOpen, MoreHorizontal, Pin, PinOff, RefreshCw, RotateCcw, SquarePen, Trash2, X } from 'lucide-react' import { useSessionStore } from '../../stores/sessionStore' import { useUIStore } from '../../stores/uiStore' import { useTranslation } from '../../i18n' @@ -39,6 +39,7 @@ import { positionProjectMenu, isDocumentVisible, } from './sidebarUtils' +import { projectsApi } from '../../api/projects' import { GitHubIcon, PlusIcon, @@ -98,6 +99,8 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { const [pendingDeleteSessionId, setPendingDeleteSessionId] = useState(null) const [pendingBatchDeleteSessionIds, setPendingBatchDeleteSessionIds] = useState(null) const [isBatchDeleting, setIsBatchDeleting] = useState(false) + const [pendingClearProjectKey, setPendingClearProjectKey] = useState(null) + const [isClearingProject, setIsClearingProject] = useState(false) const [renamingId, setRenamingId] = useState(null) const [renameValue, setRenameValue] = useState('') const [lastSelectedSessionId, setLastSelectedSessionId] = useState(null) @@ -448,6 +451,61 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { } }, [addToast, hiddenProjectKeys, persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectOrganization, projectSortBy, t]) + const requestClearProjectSessions = useCallback((project: ProjectGroup) => { + setProjectContextMenu(null) + setPendingClearProjectKey(project.key) + }, []) + + const confirmClearProjectSessions = useCallback(async () => { + if (!pendingClearProjectKey) return + const project = orderedProjectGroups.find((g) => g.key === pendingClearProjectKey) + if (!project) { + setPendingClearProjectKey(null) + return + } + const workDir = project.workDir + if (!workDir) { + addToast({ + type: 'error', + message: t('sidebar.clearProjectSessionsNoWorkDir', { project: project.title }), + }) + setPendingClearProjectKey(null) + return + } + setIsClearingProject(true) + try { + // Disconnect any open sessions for this project so the websocket + // server-side handles don't keep referencing files we're about to + // delete. closeTab handles missing tabs gracefully. + for (const session of project.sessions) { + try { closeTab(session.id) } catch { /* ignore */ } + try { disconnectSession(session.id) } catch { /* ignore */ } + } + const result = await projectsApi.clearSessions(workDir) + // Re-fetch sessions so the project either disappears (if dir removed) + // or shows up empty (preserved memory/ etc.). + await fetchSessions() + addToast({ + type: 'success', + message: t('sidebar.clearProjectSessionsSuccess', { + project: project.title, + count: result.deletedSessions, + }), + }) + } catch (error) { + addToast({ + type: 'error', + message: t('sidebar.clearProjectSessionsFailure', { + project: project.title, + error: error instanceof Error ? error.message : String(error), + }), + }) + } finally { + setIsClearingProject(false) + setPendingClearProjectKey(null) + } + }, [addToast, closeTab, disconnectSession, fetchSessions, orderedProjectGroups, pendingClearProjectKey, t]) + const openProjectInFinder = useCallback(async (project: ProjectGroup) => { setProjectContextMenu(null) try { @@ -1149,6 +1207,13 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { > {t(hidden ? 'sidebar.restoreProjectToSidebar' : 'sidebar.hideProjectFromSidebar')} + ) })()} @@ -1235,6 +1300,32 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { confirmVariant="danger" loading={isBatchDeleting} /> + { + if (!isClearingProject) setPendingClearProjectKey(null) + }} + onConfirm={confirmClearProjectSessions} + title={t('sidebar.clearProjectSessions')} + body={(() => { + const project = pendingClearProjectKey + ? orderedProjectGroups.find((g) => g.key === pendingClearProjectKey) + : null + if (!project) return '' + return ( +

+ {t('sidebar.clearProjectSessionsConfirm', { + project: project.title, + count: project.sessions.length, + })} +

+ ) + })()} + confirmLabel={t('common.delete')} + cancelLabel={t('common.cancel')} + confirmVariant="danger" + loading={isClearingProject} + /> ) } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 8f39f7ff..df67e580 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -53,6 +53,11 @@ export const en = { 'sidebar.restoreProjectToSidebar': 'Restore to Sidebar', 'sidebar.restoreHiddenProjects': 'Restore hidden projects ({count})', 'sidebar.projectHidden': '{project} was hidden from the sidebar. Existing sessions were not deleted.', + 'sidebar.clearProjectSessions': 'Delete all sessions', + 'sidebar.clearProjectSessionsConfirm': 'Permanently delete all {count} sessions in {project}? The project will be removed from the sidebar. This cannot be undone.', + 'sidebar.clearProjectSessionsSuccess': 'Deleted {count} sessions from {project}.', + 'sidebar.clearProjectSessionsFailure': 'Failed to delete sessions from {project}: {error}', + 'sidebar.clearProjectSessionsNoWorkDir': 'Cannot delete sessions for {project}: working directory unknown.', 'sidebar.newSessionInProject': 'New session in {project}', 'sidebar.showMoreSessions': 'Expand display', 'sidebar.showFewerSessions': 'Collapse display', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index d12473b1..bc883d01 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -55,6 +55,11 @@ export const jp: Record = { 'sidebar.restoreProjectToSidebar': 'サイドバーに復元', 'sidebar.restoreHiddenProjects': '非表示のプロジェクトを復元 ({count})', 'sidebar.projectHidden': '{project} をサイドバーから非表示にしました。既存のセッションは削除されていません。', + 'sidebar.clearProjectSessions': 'すべてのセッションを削除', + 'sidebar.clearProjectSessionsConfirm': '{project} のすべての {count} 個のセッションを完全に削除しますか?プロジェクトはサイドバーから消えます。この操作は取り消せません。', + 'sidebar.clearProjectSessionsSuccess': '{project} から {count} 個のセッションを削除しました。', + 'sidebar.clearProjectSessionsFailure': '{project} のセッション削除に失敗しました:{error}', + 'sidebar.clearProjectSessionsNoWorkDir': '{project} のセッションを削除できません:作業ディレクトリが不明です。', 'sidebar.newSessionInProject': '{project} で新しいセッション', 'sidebar.showMoreSessions': '表示を展開', 'sidebar.showFewerSessions': '表示を折りたたむ', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 2be43940..1340c3d9 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -55,6 +55,11 @@ export const kr: Record = { 'sidebar.restoreProjectToSidebar': '사이드바로 복원', 'sidebar.restoreHiddenProjects': '숨긴 프로젝트 복원 ({count})', 'sidebar.projectHidden': '{project}을(를) 사이드바에서 숨겼습니다. 기존 세션은 삭제되지 않았습니다.', + 'sidebar.clearProjectSessions': '모든 세션 삭제', + 'sidebar.clearProjectSessionsConfirm': '{project}의 모든 {count}개 세션을 영구적으로 삭제하시겠습니까? 프로젝트가 사이드바에서 제거됩니다. 이 작업은 되돌릴 수 없습니다.', + 'sidebar.clearProjectSessionsSuccess': '{project}에서 {count}개 세션을 삭제했습니다.', + 'sidebar.clearProjectSessionsFailure': '{project}의 세션 삭제 실패: {error}', + 'sidebar.clearProjectSessionsNoWorkDir': '{project}의 세션을 삭제할 수 없습니다: 작업 디렉토리를 알 수 없습니다.', 'sidebar.newSessionInProject': '{project}에서 새 세션', 'sidebar.showMoreSessions': '펼쳐서 표시', 'sidebar.showFewerSessions': '접어서 표시', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index c0158aaf..f9b012b6 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -55,6 +55,11 @@ export const zh: Record = { 'sidebar.restoreProjectToSidebar': '恢復到側邊欄', 'sidebar.restoreHiddenProjects': '恢復隱藏專案({count})', 'sidebar.projectHidden': '已從側邊欄隱藏 {project},已有會話不會被刪除。', + 'sidebar.clearProjectSessions': '刪除全部會話', + 'sidebar.clearProjectSessionsConfirm': '確定永久刪除 {project} 中的全部 {count} 個會話?專案將從側邊欄消失。此操作無法撤銷。', + 'sidebar.clearProjectSessionsSuccess': '已從 {project} 刪除 {count} 個會話。', + 'sidebar.clearProjectSessionsFailure': '刪除 {project} 的會話失敗:{error}', + 'sidebar.clearProjectSessionsNoWorkDir': '無法刪除 {project} 的會話:工作目錄未知。', 'sidebar.newSessionInProject': '在 {project} 中新建會話', 'sidebar.showMoreSessions': '展開顯示', 'sidebar.showFewerSessions': '摺疊顯示', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index a696f795..dcf704e0 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -55,6 +55,11 @@ export const zh: Record = { 'sidebar.restoreProjectToSidebar': '恢复到侧边栏', 'sidebar.restoreHiddenProjects': '恢复隐藏项目({count})', 'sidebar.projectHidden': '已从侧边栏隐藏 {project},已有会话不会被删除。', + 'sidebar.clearProjectSessions': '删除全部会话', + 'sidebar.clearProjectSessionsConfirm': '确定永久删除 {project} 中的全部 {count} 个会话?项目将从侧边栏消失。此操作无法撤销。', + 'sidebar.clearProjectSessionsSuccess': '已从 {project} 删除 {count} 个会话。', + 'sidebar.clearProjectSessionsFailure': '删除 {project} 的会话失败:{error}', + 'sidebar.clearProjectSessionsNoWorkDir': '无法删除 {project} 的会话:工作目录未知。', 'sidebar.newSessionInProject': '在 {project} 中新建会话', 'sidebar.showMoreSessions': '展开显示', 'sidebar.showFewerSessions': '折叠显示', diff --git a/src/server/__tests__/projects.test.ts b/src/server/__tests__/projects.test.ts new file mode 100644 index 00000000..8e51e707 --- /dev/null +++ b/src/server/__tests__/projects.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, mock, beforeEach } from 'bun:test' +import * as path from 'path' + +const MOCK_CLAUDE_HOME = path.join('/mock', 'home', '.claude') +const MOCK_PROJECTS_DIR = path.join(MOCK_CLAUDE_HOME, 'projects') + +mock.module('../../utils/envUtils.js', () => ({ + getClaudeConfigHomeDir: () => MOCK_CLAUDE_HOME, +})) + +mock.module('../../utils/path.js', () => ({ + // Match the real sanitizePath behaviour for our test inputs: replace + // path separators and colons with '-'. + sanitizePath: (p: string) => p.replace(/[\\/:]/g, '-').replace(/^-+/, ''), +})) + +// projectActivityService is unrelated to clear-sessions; stub it so the import +// chain doesn't pull in real fs work. +mock.module('../services/projectActivityService.js', () => ({ + getRecentActivity: async () => ({ items: [] }), +})) + +type FakeEntry = { name: string; isFile: () => boolean; isDirectory: () => boolean } +const dirs = new Map() +const unlinked: string[] = [] +const rmdired: string[] = [] +let nextRmdirError: NodeJS.ErrnoException | null = null + +mock.module('fs/promises', () => ({ + readdir: async (dirPath: string, _opts?: unknown) => { + const entries = dirs.get(path.resolve(dirPath)) + if (!entries) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return entries + }, + unlink: async (filePath: string) => { + unlinked.push(filePath) + // Remove the entry from its directory so a follow-up rmdir sees it as + // empty when only .jsonl files were present. + const dir = path.dirname(filePath) + const base = path.basename(filePath) + const list = dirs.get(path.resolve(dir)) + if (list) dirs.set(path.resolve(dir), list.filter(e => e.name !== base)) + }, + rmdir: async (dirPath: string) => { + if (nextRmdirError) { + const err = nextRmdirError + nextRmdirError = null + throw err + } + rmdired.push(path.resolve(dirPath)) + dirs.delete(path.resolve(dirPath)) + }, +})) + +import { handleProjectsApi } from '../api/projects' + +function projectDir(id: string) { + return path.join(MOCK_PROJECTS_DIR, id) +} + +function file(name: string): FakeEntry { + return { name, isFile: () => true, isDirectory: () => false } +} + +function dir(name: string): FakeEntry { + return { name, isFile: () => false, isDirectory: () => true } +} + +beforeEach(() => { + dirs.clear() + unlinked.length = 0 + rmdired.length = 0 + nextRmdirError = null +}) + +describe('POST /api/projects/sessions/clear', () => { + async function call(body: unknown): Promise { + const url = new URL('http://localhost/api/projects/sessions/clear') + const req = new Request(url, { method: 'POST', body: JSON.stringify(body) }) + return handleProjectsApi(req, url, ['api', 'projects', 'sessions', 'clear']) + } + + it('rejects invalid projectId (path separators)', async () => { + const res = await call({ projectId: '../escaped' }) + expect(res.status).toBe(400) + }) + + it('rejects missing projectId', async () => { + const res = await call({}) + expect(res.status).toBe(400) + }) + + it('rejects projectId with null byte', async () => { + const res = await call({ projectId: 'foo\0bar' }) + expect(res.status).toBe(400) + }) + + it('rejects . and ..', async () => { + expect((await call({ projectId: '.' })).status).toBe(400) + expect((await call({ projectId: '..' })).status).toBe(400) + }) + + it('returns ok with 0 deletes when project dir does not exist', async () => { + const res = await call({ projectId: 'C--Users-70641-vanished' }) + expect(res.status).toBe(200) + const data = await res.json() as { ok: boolean; deletedSessions: number; projectDirRemoved: boolean } + expect(data.ok).toBe(true) + expect(data.deletedSessions).toBe(0) + expect(data.projectDirRemoved).toBe(false) + }) + + it('deletes all .jsonl files and removes the now-empty project dir', async () => { + const id = 'C--Users-70641-myproj' + dirs.set(path.resolve(projectDir(id)), [ + file('a.jsonl'), + file('b.jsonl'), + file('c.jsonl'), + ]) + const res = await call({ projectId: id }) + expect(res.status).toBe(200) + const data = await res.json() as { deletedSessions: number; projectDirRemoved: boolean } + expect(data.deletedSessions).toBe(3) + expect(data.projectDirRemoved).toBe(true) + expect(unlinked).toHaveLength(3) + expect(rmdired).toContain(path.resolve(projectDir(id))) + }) + + it('keeps non-.jsonl files and does not remove the directory', async () => { + const id = 'C--Users-70641-keep-state' + dirs.set(path.resolve(projectDir(id)), [ + file('session.jsonl'), + file('user-notes.txt'), + dir('memory'), + ]) + const res = await call({ projectId: id }) + const data = await res.json() as { deletedSessions: number; projectDirRemoved: boolean } + expect(data.deletedSessions).toBe(1) + expect(data.projectDirRemoved).toBe(false) + expect(unlinked).toHaveLength(1) + expect(rmdired).toHaveLength(0) + }) + + it('GET on /sessions/clear returns 405', async () => { + const url = new URL('http://localhost/api/projects/sessions/clear') + const req = new Request(url, { method: 'GET' }) + const res = await handleProjectsApi(req, url, ['api', 'projects', 'sessions', 'clear']) + expect(res.status).toBe(405) + }) + + it('accepts an absolute workDir and sanitizes it server-side', async () => { + // Stub sanitizes "/Users/me/proj" -> "Users-me-proj". + const sanitizedId = 'Users-me-proj' + dirs.set(path.resolve(projectDir(sanitizedId)), [file('a.jsonl')]) + + const res = await call({ workDir: '/Users/me/proj' }) + expect(res.status).toBe(200) + const data = await res.json() as { deletedSessions: number; projectDirRemoved: boolean } + expect(data.deletedSessions).toBe(1) + expect(data.projectDirRemoved).toBe(true) + }) + + it('rejects a relative workDir', async () => { + const res = await call({ workDir: 'relative/path' }) + expect(res.status).toBe(400) + }) + + it('also deletes per-session summary sidecars (.summary.json) and removes the empty dir', async () => { + const id = 'C--Users-70641-with-summaries' + dirs.set(path.resolve(projectDir(id)), [ + file('a.jsonl'), + file('a.summary.json'), + file('b.jsonl'), + file('b.summary.json'), + ]) + const res = await call({ projectId: id }) + expect(res.status).toBe(200) + const data = await res.json() as { deletedSessions: number; projectDirRemoved: boolean } + expect(data.deletedSessions).toBe(2) // counts .jsonl files + expect(data.projectDirRemoved).toBe(true) // both .jsonl + .summary.json gone + expect(unlinked).toHaveLength(4) + }) +}) diff --git a/src/server/api/projects.ts b/src/server/api/projects.ts index 7ccc7e21..141e8415 100644 --- a/src/server/api/projects.ts +++ b/src/server/api/projects.ts @@ -1,16 +1,135 @@ /** - * /api/projects/* — read-only endpoints derived from the on-disk projects - * directory (`~/.claude/projects//...`) plus git state. + * /api/projects/* — endpoints derived from the on-disk projects directory + * (`~/.claude/projects//...`) plus git state. * * Currently exposes: - * GET /api/projects/recent-activity?workDir= + * GET /api/projects/recent-activity?workDir= + * POST /api/projects/sessions/clear body: { projectId } * - * which returns a "what was the user just doing in this project" snapshot - * for the desktop welcome screen. Pure derivation — never invokes a model. + * The first returns a "what was the user just doing in this project" snapshot + * for the desktop welcome screen. The second permanently deletes every .jsonl + * session file under the named project id (and removes the now-empty project + * directory) so the project disappears from the sidebar/listings. */ +import * as fs from 'fs/promises' +import * as path from 'path' import { ApiError, errorResponse } from '../middleware/errorHandler.js' import { getRecentActivity } from '../services/projectActivityService.js' +import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' +import { sanitizePath } from '../../utils/path.js' + +function getProjectsDir(): string { + return path.join(getClaudeConfigHomeDir(), 'projects') +} + +function isValidProjectId(projectId: string): boolean { + return ( + typeof projectId === 'string' && + projectId.length > 0 && + !projectId.includes('\0') && + !projectId.includes('/') && + !projectId.includes('\\') && + projectId !== '.' && + projectId !== '..' + ) +} + +async function clearProjectSessions(req: Request): Promise { + let body: unknown + try { + body = await req.json() + } catch { + throw ApiError.badRequest('Invalid JSON body') + } + + const { projectId, workDir } = body as { projectId?: unknown; workDir?: unknown } + + // Accept either projectId (already sanitized) or workDir (an absolute path + // that we sanitize ourselves). The desktop client passes workDir; backend + // tooling can pass projectId directly. + let resolvedId: string + if (typeof projectId === 'string' && projectId.length > 0) { + if (!isValidProjectId(projectId)) { + throw ApiError.badRequest('Invalid projectId') + } + resolvedId = projectId + } else if (typeof workDir === 'string' && workDir.length > 0 && path.isAbsolute(workDir)) { + resolvedId = sanitizePath(workDir) + if (!isValidProjectId(resolvedId)) { + throw ApiError.badRequest('workDir sanitized to an invalid id') + } + } else { + throw ApiError.badRequest('Provide projectId or absolute workDir') + } + + const projectsDir = path.resolve(getProjectsDir()) + const projectDir = path.join(projectsDir, resolvedId) + + // Defence in depth: even though isValidProjectId rejects path separators, + // verify the resolved directory still sits under the projects dir. + const resolvedProjectDir = path.resolve(projectDir) + if ( + resolvedProjectDir !== path.join(projectsDir, resolvedId) || + !resolvedProjectDir.startsWith(projectsDir + path.sep) + ) { + throw ApiError.badRequest('projectId resolves outside projects directory') + } + + let entries: import('node:fs').Dirent[] + try { + entries = await fs.readdir(resolvedProjectDir, { withFileTypes: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return Response.json({ ok: true, deletedSessions: 0, projectDirRemoved: false }) + } + throw error + } + + let deletedSessions = 0 + let nonSessionEntries = 0 + for (const entry of entries) { + if (!entry.isFile()) { + // Sub-directories (e.g. workspace snapshots tied to a session) are + // intentionally preserved — they may hold user state we shouldn't drop. + nonSessionEntries += 1 + continue + } + // Per-session artefacts: the JSONL transcript, plus its sidecar summary. + if (entry.name.endsWith('.jsonl')) { + try { + await fs.unlink(path.join(resolvedProjectDir, entry.name)) + deletedSessions += 1 + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + continue + } + if (entry.name.endsWith('.summary.json')) { + try { + await fs.unlink(path.join(resolvedProjectDir, entry.name)) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + continue + } + nonSessionEntries += 1 + } + + // If the directory is now empty (no leftover memory/, etc.), remove it so + // the project drops off the sidebar entirely. + let projectDirRemoved = false + if (nonSessionEntries === 0) { + try { + await fs.rmdir(resolvedProjectDir) + projectDirRemoved = true + } catch { + // Race with another writer or non-empty after all — leave directory. + } + } + + return Response.json({ ok: true, deletedSessions, projectDirRemoved }) +} export async function handleProjectsApi( req: Request, @@ -38,6 +157,17 @@ export async function handleProjectsApi( return Response.json(result) } + if (sub === 'sessions' && segments[3] === 'clear') { + if (req.method !== 'POST') { + throw new ApiError( + 405, + `Method ${req.method} not allowed on /api/projects/sessions/clear`, + 'METHOD_NOT_ALLOWED', + ) + } + return await clearProjectSessions(req) + } + throw ApiError.notFound(`Unknown projects endpoint: ${sub ?? '(root)'}`) } catch (error) { return errorResponse(error)