mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-27 15:13:37 +08:00
feat(sidebar): delete all sessions for a project (#74)
* feat(sidebar): delete all sessions for a project
Add a "Delete all sessions" option to the project context menu so a
project can be removed from the sidebar entirely (not just hidden).
Hide-from-sidebar is a UI-only soft toggle; this is a destructive
operation that wipes the on-disk artefacts.
Backend
- POST /api/projects/sessions/clear accepts { projectId } or
{ workDir } (the desktop client passes workDir; backend sanitizes).
- Deletes every .jsonl transcript and matching .summary.json sidecar
under ~/.claude/projects/<sanitized-id>/. Sub-directories and other
non-session files (memory/, user notes) are preserved.
- If the directory becomes empty, removes it so the project drops off
the sidebar listing entirely.
- Defence in depth: rejects path-separator/null-byte/`.`/`..` ids and
re-checks the resolved path stays under projectsDir.
- 11 backend tests covering traversal/null-byte/dot rejection, missing
dir 200, full-clear+rmdir, non-jsonl preservation, summary sidecar
deletion, workDir absolute-path acceptance, relative-path rejection,
and 405 on GET.
Frontend
- Sidebar project context menu: red "Delete all sessions" entry
(Trash2 icon, danger) below the hide entry.
- ConfirmDialog with project title + session count + irreversibility
warning. Disconnects open sessions and closes their tabs before the
call so the websocket layer doesn't keep stale handles.
- After success, fetchSessions() refreshes the list — the project
disappears if its dir was removed, or shows up empty if memory/
remained.
- 5 new i18n keys across en/zh/jp/kr/zh-TW.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(sidebar): cover projectsApi.clearSessions client behaviour
Wire-level guard for the desktop API helper introduced in this PR — the
sidebar's "Delete all sessions" flow depends on it sending the right
endpoint and body, and surfacing server errors back to the caller for
toast handling.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: 你的姓名 <you@example.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
a78a05b52c
commit
d2ef9ad330
35
desktop/src/api/projects.test.ts
Normal file
35
desktop/src/api/projects.test.ts
Normal file
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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 },
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@ -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<string | null>(null)
|
||||
const [pendingBatchDeleteSessionIds, setPendingBatchDeleteSessionIds] = useState<string[] | null>(null)
|
||||
const [isBatchDeleting, setIsBatchDeleting] = useState(false)
|
||||
const [pendingClearProjectKey, setPendingClearProjectKey] = useState<string | null>(null)
|
||||
const [isClearingProject, setIsClearingProject] = useState(false)
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const [lastSelectedSessionId, setLastSelectedSessionId] = useState<string | null>(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')}
|
||||
</ProjectMenuItem>
|
||||
<ProjectMenuItem
|
||||
icon={<Trash2 size={18} aria-hidden="true" />}
|
||||
onClick={() => requestClearProjectSessions(project)}
|
||||
danger
|
||||
>
|
||||
{t('sidebar.clearProjectSessions')}
|
||||
</ProjectMenuItem>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
@ -1235,6 +1300,32 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
confirmVariant="danger"
|
||||
loading={isBatchDeleting}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={pendingClearProjectKey !== null}
|
||||
onClose={() => {
|
||||
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 (
|
||||
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">
|
||||
{t('sidebar.clearProjectSessionsConfirm', {
|
||||
project: project.title,
|
||||
count: project.sessions.length,
|
||||
})}
|
||||
</p>
|
||||
)
|
||||
})()}
|
||||
confirmLabel={t('common.delete')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
confirmVariant="danger"
|
||||
loading={isClearingProject}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -55,6 +55,11 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'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': '表示を折りたたむ',
|
||||
|
||||
@ -55,6 +55,11 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'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': '접어서 표시',
|
||||
|
||||
@ -55,6 +55,11 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'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': '摺疊顯示',
|
||||
|
||||
@ -55,6 +55,11 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'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': '折叠显示',
|
||||
|
||||
184
src/server/__tests__/projects.test.ts
Normal file
184
src/server/__tests__/projects.test.ts
Normal file
@ -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<string, FakeEntry[]>()
|
||||
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<Response> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@ -1,16 +1,135 @@
|
||||
/**
|
||||
* /api/projects/* — read-only endpoints derived from the on-disk projects
|
||||
* directory (`~/.claude/projects/<sanitized-workDir>/...`) plus git state.
|
||||
* /api/projects/* — endpoints derived from the on-disk projects directory
|
||||
* (`~/.claude/projects/<sanitized-workDir>/...`) plus git state.
|
||||
*
|
||||
* Currently exposes:
|
||||
* GET /api/projects/recent-activity?workDir=<absolute-path>
|
||||
* GET /api/projects/recent-activity?workDir=<absolute-path>
|
||||
* 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<Response> {
|
||||
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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user