mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-28 15:53: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
|
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 { 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 { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { useUIStore } from '../../stores/uiStore'
|
import { useUIStore } from '../../stores/uiStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
@ -39,6 +39,7 @@ import {
|
|||||||
positionProjectMenu,
|
positionProjectMenu,
|
||||||
isDocumentVisible,
|
isDocumentVisible,
|
||||||
} from './sidebarUtils'
|
} from './sidebarUtils'
|
||||||
|
import { projectsApi } from '../../api/projects'
|
||||||
import {
|
import {
|
||||||
GitHubIcon,
|
GitHubIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
@ -98,6 +99,8 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
|||||||
const [pendingDeleteSessionId, setPendingDeleteSessionId] = useState<string | null>(null)
|
const [pendingDeleteSessionId, setPendingDeleteSessionId] = useState<string | null>(null)
|
||||||
const [pendingBatchDeleteSessionIds, setPendingBatchDeleteSessionIds] = useState<string[] | null>(null)
|
const [pendingBatchDeleteSessionIds, setPendingBatchDeleteSessionIds] = useState<string[] | null>(null)
|
||||||
const [isBatchDeleting, setIsBatchDeleting] = useState(false)
|
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 [renamingId, setRenamingId] = useState<string | null>(null)
|
||||||
const [renameValue, setRenameValue] = useState('')
|
const [renameValue, setRenameValue] = useState('')
|
||||||
const [lastSelectedSessionId, setLastSelectedSessionId] = useState<string | null>(null)
|
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])
|
}, [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) => {
|
const openProjectInFinder = useCallback(async (project: ProjectGroup) => {
|
||||||
setProjectContextMenu(null)
|
setProjectContextMenu(null)
|
||||||
try {
|
try {
|
||||||
@ -1149,6 +1207,13 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
|||||||
>
|
>
|
||||||
{t(hidden ? 'sidebar.restoreProjectToSidebar' : 'sidebar.hideProjectFromSidebar')}
|
{t(hidden ? 'sidebar.restoreProjectToSidebar' : 'sidebar.hideProjectFromSidebar')}
|
||||||
</ProjectMenuItem>
|
</ProjectMenuItem>
|
||||||
|
<ProjectMenuItem
|
||||||
|
icon={<Trash2 size={18} aria-hidden="true" />}
|
||||||
|
onClick={() => requestClearProjectSessions(project)}
|
||||||
|
danger
|
||||||
|
>
|
||||||
|
{t('sidebar.clearProjectSessions')}
|
||||||
|
</ProjectMenuItem>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
@ -1235,6 +1300,32 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
|||||||
confirmVariant="danger"
|
confirmVariant="danger"
|
||||||
loading={isBatchDeleting}
|
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>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,6 +53,11 @@ export const en = {
|
|||||||
'sidebar.restoreProjectToSidebar': 'Restore to Sidebar',
|
'sidebar.restoreProjectToSidebar': 'Restore to Sidebar',
|
||||||
'sidebar.restoreHiddenProjects': 'Restore hidden projects ({count})',
|
'sidebar.restoreHiddenProjects': 'Restore hidden projects ({count})',
|
||||||
'sidebar.projectHidden': '{project} was hidden from the sidebar. Existing sessions were not deleted.',
|
'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.newSessionInProject': 'New session in {project}',
|
||||||
'sidebar.showMoreSessions': 'Expand display',
|
'sidebar.showMoreSessions': 'Expand display',
|
||||||
'sidebar.showFewerSessions': 'Collapse display',
|
'sidebar.showFewerSessions': 'Collapse display',
|
||||||
|
|||||||
@ -55,6 +55,11 @@ export const jp: Record<TranslationKey, string> = {
|
|||||||
'sidebar.restoreProjectToSidebar': 'サイドバーに復元',
|
'sidebar.restoreProjectToSidebar': 'サイドバーに復元',
|
||||||
'sidebar.restoreHiddenProjects': '非表示のプロジェクトを復元 ({count})',
|
'sidebar.restoreHiddenProjects': '非表示のプロジェクトを復元 ({count})',
|
||||||
'sidebar.projectHidden': '{project} をサイドバーから非表示にしました。既存のセッションは削除されていません。',
|
'sidebar.projectHidden': '{project} をサイドバーから非表示にしました。既存のセッションは削除されていません。',
|
||||||
|
'sidebar.clearProjectSessions': 'すべてのセッションを削除',
|
||||||
|
'sidebar.clearProjectSessionsConfirm': '{project} のすべての {count} 個のセッションを完全に削除しますか?プロジェクトはサイドバーから消えます。この操作は取り消せません。',
|
||||||
|
'sidebar.clearProjectSessionsSuccess': '{project} から {count} 個のセッションを削除しました。',
|
||||||
|
'sidebar.clearProjectSessionsFailure': '{project} のセッション削除に失敗しました:{error}',
|
||||||
|
'sidebar.clearProjectSessionsNoWorkDir': '{project} のセッションを削除できません:作業ディレクトリが不明です。',
|
||||||
'sidebar.newSessionInProject': '{project} で新しいセッション',
|
'sidebar.newSessionInProject': '{project} で新しいセッション',
|
||||||
'sidebar.showMoreSessions': '表示を展開',
|
'sidebar.showMoreSessions': '表示を展開',
|
||||||
'sidebar.showFewerSessions': '表示を折りたたむ',
|
'sidebar.showFewerSessions': '表示を折りたたむ',
|
||||||
|
|||||||
@ -55,6 +55,11 @@ export const kr: Record<TranslationKey, string> = {
|
|||||||
'sidebar.restoreProjectToSidebar': '사이드바로 복원',
|
'sidebar.restoreProjectToSidebar': '사이드바로 복원',
|
||||||
'sidebar.restoreHiddenProjects': '숨긴 프로젝트 복원 ({count})',
|
'sidebar.restoreHiddenProjects': '숨긴 프로젝트 복원 ({count})',
|
||||||
'sidebar.projectHidden': '{project}을(를) 사이드바에서 숨겼습니다. 기존 세션은 삭제되지 않았습니다.',
|
'sidebar.projectHidden': '{project}을(를) 사이드바에서 숨겼습니다. 기존 세션은 삭제되지 않았습니다.',
|
||||||
|
'sidebar.clearProjectSessions': '모든 세션 삭제',
|
||||||
|
'sidebar.clearProjectSessionsConfirm': '{project}의 모든 {count}개 세션을 영구적으로 삭제하시겠습니까? 프로젝트가 사이드바에서 제거됩니다. 이 작업은 되돌릴 수 없습니다.',
|
||||||
|
'sidebar.clearProjectSessionsSuccess': '{project}에서 {count}개 세션을 삭제했습니다.',
|
||||||
|
'sidebar.clearProjectSessionsFailure': '{project}의 세션 삭제 실패: {error}',
|
||||||
|
'sidebar.clearProjectSessionsNoWorkDir': '{project}의 세션을 삭제할 수 없습니다: 작업 디렉토리를 알 수 없습니다.',
|
||||||
'sidebar.newSessionInProject': '{project}에서 새 세션',
|
'sidebar.newSessionInProject': '{project}에서 새 세션',
|
||||||
'sidebar.showMoreSessions': '펼쳐서 표시',
|
'sidebar.showMoreSessions': '펼쳐서 표시',
|
||||||
'sidebar.showFewerSessions': '접어서 표시',
|
'sidebar.showFewerSessions': '접어서 표시',
|
||||||
|
|||||||
@ -55,6 +55,11 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'sidebar.restoreProjectToSidebar': '恢復到側邊欄',
|
'sidebar.restoreProjectToSidebar': '恢復到側邊欄',
|
||||||
'sidebar.restoreHiddenProjects': '恢復隱藏專案({count})',
|
'sidebar.restoreHiddenProjects': '恢復隱藏專案({count})',
|
||||||
'sidebar.projectHidden': '已從側邊欄隱藏 {project},已有會話不會被刪除。',
|
'sidebar.projectHidden': '已從側邊欄隱藏 {project},已有會話不會被刪除。',
|
||||||
|
'sidebar.clearProjectSessions': '刪除全部會話',
|
||||||
|
'sidebar.clearProjectSessionsConfirm': '確定永久刪除 {project} 中的全部 {count} 個會話?專案將從側邊欄消失。此操作無法撤銷。',
|
||||||
|
'sidebar.clearProjectSessionsSuccess': '已從 {project} 刪除 {count} 個會話。',
|
||||||
|
'sidebar.clearProjectSessionsFailure': '刪除 {project} 的會話失敗:{error}',
|
||||||
|
'sidebar.clearProjectSessionsNoWorkDir': '無法刪除 {project} 的會話:工作目錄未知。',
|
||||||
'sidebar.newSessionInProject': '在 {project} 中新建會話',
|
'sidebar.newSessionInProject': '在 {project} 中新建會話',
|
||||||
'sidebar.showMoreSessions': '展開顯示',
|
'sidebar.showMoreSessions': '展開顯示',
|
||||||
'sidebar.showFewerSessions': '摺疊顯示',
|
'sidebar.showFewerSessions': '摺疊顯示',
|
||||||
|
|||||||
@ -55,6 +55,11 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'sidebar.restoreProjectToSidebar': '恢复到侧边栏',
|
'sidebar.restoreProjectToSidebar': '恢复到侧边栏',
|
||||||
'sidebar.restoreHiddenProjects': '恢复隐藏项目({count})',
|
'sidebar.restoreHiddenProjects': '恢复隐藏项目({count})',
|
||||||
'sidebar.projectHidden': '已从侧边栏隐藏 {project},已有会话不会被删除。',
|
'sidebar.projectHidden': '已从侧边栏隐藏 {project},已有会话不会被删除。',
|
||||||
|
'sidebar.clearProjectSessions': '删除全部会话',
|
||||||
|
'sidebar.clearProjectSessionsConfirm': '确定永久删除 {project} 中的全部 {count} 个会话?项目将从侧边栏消失。此操作无法撤销。',
|
||||||
|
'sidebar.clearProjectSessionsSuccess': '已从 {project} 删除 {count} 个会话。',
|
||||||
|
'sidebar.clearProjectSessionsFailure': '删除 {project} 的会话失败:{error}',
|
||||||
|
'sidebar.clearProjectSessionsNoWorkDir': '无法删除 {project} 的会话:工作目录未知。',
|
||||||
'sidebar.newSessionInProject': '在 {project} 中新建会话',
|
'sidebar.newSessionInProject': '在 {project} 中新建会话',
|
||||||
'sidebar.showMoreSessions': '展开显示',
|
'sidebar.showMoreSessions': '展开显示',
|
||||||
'sidebar.showFewerSessions': '折叠显示',
|
'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
|
* /api/projects/* — endpoints derived from the on-disk projects directory
|
||||||
* directory (`~/.claude/projects/<sanitized-workDir>/...`) plus git state.
|
* (`~/.claude/projects/<sanitized-workDir>/...`) plus git state.
|
||||||
*
|
*
|
||||||
* Currently exposes:
|
* 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
|
* The first returns a "what was the user just doing in this project" snapshot
|
||||||
* for the desktop welcome screen. Pure derivation — never invokes a model.
|
* 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 { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||||
import { getRecentActivity } from '../services/projectActivityService.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(
|
export async function handleProjectsApi(
|
||||||
req: Request,
|
req: Request,
|
||||||
@ -38,6 +157,17 @@ export async function handleProjectsApi(
|
|||||||
return Response.json(result)
|
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)'}`)
|
throw ApiError.notFound(`Unknown projects endpoint: ${sub ?? '(root)'}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error)
|
return errorResponse(error)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user