feat: use native OS folder picker dialog in Electron mode

Electron 环境下改用系统原生文件夹选择对话框替代自定义 FolderPicker 组件,
浏览器环境保留原有 FolderPicker 作为 fallback(浏览器沙箱无法获取绝对路径)。

- electron/main.ts: 新增 `dialog:open-folder` IPC handler,调用
  `dialog.showOpenDialog` 并设置 openDirectory + createDirectory 属性,
  macOS 下支持在对话框内新建文件夹,对话框模态绑定 mainWindow
- electron/preload.ts: 在 electronAPI 中暴露 `dialog.openFolder()` 方法
- src/types/electron.d.ts: 新建全局类型声明,统一定义 window.electronAPI
  的完整类型,消除代码中散落的 `(window as any)` 类型断言
- src/hooks/useNativeFolderPicker.ts: 新建 hook 封装平台检测和原生对话框
  调用逻辑,返回 isElectron 标志和 openNativePicker 方法
- src/components/layout/ChatListPanel.tsx: 新增 openFolderPicker() 统一入口,
  Electron 走原生对话框、浏览器走 setFolderPickerOpen(true);替换
  handleNewChat 中 4 处及文件夹按钮 onClick 共 5 处调用点;
  handleFolderSelect 改为 useCallback 并调整声明顺序避免引用前使用;
  清理 electronAPI 类型断言
- src/app/chat/[id]/page.tsx: 移除 `window as unknown as ...` 类型断言
- src/components/layout/ConnectionStatus.tsx: 移除 `(window as any)` 断言
- src/components/layout/InstallWizard.tsx: getInstallAPI() 简化为直接返回
  window.electronAPI?.install,移除 10 行手动类型定义

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-02-26 10:47:08 +08:00
1 parent 5bace38e47
commit c6ed594800
8 files changed
+119 -43

No files matched your search

+11
View File
@@ -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;
+4
View File
@@ -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),
+2 -3
View File
@@ -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',
+36 -26
View File
@@ -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<ChatSession[]>([]);
const [hoveredSession, setHoveredSession] = useState<string | null>(null);
const [deletingSession, setDeletingSession] = useState<string | null>(null);
@@ -131,13 +133,39 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
const [hoveredFolder, setHoveredFolder] = useState<string | null>(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()}
>
<HugeiconsIcon icon={FolderOpenIcon} className="h-3.5 w-3.5" />
<span className="sr-only">{t('chatList.addProjectFolder')}</span>
@@ -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',
+1 -2
View File
@@ -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<boolean | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+1 -12
View File
@@ -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<PrereqResult>;
start: (options?: { includeNode?: boolean }) => Promise<void>;
cancel: () => Promise<void>;
getLogs: () => Promise<string[]>;
onProgress: (
callback: (progress: InstallProgress) => void
) => () => void;
}
| undefined;
return window.electronAPI?.install;
}
return undefined;
}
+20
View File
@@ -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<string | null> => {
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 };
}
+44
View File
@@ -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<void>;
cancel: () => Promise<void>;
getLogs: () => Promise<string[]>;
// 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<string>;
};
dialog: {
openFolder: (options?: {
defaultPath?: string;
title?: string;
}) => Promise<{ canceled: boolean; filePaths: string[] }>;
};
install: ElectronInstallAPI;
}
declare global {
interface Window {
electronAPI?: ElectronAPI;
}
}
export {};