Skip to content

Commit c6ed594

Browse files
7418claude
andcommitted
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>
1 parent 5bace38 commit c6ed594

File tree

8 files changed

+119
-43
lines changed

8 files changed

+119
-43
lines changed

electron/main.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,17 @@ app.whenReady().then(async () => {
712712
return shell.openPath(folderPath);
713713
});
714714

715+
// Native folder picker dialog
716+
ipcMain.handle('dialog:open-folder', async (_event, options?: { defaultPath?: string; title?: string }) => {
717+
if (!mainWindow) return { canceled: true, filePaths: [] };
718+
const result = await dialog.showOpenDialog(mainWindow, {
719+
title: options?.title || 'Select a project folder',
720+
defaultPath: options?.defaultPath || undefined,
721+
properties: ['openDirectory', 'createDirectory'],
722+
});
723+
return { canceled: result.canceled, filePaths: result.filePaths };
724+
});
725+
715726
try {
716727
let port: number;
717728

electron/preload.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
1010
shell: {
1111
openPath: (folderPath: string) => ipcRenderer.invoke('shell:open-path', folderPath),
1212
},
13+
dialog: {
14+
openFolder: (options?: { defaultPath?: string; title?: string }) =>
15+
ipcRenderer.invoke('dialog:open-folder', options),
16+
},
1317
install: {
1418
checkPrerequisites: () => ipcRenderer.invoke('install:check-prerequisites'),
1519
start: (options?: { includeNode?: boolean }) => ipcRenderer.invoke('install:start', options),

src/app/chat/[id]/page.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,9 +182,8 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
182182
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
183183
onClick={() => {
184184
if (sessionWorkingDir) {
185-
const w = window as unknown as { electronAPI?: { shell?: { openPath: (p: string) => void } } };
186-
if (w.electronAPI?.shell?.openPath) {
187-
w.electronAPI.shell.openPath(sessionWorkingDir);
185+
if (window.electronAPI?.shell?.openPath) {
186+
window.electronAPI.shell.openPath(sessionWorkingDir);
188187
} else {
189188
fetch('/api/files/open', {
190189
method: 'POST',

src/components/layout/ChatListPanel.tsx

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
import { cn } from "@/lib/utils";
2727
import { usePanel } from "@/hooks/usePanel";
2828
import { useTranslation } from "@/hooks/useTranslation";
29+
import { useNativeFolderPicker } from "@/hooks/useNativeFolderPicker";
2930
import { ConnectionStatus } from "./ConnectionStatus";
3031
import { ImportSessionDialog } from "./ImportSessionDialog";
3132
import { FolderPicker } from "@/components/chat/FolderPicker";
@@ -119,6 +120,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
119120
const router = useRouter();
120121
const { streamingSessionId, pendingApprovalSessionId, workingDirectory } = usePanel();
121122
const { t } = useTranslation();
123+
const { isElectron, openNativePicker } = useNativeFolderPicker();
122124
const [sessions, setSessions] = useState<ChatSession[]>([]);
123125
const [hoveredSession, setHoveredSession] = useState<string | null>(null);
124126
const [deletingSession, setDeletingSession] = useState<string | null>(null);
@@ -131,13 +133,39 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
131133
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
132134
const [creatingChat, setCreatingChat] = useState(false);
133135

136+
const handleFolderSelect = useCallback(async (path: string) => {
137+
try {
138+
const res = await fetch("/api/chat/sessions", {
139+
method: "POST",
140+
headers: { "Content-Type": "application/json" },
141+
body: JSON.stringify({ working_directory: path }),
142+
});
143+
if (res.ok) {
144+
const data = await res.json();
145+
window.dispatchEvent(new CustomEvent("session-created"));
146+
router.push(`/chat/${data.session.id}`);
147+
}
148+
} catch {
149+
// Silently fail
150+
}
151+
}, [router]);
152+
153+
const openFolderPicker = useCallback(async (defaultPath?: string) => {
154+
if (isElectron) {
155+
const path = await openNativePicker({ defaultPath, title: t('folderPicker.title') });
156+
if (path) handleFolderSelect(path);
157+
} else {
158+
setFolderPickerOpen(true);
159+
}
160+
}, [isElectron, openNativePicker, t, handleFolderSelect]);
161+
134162
const handleNewChat = useCallback(async () => {
135163
const lastDir = workingDirectory
136164
|| (typeof window !== 'undefined' ? localStorage.getItem("codepilot:last-working-directory") : null);
137165

138166
if (!lastDir) {
139167
// No saved directory — let user pick one
140-
setFolderPickerOpen(true);
168+
openFolderPicker();
141169
return;
142170
}
143171

@@ -150,7 +178,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
150178
if (!checkRes.ok) {
151179
// Directory is gone — clear stale value and prompt user
152180
localStorage.removeItem("codepilot:last-working-directory");
153-
setFolderPickerOpen(true);
181+
openFolderPicker();
154182
return;
155183
}
156184

@@ -162,18 +190,18 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
162190
if (!res.ok) {
163191
// Backend rejected it (e.g. INVALID_DIRECTORY) — prompt user
164192
localStorage.removeItem("codepilot:last-working-directory");
165-
setFolderPickerOpen(true);
193+
openFolderPicker();
166194
return;
167195
}
168196
const data = await res.json();
169197
router.push(`/chat/${data.session.id}`);
170198
window.dispatchEvent(new CustomEvent("session-created"));
171199
} catch {
172-
setFolderPickerOpen(true);
200+
openFolderPicker();
173201
} finally {
174202
setCreatingChat(false);
175203
}
176-
}, [router, workingDirectory]);
204+
}, [router, workingDirectory, openFolderPicker]);
177205

178206
const toggleProject = useCallback((wd: string) => {
179207
setCollapsedProjects((prev) => {
@@ -263,23 +291,6 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
263291
}
264292
};
265293

266-
const handleFolderSelect = async (path: string) => {
267-
try {
268-
const res = await fetch("/api/chat/sessions", {
269-
method: "POST",
270-
headers: { "Content-Type": "application/json" },
271-
body: JSON.stringify({ working_directory: path }),
272-
});
273-
if (res.ok) {
274-
const data = await res.json();
275-
window.dispatchEvent(new CustomEvent("session-created"));
276-
router.push(`/chat/${data.session.id}`);
277-
}
278-
} catch {
279-
// Silently fail
280-
}
281-
};
282-
283294
const isSearching = searchQuery.length > 0;
284295

285296
const filteredSessions = searchQuery
@@ -341,7 +352,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
341352
variant="outline"
342353
size="icon-sm"
343354
className="h-8 w-8 shrink-0"
344-
onClick={() => setFolderPickerOpen(true)}
355+
onClick={() => openFolderPicker()}
345356
>
346357
<HugeiconsIcon icon={FolderOpenIcon} className="h-3.5 w-3.5" />
347358
<span className="sr-only">{t('chatList.addProjectFolder')}</span>
@@ -408,9 +419,8 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
408419
onDoubleClick={(e) => {
409420
e.stopPropagation();
410421
if (group.workingDirectory) {
411-
const w = window as unknown as { electronAPI?: { shell?: { openPath: (p: string) => void } } };
412-
if (w.electronAPI?.shell?.openPath) {
413-
w.electronAPI.shell.openPath(group.workingDirectory);
422+
if (window.electronAPI?.shell?.openPath) {
423+
window.electronAPI.shell.openPath(group.workingDirectory);
414424
} else {
415425
fetch('/api/files/open', {
416426
method: 'POST',

src/components/layout/ConnectionStatus.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,7 @@ export function ConnectionStatus() {
3131

3232
const isElectron =
3333
typeof window !== "undefined" &&
34-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
35-
!!(window as any).electronAPI?.install;
34+
!!window.electronAPI?.install;
3635
const stableCountRef = useRef(0);
3736
const lastConnectedRef = useRef<boolean | null>(null);
3837
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

src/components/layout/InstallWizard.tsx

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -58,18 +58,7 @@ interface PrereqResult {
5858

5959
function getInstallAPI() {
6060
if (typeof window !== "undefined") {
61-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
62-
return (window as any).electronAPI?.install as
63-
| {
64-
checkPrerequisites: () => Promise<PrereqResult>;
65-
start: (options?: { includeNode?: boolean }) => Promise<void>;
66-
cancel: () => Promise<void>;
67-
getLogs: () => Promise<string[]>;
68-
onProgress: (
69-
callback: (progress: InstallProgress) => void
70-
) => () => void;
71-
}
72-
| undefined;
61+
return window.electronAPI?.install;
7362
}
7463
return undefined;
7564
}

src/hooks/useNativeFolderPicker.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { useCallback } from 'react';
2+
3+
/**
4+
* Hook that provides native folder picker functionality in Electron,
5+
* with detection for browser fallback.
6+
*/
7+
export function useNativeFolderPicker() {
8+
const isElectron = typeof window !== 'undefined' && !!window.electronAPI?.dialog?.openFolder;
9+
10+
const openNativePicker = useCallback(
11+
async (options?: { defaultPath?: string; title?: string }): Promise<string | null> => {
12+
if (!window.electronAPI?.dialog?.openFolder) return null;
13+
const result = await window.electronAPI.dialog.openFolder(options);
14+
return result.canceled ? null : result.filePaths[0] ?? null;
15+
},
16+
[]
17+
);
18+
19+
return { isElectron, openNativePicker };
20+
}

src/types/electron.d.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Global type declarations for the Electron preload API.
3+
* Exposed via contextBridge.exposeInMainWorld('electronAPI', ...) in electron/preload.ts.
4+
*/
5+
6+
interface ElectronInstallAPI {
7+
checkPrerequisites: () => Promise<{
8+
hasNode: boolean;
9+
nodeVersion?: string;
10+
hasClaude: boolean;
11+
claudeVersion?: string;
12+
}>;
13+
start: (options?: { includeNode?: boolean }) => Promise<void>;
14+
cancel: () => Promise<void>;
15+
getLogs: () => Promise<string[]>;
16+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
17+
onProgress: (callback: (data: any) => void) => () => void;
18+
}
19+
20+
interface ElectronAPI {
21+
versions: {
22+
electron: string;
23+
node: string;
24+
chrome: string;
25+
};
26+
shell: {
27+
openPath: (path: string) => Promise<string>;
28+
};
29+
dialog: {
30+
openFolder: (options?: {
31+
defaultPath?: string;
32+
title?: string;
33+
}) => Promise<{ canceled: boolean; filePaths: string[] }>;
34+
};
35+
install: ElectronInstallAPI;
36+
}
37+
38+
declare global {
39+
interface Window {
40+
electronAPI?: ElectronAPI;
41+
}
42+
}
43+
44+
export {};

0 commit comments

Comments
 (0)