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:
1 parent
5bace38e47
commit
c6ed594800
8 files changed
+119
-43
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Vendored
+44
@@ -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 {};
|
||||
Reference in new issue
Block a user