diff --git a/electron/main.ts b/electron/main.ts index 33bdfef..819f279 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -712,6 +712,17 @@ app.whenReady().then(async () => { return shell.openPath(folderPath); }); + // Native folder picker dialog + ipcMain.handle('dialog:open-folder', async (_event, options?: { defaultPath?: string; title?: string }) => { + if (!mainWindow) return { canceled: true, filePaths: [] }; + const result = await dialog.showOpenDialog(mainWindow, { + title: options?.title || 'Select a project folder', + defaultPath: options?.defaultPath || undefined, + properties: ['openDirectory', 'createDirectory'], + }); + return { canceled: result.canceled, filePaths: result.filePaths }; + }); + try { let port: number; diff --git a/electron/preload.ts b/electron/preload.ts index 31a823e..6de761a 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -10,6 +10,10 @@ contextBridge.exposeInMainWorld('electronAPI', { shell: { openPath: (folderPath: string) => ipcRenderer.invoke('shell:open-path', folderPath), }, + dialog: { + openFolder: (options?: { defaultPath?: string; title?: string }) => + ipcRenderer.invoke('dialog:open-folder', options), + }, install: { checkPrerequisites: () => ipcRenderer.invoke('install:check-prerequisites'), start: (options?: { includeNode?: boolean }) => ipcRenderer.invoke('install:start', options), diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index c3e6858..c318f7b 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -182,9 +182,8 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties} onClick={() => { if (sessionWorkingDir) { - const w = window as unknown as { electronAPI?: { shell?: { openPath: (p: string) => void } } }; - if (w.electronAPI?.shell?.openPath) { - w.electronAPI.shell.openPath(sessionWorkingDir); + if (window.electronAPI?.shell?.openPath) { + window.electronAPI.shell.openPath(sessionWorkingDir); } else { fetch('/api/files/open', { method: 'POST', diff --git a/src/components/layout/ChatListPanel.tsx b/src/components/layout/ChatListPanel.tsx index 436bb9e..6f78087 100644 --- a/src/components/layout/ChatListPanel.tsx +++ b/src/components/layout/ChatListPanel.tsx @@ -26,6 +26,7 @@ import { import { cn } from "@/lib/utils"; import { usePanel } from "@/hooks/usePanel"; import { useTranslation } from "@/hooks/useTranslation"; +import { useNativeFolderPicker } from "@/hooks/useNativeFolderPicker"; import { ConnectionStatus } from "./ConnectionStatus"; import { ImportSessionDialog } from "./ImportSessionDialog"; import { FolderPicker } from "@/components/chat/FolderPicker"; @@ -119,6 +120,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { const router = useRouter(); const { streamingSessionId, pendingApprovalSessionId, workingDirectory } = usePanel(); const { t } = useTranslation(); + const { isElectron, openNativePicker } = useNativeFolderPicker(); const [sessions, setSessions] = useState([]); const [hoveredSession, setHoveredSession] = useState(null); const [deletingSession, setDeletingSession] = useState(null); @@ -131,13 +133,39 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { const [hoveredFolder, setHoveredFolder] = useState(null); const [creatingChat, setCreatingChat] = useState(false); + const handleFolderSelect = useCallback(async (path: string) => { + try { + const res = await fetch("/api/chat/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ working_directory: path }), + }); + if (res.ok) { + const data = await res.json(); + window.dispatchEvent(new CustomEvent("session-created")); + router.push(`/chat/${data.session.id}`); + } + } catch { + // Silently fail + } + }, [router]); + + const openFolderPicker = useCallback(async (defaultPath?: string) => { + if (isElectron) { + const path = await openNativePicker({ defaultPath, title: t('folderPicker.title') }); + if (path) handleFolderSelect(path); + } else { + setFolderPickerOpen(true); + } + }, [isElectron, openNativePicker, t, handleFolderSelect]); + const handleNewChat = useCallback(async () => { const lastDir = workingDirectory || (typeof window !== 'undefined' ? localStorage.getItem("codepilot:last-working-directory") : null); if (!lastDir) { // No saved directory — let user pick one - setFolderPickerOpen(true); + openFolderPicker(); return; } @@ -150,7 +178,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { if (!checkRes.ok) { // Directory is gone — clear stale value and prompt user localStorage.removeItem("codepilot:last-working-directory"); - setFolderPickerOpen(true); + openFolderPicker(); return; } @@ -162,18 +190,18 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { if (!res.ok) { // Backend rejected it (e.g. INVALID_DIRECTORY) — prompt user localStorage.removeItem("codepilot:last-working-directory"); - setFolderPickerOpen(true); + openFolderPicker(); return; } const data = await res.json(); router.push(`/chat/${data.session.id}`); window.dispatchEvent(new CustomEvent("session-created")); } catch { - setFolderPickerOpen(true); + openFolderPicker(); } finally { setCreatingChat(false); } - }, [router, workingDirectory]); + }, [router, workingDirectory, openFolderPicker]); const toggleProject = useCallback((wd: string) => { setCollapsedProjects((prev) => { @@ -263,23 +291,6 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { } }; - const handleFolderSelect = async (path: string) => { - try { - const res = await fetch("/api/chat/sessions", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ working_directory: path }), - }); - if (res.ok) { - const data = await res.json(); - window.dispatchEvent(new CustomEvent("session-created")); - router.push(`/chat/${data.session.id}`); - } - } catch { - // Silently fail - } - }; - const isSearching = searchQuery.length > 0; const filteredSessions = searchQuery @@ -341,7 +352,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { variant="outline" size="icon-sm" className="h-8 w-8 shrink-0" - onClick={() => setFolderPickerOpen(true)} + onClick={() => openFolderPicker()} > {t('chatList.addProjectFolder')} @@ -408,9 +419,8 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { onDoubleClick={(e) => { e.stopPropagation(); if (group.workingDirectory) { - const w = window as unknown as { electronAPI?: { shell?: { openPath: (p: string) => void } } }; - if (w.electronAPI?.shell?.openPath) { - w.electronAPI.shell.openPath(group.workingDirectory); + if (window.electronAPI?.shell?.openPath) { + window.electronAPI.shell.openPath(group.workingDirectory); } else { fetch('/api/files/open', { method: 'POST', diff --git a/src/components/layout/ConnectionStatus.tsx b/src/components/layout/ConnectionStatus.tsx index 68b8267..a8cd48f 100644 --- a/src/components/layout/ConnectionStatus.tsx +++ b/src/components/layout/ConnectionStatus.tsx @@ -31,8 +31,7 @@ export function ConnectionStatus() { const isElectron = typeof window !== "undefined" && - // eslint-disable-next-line @typescript-eslint/no-explicit-any - !!(window as any).electronAPI?.install; + !!window.electronAPI?.install; const stableCountRef = useRef(0); const lastConnectedRef = useRef(null); const timerRef = useRef | null>(null); diff --git a/src/components/layout/InstallWizard.tsx b/src/components/layout/InstallWizard.tsx index 99a7ccf..e0ca98b 100644 --- a/src/components/layout/InstallWizard.tsx +++ b/src/components/layout/InstallWizard.tsx @@ -58,18 +58,7 @@ interface PrereqResult { function getInstallAPI() { if (typeof window !== "undefined") { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (window as any).electronAPI?.install as - | { - checkPrerequisites: () => Promise; - start: (options?: { includeNode?: boolean }) => Promise; - cancel: () => Promise; - getLogs: () => Promise; - onProgress: ( - callback: (progress: InstallProgress) => void - ) => () => void; - } - | undefined; + return window.electronAPI?.install; } return undefined; } diff --git a/src/hooks/useNativeFolderPicker.ts b/src/hooks/useNativeFolderPicker.ts new file mode 100644 index 0000000..d4b7ae2 --- /dev/null +++ b/src/hooks/useNativeFolderPicker.ts @@ -0,0 +1,20 @@ +import { useCallback } from 'react'; + +/** + * Hook that provides native folder picker functionality in Electron, + * with detection for browser fallback. + */ +export function useNativeFolderPicker() { + const isElectron = typeof window !== 'undefined' && !!window.electronAPI?.dialog?.openFolder; + + const openNativePicker = useCallback( + async (options?: { defaultPath?: string; title?: string }): Promise => { + if (!window.electronAPI?.dialog?.openFolder) return null; + const result = await window.electronAPI.dialog.openFolder(options); + return result.canceled ? null : result.filePaths[0] ?? null; + }, + [] + ); + + return { isElectron, openNativePicker }; +} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts new file mode 100644 index 0000000..204b7e5 --- /dev/null +++ b/src/types/electron.d.ts @@ -0,0 +1,44 @@ +/** + * Global type declarations for the Electron preload API. + * Exposed via contextBridge.exposeInMainWorld('electronAPI', ...) in electron/preload.ts. + */ + +interface ElectronInstallAPI { + checkPrerequisites: () => Promise<{ + hasNode: boolean; + nodeVersion?: string; + hasClaude: boolean; + claudeVersion?: string; + }>; + start: (options?: { includeNode?: boolean }) => Promise; + cancel: () => Promise; + getLogs: () => Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onProgress: (callback: (data: any) => void) => () => void; +} + +interface ElectronAPI { + versions: { + electron: string; + node: string; + chrome: string; + }; + shell: { + openPath: (path: string) => Promise; + }; + dialog: { + openFolder: (options?: { + defaultPath?: string; + title?: string; + }) => Promise<{ canceled: boolean; filePaths: string[] }>; + }; + install: ElectronInstallAPI; +} + +declare global { + interface Window { + electronAPI?: ElectronAPI; + } +} + +export {};