From 1bc8fe1646379cfb2d884c2330527e3b361200a4 Mon Sep 17 00:00:00 2001 From: Apple Date: Mon, 29 Jun 2026 16:03:20 +0800 Subject: [PATCH] feat: drag-and-drop folder to sidebar to create project Allow users to drag a folder from Finder directly onto the ChatListPanel sidebar to create a new project session, bypassing the system folder picker. - Add onDragEnter/Over/Leave/Drop handlers to sidebar root container - Use preload's webUtils.getPathForFile to obtain folder path (File.path is empty under contextIsolation:true) - Validate dropped path via /api/files/browse to reject non-directories - Show drag-over overlay with dashed border and i18n tooltip - Counter-based dragDepthRef to handle nested dragleave bubbling - Non-Electron environments silently no-op - Add i18n keys: dropToCreateProject, dropFileNotFolder, dropCreateFailed Co-Authored-By: Claude Fable 5 --- src/components/layout/ChatListPanel.tsx | 85 ++++++++++++++++++++++++- src/i18n/en.ts | 3 + src/i18n/zh.ts | 3 + 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/components/layout/ChatListPanel.tsx b/src/components/layout/ChatListPanel.tsx index 4b8d23f2..40638236 100644 --- a/src/components/layout/ChatListPanel.tsx +++ b/src/components/layout/ChatListPanel.tsx @@ -83,6 +83,12 @@ export function ChatListPanel({ open, hasUpdate, readyToInstall }: ChatListPanel buddy?: { emoji: string; buddyName?: string; species?: string }; } | null>(null); const [promoDismissed, setPromoDismissed] = useState(false); + // Drag a folder onto the sidebar to create a project (Electron desktop only). + // dragDepthRef is a counter that absorbs dragenter/dragleave bubbling across + // nested children, so the drop highlight doesn't flicker when the cursor + // crosses sub-elements inside the panel. + const dragDepthRef = useRef(0); + const [isDragOver, setIsDragOver] = useState(false); // Reload assistant summary when sessions change (e.g. after onboarding/rename) useEffect(() => { @@ -126,6 +132,38 @@ export function ChatListPanel({ open, hasUpdate, readyToInstall }: ChatListPanel } }, [isElectron, openNativePicker, t, handleFolderSelect]); + const handleDrop = useCallback(async (e: React.DragEvent) => { + e.preventDefault(); + dragDepthRef.current = 0; + setIsDragOver(false); + // Only the Electron desktop build can resolve a dropped File to a real + // filesystem path — under contextIsolation the renderer's File.path is + // intentionally zeroed, so we route through preload's webUtils helper. + const getPathForFile = window.electronAPI?.fs?.getPathForFile; + const files = e.dataTransfer?.files; + if (!getPathForFile || !files || files.length === 0) return; + const path = getPathForFile(files[0]); + if (!path) return; + // Reject non-directories up-front (the same /api/files/browse check + // handleNewChat uses) so dragging a single file produces a clear warning + // instead of a silent no-op inside handleFolderSelect. + try { + const checkRes = await fetch(`/api/files/browse?dir=${encodeURIComponent(path)}`); + if (!checkRes.ok) { + showToast({ type: 'warning', message: t('chatList.dropFileNotFolder') }); + return; + } + } catch { + // Validation request itself failed — fall through and let + // handleFolderSelect's own failure path deal with it. + } + try { + await handleFolderSelect(path); + } catch { + showToast({ type: 'error', message: t('chatList.dropCreateFailed') }); + } + }, [handleFolderSelect, t, showToast]); + const handleNewChat = useCallback(async () => { let lastDir = workingDirectory || (typeof window !== 'undefined' ? localStorage.getItem("codepilot:last-working-directory") : null); @@ -436,7 +474,33 @@ export function ChatListPanel({ open, hasUpdate, readyToInstall }: ChatListPanel // in AppShell. This inner block now // only owns the column layout for its own children. return ( -
+
{ + // Electron-only + file/folder drags only. Skip in-browser runs + // (no path resolution available) and in-app text/link drags. + if (!window.electronAPI?.fs?.getPathForFile) return; + if (!e.dataTransfer?.types?.includes('Files')) return; + e.preventDefault(); + dragDepthRef.current += 1; + setIsDragOver(true); + }} + onDragOver={(e) => { + // preventDefault is mandatory here — otherwise the browser would + // open the dropped file and onDrop would never fire. + if (!window.electronAPI?.fs?.getPathForFile) return; + if (!e.dataTransfer?.types?.includes('Files')) return; + e.preventDefault(); + }} + onDragLeave={() => { + dragDepthRef.current -= 1; + if (dragDepthRef.current <= 0) { + dragDepthRef.current = 0; + setIsDragOver(false); + } + }} + onDrop={handleDrop} + > {/* Round 20 — the h-12 traffic-light-safe-area + collapse button used to live at the top of this panel. Both moved to UnifiedTopBar so the four floating cards (this sidebar, @@ -841,6 +905,25 @@ export function ChatListPanel({ open, hasUpdate, readyToInstall }: ChatListPanel onSelect={handleFolderSelect} /> + {/* Drag-to-create overlay — visible only while a folder is dragged + over the panel (Electron desktop only). */} + + {isDragOver && ( + + + + {t('chatList.dropToCreateProject')} + + + )} + +
); } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 63ee25af..5ad19b4f 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -39,6 +39,9 @@ const en = { 'chatList.projects': 'Projects', 'chatList.assistantSection': 'Assistant', 'chatList.newProject': 'New Project', + 'chatList.dropToCreateProject': 'Drop folder to create project', + 'chatList.dropFileNotFolder': 'Please drop a folder, not a file', + 'chatList.dropCreateFailed': 'Failed to create project', 'chatList.showMore': 'Show {count} more', 'chatList.showLess': 'Show less', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 695a3e00..aad905f3 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -36,6 +36,9 @@ const zh: Record = { 'chatList.projects': '项目', 'chatList.assistantSection': '助理', 'chatList.newProject': '新建项目', + 'chatList.dropToCreateProject': '松开即可新建项目', + 'chatList.dropFileNotFolder': '请拖入文件夹,不支持单个文件', + 'chatList.dropCreateFailed': '创建项目失败', 'chatList.showMore': '展开更多({count} 条)', 'chatList.showLess': '收起',