From e689af63f7ab675cb1c09fdc697aa9cbab757a97 Mon Sep 17 00:00:00 2001 From: xuxu777xu <1728019186@qq.com> Date: Sat, 7 Mar 2026 12:57:04 +0800 Subject: [PATCH] feat(chat-list): warn on missing project directories - debounce directory existence checks for session groups - refresh missing-directory state when the tab becomes visible again - show localized warning tooltip when a project folder is gone --- .../unit/directory-existence.test.ts | 42 +++++++ src/app/api/files/exists/route.ts | 19 ++++ src/components/layout/ChatListPanel.tsx | 106 +++++++++++++++++- src/i18n/en.ts | 1 + src/i18n/zh.ts | 1 + src/lib/directory-existence.ts | 19 ++++ 6 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/unit/directory-existence.test.ts create mode 100644 src/app/api/files/exists/route.ts create mode 100644 src/lib/directory-existence.ts diff --git a/src/__tests__/unit/directory-existence.test.ts b/src/__tests__/unit/directory-existence.test.ts new file mode 100644 index 00000000..489d558e --- /dev/null +++ b/src/__tests__/unit/directory-existence.test.ts @@ -0,0 +1,42 @@ +import { after, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { findMissingDirectories } from '../../lib/directory-existence'; + +describe('findMissingDirectories', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codepilot-dir-exists-')); + const existingDir = path.join(tmpDir, 'existing'); + const anotherDir = path.join(tmpDir, 'another'); + const missingDir = path.join(tmpDir, 'missing'); + + fs.mkdirSync(existingDir, { recursive: true }); + fs.mkdirSync(anotherDir, { recursive: true }); + + after(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should return only missing directories in stable order', async () => { + const result = await findMissingDirectories([ + existingDir, + missingDir, + anotherDir, + ]); + + assert.deepEqual(result, [missingDir]); + }); + + it('should deduplicate repeated directories before checking', async () => { + const result = await findMissingDirectories([ + missingDir, + missingDir, + existingDir, + existingDir, + ]); + + assert.deepEqual(result, [missingDir]); + }); +}); diff --git a/src/app/api/files/exists/route.ts b/src/app/api/files/exists/route.ts new file mode 100644 index 00000000..44f6a8ef --- /dev/null +++ b/src/app/api/files/exists/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from 'next/server'; +import { findMissingDirectories } from '@/lib/directory-existence'; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const dirs = Array.isArray(body?.dirs) + ? body.dirs.filter((dir: unknown): dir is string => typeof dir === 'string') + : []; + + const missingDirs = await findMissingDirectories(dirs); + return NextResponse.json({ missingDirs }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to check directories' }, + { status: 500 } + ); + } +} diff --git a/src/components/layout/ChatListPanel.tsx b/src/components/layout/ChatListPanel.tsx index 372cd787..c621e071 100644 --- a/src/components/layout/ChatListPanel.tsx +++ b/src/components/layout/ChatListPanel.tsx @@ -16,7 +16,7 @@ import { FolderOpenIcon, AiUserIcon, } from "@hugeicons/core-free-icons"; -import { Columns2, X } from "lucide-react"; +import { AlertTriangle, Columns2, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -117,7 +117,7 @@ function groupSessionsByProject(sessions: ChatSession[]): ProjectGroup[] { export function ChatListPanel({ open, width }: ChatListPanelProps) { const pathname = usePathname(); const router = useRouter(); - const { streamingSessionId, pendingApprovalSessionId, activeStreamingSessions, pendingApprovalSessionIds, workingDirectory, sessionId: currentSessionId } = usePanel(); + const { streamingSessionId, pendingApprovalSessionId, activeStreamingSessions, pendingApprovalSessionIds, workingDirectory } = usePanel(); const { splitSessions, isSplitActive, activeColumnId, addToSplit, removeFromSplit, setActiveColumn, isInSplit } = useSplit(); const { t } = useTranslation(); const { isElectron, openNativePicker } = useNativeFolderPicker(); @@ -132,6 +132,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { ); const [hoveredFolder, setHoveredFolder] = useState(null); const [creatingChat, setCreatingChat] = useState(false); + const [missingDirs, setMissingDirs] = useState>(new Set()); const { workspacePath } = useAssistantWorkspace(); /** Read current model + provider_id from localStorage for new session creation */ @@ -211,7 +212,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { } finally { setCreatingChat(false); } - }, [router, workingDirectory, openFolderPicker]); + }, [router, workingDirectory, openFolderPicker, getCurrentModelAndProvider]); const toggleProject = useCallback((wd: string) => { setCollapsedProjects((prev) => { @@ -330,6 +331,88 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { } }; + const uniqueDirsKey = useMemo( + () => + JSON.stringify( + [...new Set(sessions.map((session) => session.working_directory).filter(Boolean))].sort() + ), + [sessions] + ); + const dirCheckTimerRef = useRef | null>(null); + const mountedRef = useRef(true); + const dirCheckAbortRef = useRef(null); + const dirCheckRequestIdRef = useRef(0); + + const checkMissingDirs = useCallback(() => { + const dirs: string[] = JSON.parse(uniqueDirsKey); + if (dirs.length === 0) { + if (mountedRef.current) { + setMissingDirs(new Set()); + } + return; + } + + if (dirCheckTimerRef.current) { + clearTimeout(dirCheckTimerRef.current); + } + dirCheckAbortRef.current?.abort(); + const requestId = dirCheckRequestIdRef.current + 1; + dirCheckRequestIdRef.current = requestId; + + dirCheckTimerRef.current = setTimeout(async () => { + const abortController = new AbortController(); + dirCheckAbortRef.current = abortController; + + let missing = new Set(); + try { + const res = await fetch("/api/files/exists", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dirs }), + signal: abortController.signal, + }); + + if (res.ok) { + const data = await res.json(); + missing = new Set(Array.isArray(data.missingDirs) ? data.missingDirs : []); + } else { + missing = new Set(dirs); + } + } catch (error) { + if ((error as Error).name === "AbortError") { + return; + } + missing = new Set(dirs); + } + + if (mountedRef.current && requestId === dirCheckRequestIdRef.current) { + setMissingDirs(missing); + } + }, 1000); + }, [uniqueDirsKey]); + + useEffect(() => { + mountedRef.current = true; + checkMissingDirs(); + + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + checkMissingDirs(); + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + mountedRef.current = false; + if (dirCheckTimerRef.current) { + clearTimeout(dirCheckTimerRef.current); + } + dirCheckAbortRef.current?.abort(); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [checkMissingDirs]); + const isSearching = searchQuery.length > 0; const splitSessionIds = useMemo( @@ -569,6 +652,23 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { {workspacePath && group.workingDirectory === workspacePath && ( )} + {group.workingDirectory && missingDirs.has(group.workingDirectory) && ( + + + + + {t("chatList.dirNotFound")} + + + +

{t("chatList.dirNotFound")}

+
+
+ )} {/* New chat in project button (on hover) */} {group.workingDirectory !== "" && ( diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 0413ffce..426ba642 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -25,6 +25,7 @@ const en = { 'chatList.noSessions': 'No sessions yet', 'chatList.importFromCli': 'Import from Claude Code', 'chatList.addProjectFolder': 'Add project folder', + 'chatList.dirNotFound': 'Project directory no longer exists', 'chatList.threads': 'Threads', // ── Message list ──────────────────────────────────────────── diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 46833814..607636a9 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -22,6 +22,7 @@ const zh: Record = { 'chatList.noSessions': '暂无会话', 'chatList.importFromCli': '从 Claude Code 导入', 'chatList.addProjectFolder': '添加项目文件夹', + 'chatList.dirNotFound': '项目目录已不存在', 'chatList.threads': '对话列表', // ── Message list ──────────────────────────────────────────── diff --git a/src/lib/directory-existence.ts b/src/lib/directory-existence.ts new file mode 100644 index 00000000..e93300f1 --- /dev/null +++ b/src/lib/directory-existence.ts @@ -0,0 +1,19 @@ +import fs from 'fs/promises'; +import path from 'path'; + +export async function findMissingDirectories(directories: string[]): Promise { + const uniqueDirectories = [...new Set(directories.filter(Boolean).map((dir) => path.resolve(dir)))]; + const missing: string[] = []; + + await Promise.all( + uniqueDirectories.map(async (dir) => { + try { + await fs.access(dir); + } catch { + missing.push(dir); + } + }) + ); + + return uniqueDirectories.filter((dir) => missing.includes(dir)); +}