From 8d393b3b4fc278dde1ad8912043e381fab7502f3 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 30 Mar 2026 14:59:23 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96web/server=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E4=B8=8B=E7=9A=84=E7=9B=AE=E5=BD=95=E9=80=89=E6=8B=A9=EF=BC=8C?= =?UTF-8?q?=E7=8E=B0=E5=9C=A8=E6=94=AF=E6=8C=81=E7=9B=AE=E5=BD=95=E6=A0=91?= =?UTF-8?q?=E9=80=89=E6=8B=A9=EF=BC=8C=E8=80=8C=E4=B8=8D=E6=98=AF=E7=A1=AC?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/folders.rs | 92 ++++ src-tauri/src/lib.rs | 2 + src-tauri/src/web/handlers/folders.rs | 18 + src-tauri/src/web/router.rs | 2 + .../layout/folder-name-dropdown.tsx | 29 +- src/components/layout/folder-title-bar.tsx | 34 +- .../shadcn/create-project-dialog.tsx | 410 +++++++++--------- .../shared/directory-browser-dialog.tsx | 349 +++++++++++++++ src/components/welcome/clone-dialog.tsx | 191 ++++---- src/components/welcome/folder-actions.tsx | 112 +++-- src/i18n/messages/ar.json | 15 +- src/i18n/messages/de.json | 15 +- src/i18n/messages/en.json | 15 +- src/i18n/messages/es.json | 15 +- src/i18n/messages/fr.json | 15 +- src/i18n/messages/ja.json | 15 +- src/i18n/messages/ko.json | 15 +- src/i18n/messages/pt.json | 15 +- src/i18n/messages/zh-CN.json | 15 +- src/i18n/messages/zh-TW.json | 15 +- src/lib/api.ts | 13 + src/lib/tauri.ts | 13 + src/lib/types.ts | 6 + 23 files changed, 1077 insertions(+), 344 deletions(-) create mode 100644 src/components/shared/directory-browser-dialog.tsx diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index 6098b50..71979b2 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -2934,6 +2934,98 @@ where })? } +// ─── Directory browser helpers (for web/server mode) ─── + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn get_home_directory() -> Result { + dirs::home_dir() + .map(|p| p.to_string_lossy().to_string()) + .ok_or_else(|| AppCommandError::io_error("Could not determine home directory")) +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DirectoryEntry { + pub name: String, + pub path: String, + pub has_children: bool, +} + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn list_directory_entries( + path: String, +) -> Result, AppCommandError> { + let root = PathBuf::from(&path); + if !root.is_dir() { + return Err(AppCommandError::io_error("Path is not a directory") + .with_detail(path)); + } + + let mut entries: Vec = Vec::new(); + let read_dir = std::fs::read_dir(&root).map_err(|e| { + AppCommandError::io_error("Failed to read directory").with_detail(e.to_string()) + })?; + + for entry in read_dir { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + let file_type = match entry.file_type() { + Ok(ft) => ft, + Err(_) => continue, + }; + // Follow symlinks: check if the resolved path is a directory + let is_dir = if file_type.is_symlink() { + entry.path().is_dir() + } else { + file_type.is_dir() + }; + if !is_dir { + continue; + } + let name = entry.file_name().to_string_lossy().to_string(); + // Skip hidden directories (starting with '.') + if name.starts_with('.') { + continue; + } + let abs_path = entry.path().to_string_lossy().to_string(); + + // Peek into subdirectory to check if it has child directories + let has_children = match std::fs::read_dir(entry.path()) { + Ok(sub) => sub + .filter_map(|e| e.ok()) + .any(|e| { + let ft = e.file_type().ok(); + let is_sub_dir = ft.map_or(false, |ft| { + if ft.is_symlink() { + e.path().is_dir() + } else { + ft.is_dir() + } + }); + if !is_sub_dir { + return false; + } + let sub_name = e.file_name().to_string_lossy().to_string(); + !sub_name.starts_with('.') + }), + Err(_) => false, + }; + + entries.push(DirectoryEntry { + name, + path: abs_path, + has_children, + }); + } + + // Sort by name, case-insensitive + entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + + Ok(entries) +} + #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn get_file_tree( path: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f33bb26..7640e58 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -270,6 +270,8 @@ mod tauri_app { folders::save_folder_opened_conversations, folders::start_file_tree_watch, folders::stop_file_tree_watch, + folders::get_home_directory, + folders::list_directory_entries, folders::get_file_tree, folders::read_file_base64, folders::read_file_preview, diff --git a/src-tauri/src/web/handlers/folders.rs b/src-tauri/src/web/handlers/folders.rs index df11c88..a51c943 100644 --- a/src-tauri/src/web/handlers/folders.rs +++ b/src-tauri/src/web/handlers/folders.rs @@ -110,6 +110,24 @@ pub async fn get_git_branch( Ok(Json(result)) } +pub async fn get_home_directory() -> Result, AppCommandError> { + let result = folder_commands::get_home_directory().await?; + Ok(Json(result)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListDirectoryEntriesParams { + pub path: String, +} + +pub async fn list_directory_entries( + Json(params): Json, +) -> Result>, AppCommandError> { + let result = folder_commands::list_directory_entries(params.path).await?; + Ok(Json(result)) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetFileTreeParams { diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 4cafcdb..6fbf8cf 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -50,6 +50,8 @@ pub fn build_router(state: Arc, token: String, static_dir: std::path:: .route("/create_folder_directory", post(handlers::folders::create_folder_directory)) .route("/save_folder_opened_conversations", post(handlers::folders::save_folder_opened_conversations)) .route("/get_git_branch", post(handlers::folders::get_git_branch)) + .route("/get_home_directory", post(handlers::folders::get_home_directory)) + .route("/list_directory_entries", post(handlers::folders::list_directory_entries)) .route("/get_file_tree", post(handlers::folders::get_file_tree)) .route("/start_file_tree_watch", post(handlers::folders::start_file_tree_watch)) .route("/stop_file_tree_watch", post(handlers::folders::stop_file_tree_watch)) diff --git a/src/components/layout/folder-name-dropdown.tsx b/src/components/layout/folder-name-dropdown.tsx index 2d21e32..8e78d78 100644 --- a/src/components/layout/folder-name-dropdown.tsx +++ b/src/components/layout/folder-name-dropdown.tsx @@ -24,9 +24,10 @@ import { openFolderWindow, openProjectBootWindow, } from "@/lib/api" -import { openFileDialog } from "@/lib/platform" +import { isDesktop, openFileDialog } from "@/lib/platform" import { useFolderContext } from "@/contexts/folder-context" import { CloneDialog } from "@/components/welcome/clone-dialog" +import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog" import type { FolderHistoryEntry } from "@/lib/types" export function FolderNameDropdown() { @@ -35,6 +36,7 @@ export function FolderNameDropdown() { const [openFolders, setOpenFolders] = useState([]) const [history, setHistory] = useState([]) const [cloneOpen, setCloneOpen] = useState(false) + const [browserOpen, setBrowserOpen] = useState(false) const folderPath = folder?.path ?? "" const folderName = folder?.name ?? t("fallbackFolderName") @@ -57,11 +59,19 @@ export function FolderNameDropdown() { } async function handleOpenFolder() { - const selected = await openFileDialog({ directory: true, multiple: false }) - if (selected) { - await openFolderWindow(Array.isArray(selected) ? selected[0] : selected, { - newWindow: true, + if (isDesktop()) { + const selected = await openFileDialog({ + directory: true, + multiple: false, }) + if (selected) { + await openFolderWindow( + Array.isArray(selected) ? selected[0] : selected, + { newWindow: true } + ) + } + } else { + setBrowserOpen(true) } } @@ -149,6 +159,15 @@ export function FolderNameDropdown() { + { + openFolderWindow(path, { newWindow: true }).catch((err) => { + console.error("[FolderNameDropdown] failed to open folder:", err) + }) + }} + /> ) } diff --git a/src/components/layout/folder-title-bar.tsx b/src/components/layout/folder-title-bar.tsx index 858fa10..d523f02 100644 --- a/src/components/layout/folder-title-bar.tsx +++ b/src/components/layout/folder-title-bar.tsx @@ -19,7 +19,7 @@ import { } from "lucide-react" import { useTranslations } from "next-intl" import { getGitBranch, openFolderWindow, openSettingsWindow } from "@/lib/api" -import { openFileDialog } from "@/lib/platform" +import { isDesktop, openFileDialog } from "@/lib/platform" import { useFolderContext } from "@/contexts/folder-context" import { Button } from "@/components/ui/button" import { useSidebarContext } from "@/contexts/sidebar-context" @@ -38,6 +38,7 @@ import { FolderNameDropdown } from "./folder-name-dropdown" import { BranchDropdown } from "./branch-dropdown" import { CommandDropdown } from "./command-dropdown" import { SearchCommandDialog } from "@/components/conversations/search-command-dialog" +import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog" import { cn } from "@/lib/utils" const MODE_TABS = [ @@ -71,6 +72,7 @@ export function FolderTitleBar() { const { shortcuts } = useShortcutSettings() const [branch, setBranch] = useState(null) const [searchOpen, setSearchOpen] = useState(false) + const [browserOpen, setBrowserOpen] = useState(false) const intervalRef = useRef | undefined>( undefined ) @@ -78,13 +80,20 @@ export function FolderTitleBar() { const folderPath = folder?.path ?? "" const handleOpenFolder = useCallback(async () => { - try { - const result = await openFileDialog({ directory: true, multiple: false }) - if (!result) return - const selected = Array.isArray(result) ? result[0] : result - await openFolderWindow(selected, { newWindow: true }) - } catch (err) { - console.error("[FolderTitleBar] failed to open folder:", err) + if (isDesktop()) { + try { + const result = await openFileDialog({ + directory: true, + multiple: false, + }) + if (!result) return + const selected = Array.isArray(result) ? result[0] : result + await openFolderWindow(selected, { newWindow: true }) + } catch (err) { + console.error("[FolderTitleBar] failed to open folder:", err) + } + } else { + setBrowserOpen(true) } }, []) @@ -399,6 +408,15 @@ export function FolderTitleBar() { } /> + { + openFolderWindow(path, { newWindow: true }).catch((err) => { + console.error("[FolderTitleBar] failed to open folder:", err) + }) + }} + /> ) } diff --git a/src/components/project-boot/shadcn/create-project-dialog.tsx b/src/components/project-boot/shadcn/create-project-dialog.tsx index b3418c0..c71242f 100644 --- a/src/components/project-boot/shadcn/create-project-dialog.tsx +++ b/src/components/project-boot/shadcn/create-project-dialog.tsx @@ -34,13 +34,14 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog" -import { openFileDialog } from "@/lib/platform" +import { isDesktop, openFileDialog } from "@/lib/platform" import { createShadcnProject, openFolderWindow, detectPackageManager, } from "@/lib/api" import { extractAppCommandError, toErrorMessage } from "@/lib/app-error" +import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog" import { BASE_OPTIONS, FRAMEWORK_OPTIONS, @@ -67,6 +68,7 @@ export function CreateProjectDialog({ const [rtl, setRtl] = useState(false) const [advancedOpen, setAdvancedOpen] = useState(false) const [creating, setCreating] = useState(false) + const [browserOpen, setBrowserOpen] = useState(false) const [error, setError] = useState(null) const [pmVersion, setPmVersion] = useState(null) @@ -96,10 +98,14 @@ export function CreateProjectDialog({ }, [open, packageManager, checkPackageManager]) const handleBrowse = async () => { - const result = await openFileDialog({ directory: true, multiple: false }) - if (!result) return - const selected = Array.isArray(result) ? result[0] : result - setSaveDirectory(selected) + if (isDesktop()) { + const result = await openFileDialog({ directory: true, multiple: false }) + if (!result) return + const selected = Array.isArray(result) ? result[0] : result + setSaveDirectory(selected) + } else { + setBrowserOpen(true) + } } const handleCreate = async () => { @@ -151,208 +157,216 @@ export function CreateProjectDialog({ pmInstalled === true return ( - { - onOpenChange(v) - if (!v) resetForm() - }} - > - - - {t("createDialog.title")} - + <> + { + onOpenChange(v) + if (!v) resetForm() + }} + > + + + {t("createDialog.title")} + -
-
- - setProjectName(e.target.value)} - placeholder={t("createDialog.projectNamePlaceholder")} - disabled={creating} - /> -
- -
- -
+
+
+ setSaveDirectory(e.target.value)} - placeholder={t("createDialog.saveDirectoryPlaceholder")} + value={projectName} + onChange={(e) => setProjectName(e.target.value)} + placeholder={t("createDialog.projectNamePlaceholder")} disabled={creating} - className="flex-1" /> -
- {saveDirectory && projectName.trim() && ( -

- {t("createDialog.projectPath", { - path: `${saveDirectory}/${projectName.trim()}`, - })} -

+ +
+ +
+ setSaveDirectory(e.target.value)} + placeholder={t("createDialog.saveDirectoryPlaceholder")} + disabled={creating} + className="flex-1" + /> + +
+ {saveDirectory && projectName.trim() && ( +

+ {t("createDialog.projectPath", { + path: `${saveDirectory}/${projectName.trim()}`, + })} +

+ )} +
+ +
+ + + + {PACKAGE_MANAGER_OPTIONS.map((opt) => ( + + {opt.label} + + ))} + + {PACKAGE_MANAGER_OPTIONS.map((opt) => ( + +
+ {pmChecking ? ( + <> + + + {t("createDialog.pmChecking")} + + + ) : pmInstalled ? ( + <> + + + {opt.label} v{pmVersion} + + + ) : ( + <> + + + {t("createDialog.pmNotInstalled")} + + + )} +
+
+ ))} +
+
+ + + + + + +
+ + + {FRAMEWORK_OPTIONS.map((opt) => ( + + + + {opt.label} + + + + + ))} + +
+ +
+ + + {BASE_OPTIONS.map((opt) => ( + + + + {opt.label} + + + + + ))} + +
+ + +
+
+ + {error && ( +
+ {error} +
)}
-
- - +
+ {t("createDialog.cancel")} + + + + +
- - - - - -
- - - {FRAMEWORK_OPTIONS.map((opt) => ( - - - - {opt.label} - - - - - ))} - -
- -
- - - {BASE_OPTIONS.map((opt) => ( - - - - {opt.label} - - - - - ))} - -
- - -
-
- - {error && ( -
- {error} -
- )} - - - - - - -
-
+ setSaveDirectory(path)} + /> + ) } diff --git a/src/components/shared/directory-browser-dialog.tsx b/src/components/shared/directory-browser-dialog.tsx new file mode 100644 index 0000000..6ce5a44 --- /dev/null +++ b/src/components/shared/directory-browser-dialog.tsx @@ -0,0 +1,349 @@ +"use client" + +import { useState, useEffect, useCallback, useRef } from "react" +import { useTranslations } from "next-intl" +import { + ChevronRight, + ChevronUp, + FolderIcon, + FolderOpenIcon, + Home, + Loader2, +} from "lucide-react" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" +import { cn } from "@/lib/utils" +import { getHomeDirectory, listDirectoryEntries } from "@/lib/api" +import type { DirectoryEntry } from "@/lib/types" + +interface DirectoryBrowserDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onSelect: (path: string) => void + title?: string + initialPath?: string +} + +export function DirectoryBrowserDialog({ + open, + onOpenChange, + onSelect, + title, + initialPath, +}: DirectoryBrowserDialogProps) { + const t = useTranslations("DirectoryBrowser") + + const [rootPath, setRootPath] = useState("") + const [pathInput, setPathInput] = useState("") + const [entries, setEntries] = useState>( + new Map() + ) + const [expandedPaths, setExpandedPaths] = useState>(new Set()) + const [selectedPath, setSelectedPath] = useState(null) + const [loading, setLoading] = useState>(new Set()) + const [error, setError] = useState(null) + + const initialized = useRef(false) + + const loadEntries = useCallback( + async (path: string): Promise => { + // Already cached + if (entries.has(path)) return entries.get(path)! + + setLoading((prev) => new Set(prev).add(path)) + setError(null) + try { + const result = await listDirectoryEntries(path) + setEntries((prev) => new Map(prev).set(path, result)) + return result + } catch { + setError(t("errorLoadingDir")) + return null + } finally { + setLoading((prev) => { + const next = new Set(prev) + next.delete(path) + return next + }) + } + }, + [entries, t] + ) + + const navigateTo = useCallback( + async (path: string) => { + const result = await loadEntries(path) + if (result !== null) { + setRootPath(path) + setPathInput(path) + setExpandedPaths(new Set()) + setSelectedPath(null) + } + }, + [loadEntries] + ) + + // Initialize on open + useEffect(() => { + if (!open) { + initialized.current = false + return + } + if (initialized.current) return + initialized.current = true + + const init = async () => { + try { + const startPath = initialPath || (await getHomeDirectory()) + setRootPath(startPath) + setPathInput(startPath) + setSelectedPath(null) + setExpandedPaths(new Set()) + setEntries(new Map()) + setError(null) + setLoading(new Set([startPath])) + + const result = await listDirectoryEntries(startPath) + setEntries(new Map([[startPath, result]])) + setLoading(new Set()) + } catch { + setError(t("errorLoadingDir")) + setLoading(new Set()) + } + } + init() + }, [open, initialPath, t]) + + const handleToggleExpand = useCallback( + async (path: string) => { + const newExpanded = new Set(expandedPaths) + if (newExpanded.has(path)) { + newExpanded.delete(path) + setExpandedPaths(newExpanded) + } else { + await loadEntries(path) + newExpanded.add(path) + setExpandedPaths(newExpanded) + } + }, + [expandedPaths, loadEntries] + ) + + const handleSelect = useCallback( + (path: string) => { + setSelectedPath(path === selectedPath ? null : path) + }, + [selectedPath] + ) + + const handleConfirm = useCallback(() => { + if (selectedPath) { + onSelect(selectedPath) + onOpenChange(false) + } + }, [selectedPath, onSelect, onOpenChange]) + + const handleNavigateUp = useCallback(() => { + if (!rootPath) return + const parts = rootPath.replace(/\/$/, "").split("/") + if (parts.length <= 1) return + parts.pop() + const parent = parts.join("/") || "/" + navigateTo(parent) + }, [rootPath, navigateTo]) + + const handleGoHome = useCallback(async () => { + try { + const home = await getHomeDirectory() + navigateTo(home) + } catch { + setError(t("errorLoadingDir")) + } + }, [navigateTo, t]) + + const handlePathInputKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && pathInput.trim()) { + navigateTo(pathInput.trim()) + } + }, + [pathInput, navigateTo] + ) + + const handleDoubleClick = useCallback( + (path: string) => { + onSelect(path) + onOpenChange(false) + }, + [onSelect, onOpenChange] + ) + + const renderEntries = (parentPath: string, depth: number) => { + const children = entries.get(parentPath) + const isLoading = loading.has(parentPath) + + if (isLoading) { + return ( +
+ + {t("loading")} +
+ ) + } + + if (!children) return null + + if (children.length === 0) { + return ( +
+ {t("emptyDirectory")} +
+ ) + } + + return children.map((entry) => { + const isExpanded = expandedPaths.has(entry.path) + const isSelected = selectedPath === entry.path + + return ( +
+ + {isExpanded && renderEntries(entry.path, depth + 1)} +
+ ) + }) + } + + return ( + + + + {title ?? t("title")} + + +
+
+ + + setPathInput(e.target.value)} + onKeyDown={handlePathInputKeyDown} + placeholder={t("pathPlaceholder")} + className="flex-1 h-8 text-sm font-mono" + /> +
+ + +
+ {renderEntries(rootPath, 0)} + {error && !loading.size && ( +
+ {error} +
+ )} +
+
+ + {selectedPath && ( +

+ {selectedPath} +

+ )} +
+ + + + + +
+
+ ) +} diff --git a/src/components/welcome/clone-dialog.tsx b/src/components/welcome/clone-dialog.tsx index 8cf8bf1..2fcd891 100644 --- a/src/components/welcome/clone-dialog.tsx +++ b/src/components/welcome/clone-dialog.tsx @@ -1,10 +1,10 @@ "use client" -import { useState } from "react" +import { useState, useMemo } from "react" import { useTranslations } from "next-intl" import { toast } from "sonner" import { cloneRepository, openFolderWindow } from "@/lib/api" -import { openFileDialog } from "@/lib/platform" +import { isDesktop, openFileDialog } from "@/lib/platform" import { useGitCredential } from "@/contexts/git-credential-context" import { Dialog, @@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button" import { Label } from "@/components/ui/label" import { FolderOpen, Loader2 } from "lucide-react" import { resolveCloneError } from "@/components/welcome/error-utils" +import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog" interface CloneDialogProps { open: boolean @@ -30,27 +31,38 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) { const [url, setUrl] = useState("") const [targetDir, setTargetDir] = useState("") const [cloning, setCloning] = useState(false) + const [browserOpen, setBrowserOpen] = useState(false) const [error, setError] = useState<{ message: string detail: string | null } | null>(null) + const repoName = useMemo( + () => + url + .replace(/\.git$/, "") + .split("/") + .pop() ?? "repo", + [url] + ) + const handleBrowse = async () => { - const selected = await openFileDialog({ directory: true, multiple: false }) - if (selected) { - setTargetDir(Array.isArray(selected) ? selected[0] : selected) + if (isDesktop()) { + const selected = await openFileDialog({ + directory: true, + multiple: false, + }) + if (selected) { + setTargetDir(Array.isArray(selected) ? selected[0] : selected) + } + } else { + setBrowserOpen(true) } } const handleClone = async () => { if (!url || !targetDir) return - // Derive repo name from URL - const repoName = - url - .replace(/\.git$/, "") - .split("/") - .pop() ?? "repo" const fullPath = `${targetDir}/${repoName}` setCloning(true) @@ -85,84 +97,101 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) { } return ( - { - onOpenChange(v) - if (!v) resetForm() - }} - > - - - {t("cloneDialog.title")} - + <> + { + onOpenChange(v) + if (!v) resetForm() + }} + > + + + {t("cloneDialog.title")} + -
-
- - setUrl(e.target.value)} - disabled={cloning} - /> -
- -
- -
+
+
+ setTargetDir(e.target.value)} + id="repo-url" + placeholder={t("cloneDialog.repositoryUrlPlaceholder")} + value={url} + onChange={(e) => setUrl(e.target.value)} disabled={cloning} - className="flex-1" /> -
-
- {error && ( -
-

{error.message}

- {error.detail && ( -

{error.detail}

+
+ +
+ setTargetDir(e.target.value)} + disabled={cloning} + className="flex-1" + /> + +
+ {targetDir && url && ( +

+ {t("cloneDialog.clonePath", { + path: `${targetDir}/${repoName}`, + })} +

)}
- )} -
- - - - - -
+ {error && ( +
+

{error.message}

+ {error.detail && ( +

+ {error.detail} +

+ )} +
+ )} + + + + + + +
+
+ + setTargetDir(path)} + /> + ) } diff --git a/src/components/welcome/folder-actions.tsx b/src/components/welcome/folder-actions.tsx index cc593d6..95d4434 100644 --- a/src/components/welcome/folder-actions.tsx +++ b/src/components/welcome/folder-actions.tsx @@ -5,22 +5,42 @@ import { FolderOpen, GitBranch, Rocket } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" import { openFolderWindow, openProjectBootWindow } from "@/lib/api" -import { openFileDialog } from "@/lib/platform" +import { isDesktop, openFileDialog } from "@/lib/platform" import { Button } from "@/components/ui/button" import { CloneDialog } from "./clone-dialog" import { resolveWelcomeError } from "@/components/welcome/error-utils" +import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog" export function FolderActions() { const t = useTranslations("WelcomePage") const [cloneOpen, setCloneOpen] = useState(false) + const [browserOpen, setBrowserOpen] = useState(false) const handleOpen = async () => { - const result = await openFileDialog({ directory: true, multiple: false }) - if (!result) return - const selected = Array.isArray(result) ? result[0] : result + if (isDesktop()) { + const result = await openFileDialog({ + directory: true, + multiple: false, + }) + if (!result) return + const selected = Array.isArray(result) ? result[0] : result + try { + await openFolderWindow(selected) + } catch (err) { + console.error("[FolderActions] failed to open folder:", err) + const resolvedError = resolveWelcomeError(err) + toast.error(t("toasts.openFolderFailed"), { + description: resolvedError.detail ?? t(resolvedError.key), + }) + } + } else { + setBrowserOpen(true) + } + } + const handleBrowserSelect = async (path: string) => { try { - await openFolderWindow(selected) + await openFolderWindow(path) } catch (err) { console.error("[FolderActions] failed to open folder:", err) const resolvedError = resolveWelcomeError(err) @@ -31,44 +51,52 @@ export function FolderActions() { } return ( -
- - + <> +
+ + - + - -
+ +
+ + + ) } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index ff11974..60b294c 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -56,7 +56,8 @@ "directoryPlaceholder": "اختر مجلد الهدف...", "browseDirectory": "تصفح المجلد", "cancel": "إلغاء", - "clone": "استنساخ" + "clone": "استنساخ", + "clonePath": "مسار الاستنساخ: {path}" } }, "GitCredentialDialog": { @@ -1657,5 +1658,17 @@ "addressLabel": "عنوان الوصول", "tokenLabel": "رمز الوصول", "tokenHint": "أدخل هذا الرمز عند الوصول إلى عميل الويب لأول مرة" + }, + "DirectoryBrowser": { + "title": "تصفح المجلد", + "pathPlaceholder": "أدخل مسار المجلد...", + "goHome": "الذهاب إلى المجلد الرئيسي", + "navigateUp": "الذهاب إلى المجلد الأعلى", + "select": "اختيار", + "cancel": "إلغاء", + "loading": "جاري التحميل...", + "emptyDirectory": "هذا المجلد فارغ", + "errorLoadingDir": "فشل في تحميل المجلد", + "permissionDenied": "تم رفض الإذن" } } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 6a7e518..419f499 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -56,7 +56,8 @@ "directoryPlaceholder": "Zielverzeichnis auswählen...", "browseDirectory": "Verzeichnis durchsuchen", "cancel": "Abbrechen", - "clone": "Klonen" + "clone": "Klonen", + "clonePath": "Klonpfad: {path}" } }, "GitCredentialDialog": { @@ -1657,5 +1658,17 @@ "addressLabel": "Zugriffsadresse", "tokenLabel": "Zugriffstoken", "tokenHint": "Geben Sie dieses Token beim ersten Zugriff auf den Web-Client ein" + }, + "DirectoryBrowser": { + "title": "Verzeichnis durchsuchen", + "pathPlaceholder": "Verzeichnispfad eingeben...", + "goHome": "Zum Heimverzeichnis", + "navigateUp": "Zum übergeordneten Verzeichnis", + "select": "Auswählen", + "cancel": "Abbrechen", + "loading": "Wird geladen...", + "emptyDirectory": "Dieses Verzeichnis ist leer", + "errorLoadingDir": "Verzeichnis konnte nicht geladen werden", + "permissionDenied": "Zugriff verweigert" } } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 6c51e3a..b6d297c 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -56,7 +56,8 @@ "directoryPlaceholder": "Select target directory...", "browseDirectory": "Browse directory", "cancel": "Cancel", - "clone": "Clone" + "clone": "Clone", + "clonePath": "Clone path: {path}" } }, "GitCredentialDialog": { @@ -1657,5 +1658,17 @@ "addressLabel": "Access Address", "tokenLabel": "Access Token", "tokenHint": "Enter this token when accessing the Web client for the first time" + }, + "DirectoryBrowser": { + "title": "Browse Directory", + "pathPlaceholder": "Enter directory path...", + "goHome": "Go to home directory", + "navigateUp": "Go to parent directory", + "select": "Select", + "cancel": "Cancel", + "loading": "Loading...", + "emptyDirectory": "This directory is empty", + "errorLoadingDir": "Failed to load directory", + "permissionDenied": "Permission denied" } } diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index fe37cc4..96ad2d8 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -56,7 +56,8 @@ "directoryPlaceholder": "Selecciona el directorio de destino...", "browseDirectory": "Explorar directorio", "cancel": "Cancelar", - "clone": "Clonar" + "clone": "Clonar", + "clonePath": "Ruta de clonación: {path}" } }, "GitCredentialDialog": { @@ -1657,5 +1658,17 @@ "addressLabel": "Dirección de acceso", "tokenLabel": "Token de acceso", "tokenHint": "Ingrese este token al acceder al cliente Web por primera vez" + }, + "DirectoryBrowser": { + "title": "Explorar directorio", + "pathPlaceholder": "Ingrese la ruta del directorio...", + "goHome": "Ir al directorio principal", + "navigateUp": "Ir al directorio superior", + "select": "Seleccionar", + "cancel": "Cancelar", + "loading": "Cargando...", + "emptyDirectory": "Este directorio está vacío", + "errorLoadingDir": "Error al cargar el directorio", + "permissionDenied": "Permiso denegado" } } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index b16da5b..20e4d83 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -56,7 +56,8 @@ "directoryPlaceholder": "Sélectionnez le répertoire cible...", "browseDirectory": "Parcourir le répertoire", "cancel": "Annuler", - "clone": "Cloner" + "clone": "Cloner", + "clonePath": "Chemin de clonage : {path}" } }, "GitCredentialDialog": { @@ -1657,5 +1658,17 @@ "addressLabel": "Adresse d'accès", "tokenLabel": "Token d'accès", "tokenHint": "Entrez ce token lors du premier accès au client Web" + }, + "DirectoryBrowser": { + "title": "Parcourir le répertoire", + "pathPlaceholder": "Entrez le chemin du répertoire...", + "goHome": "Aller au répertoire personnel", + "navigateUp": "Aller au répertoire parent", + "select": "Sélectionner", + "cancel": "Annuler", + "loading": "Chargement...", + "emptyDirectory": "Ce répertoire est vide", + "errorLoadingDir": "Échec du chargement du répertoire", + "permissionDenied": "Permission refusée" } } diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 8f1fc96..5d68106 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -56,7 +56,8 @@ "directoryPlaceholder": "保存先ディレクトリを選択...", "browseDirectory": "ディレクトリを参照", "cancel": "キャンセル", - "clone": "クローン" + "clone": "クローン", + "clonePath": "クローンパス: {path}" } }, "GitCredentialDialog": { @@ -1657,5 +1658,17 @@ "addressLabel": "アクセスアドレス", "tokenLabel": "アクセストークン", "tokenHint": "Webクライアントの初回アクセス時にこのトークンを入力してください" + }, + "DirectoryBrowser": { + "title": "ディレクトリを参照", + "pathPlaceholder": "ディレクトリパスを入力...", + "goHome": "ホームディレクトリへ", + "navigateUp": "親ディレクトリへ", + "select": "選択", + "cancel": "キャンセル", + "loading": "読み込み中...", + "emptyDirectory": "このディレクトリは空です", + "errorLoadingDir": "ディレクトリの読み込みに失敗しました", + "permissionDenied": "アクセス権がありません" } } diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index eabada9..073371b 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -56,7 +56,8 @@ "directoryPlaceholder": "대상 디렉터리 선택...", "browseDirectory": "디렉터리 찾아보기", "cancel": "취소", - "clone": "클론" + "clone": "클론", + "clonePath": "클론 경로: {path}" } }, "GitCredentialDialog": { @@ -1657,5 +1658,17 @@ "addressLabel": "접속 주소", "tokenLabel": "접속 토큰", "tokenHint": "웹 클라이언트 첫 접속 시 이 토큰을 입력하세요" + }, + "DirectoryBrowser": { + "title": "디렉토리 찾아보기", + "pathPlaceholder": "디렉토리 경로 입력...", + "goHome": "홈 디렉토리로 이동", + "navigateUp": "상위 디렉토리로 이동", + "select": "선택", + "cancel": "취소", + "loading": "로딩 중...", + "emptyDirectory": "이 디렉토리는 비어 있습니다", + "errorLoadingDir": "디렉토리 로딩 실패", + "permissionDenied": "권한이 없습니다" } } diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 89a20b3..030c527 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -56,7 +56,8 @@ "directoryPlaceholder": "Selecione o diretório de destino...", "browseDirectory": "Procurar diretório", "cancel": "Cancelar", - "clone": "Clonar" + "clone": "Clonar", + "clonePath": "Caminho de clonagem: {path}" } }, "GitCredentialDialog": { @@ -1657,5 +1658,17 @@ "addressLabel": "Endereço de acesso", "tokenLabel": "Token de acesso", "tokenHint": "Insira este token ao acessar o cliente Web pela primeira vez" + }, + "DirectoryBrowser": { + "title": "Explorar diretório", + "pathPlaceholder": "Digite o caminho do diretório...", + "goHome": "Ir para o diretório inicial", + "navigateUp": "Ir para o diretório superior", + "select": "Selecionar", + "cancel": "Cancelar", + "loading": "Carregando...", + "emptyDirectory": "Este diretório está vazio", + "errorLoadingDir": "Falha ao carregar o diretório", + "permissionDenied": "Permissão negada" } } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 45736ae..b5daf0c 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -56,7 +56,8 @@ "directoryPlaceholder": "选择目标目录...", "browseDirectory": "浏览目录", "cancel": "取消", - "clone": "克隆" + "clone": "克隆", + "clonePath": "克隆路径: {path}" } }, "GitCredentialDialog": { @@ -1657,5 +1658,17 @@ "addressLabel": "访问地址", "tokenLabel": "访问 Token", "tokenHint": "Web 客户端首次访问时需输入此 Token" + }, + "DirectoryBrowser": { + "title": "浏览目录", + "pathPlaceholder": "输入目录路径...", + "goHome": "回到主目录", + "navigateUp": "返回上级目录", + "select": "选择", + "cancel": "取消", + "loading": "加载中...", + "emptyDirectory": "此目录为空", + "errorLoadingDir": "加载目录失败", + "permissionDenied": "权限不足" } } diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 0c83bb9..91aa622 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -56,7 +56,8 @@ "directoryPlaceholder": "選擇目標目錄...", "browseDirectory": "瀏覽目錄", "cancel": "取消", - "clone": "複製" + "clone": "複製", + "clonePath": "克隆路徑: {path}" } }, "GitCredentialDialog": { @@ -1657,5 +1658,17 @@ "addressLabel": "存取位址", "tokenLabel": "存取 Token", "tokenHint": "Web 用戶端首次存取時需輸入此 Token" + }, + "DirectoryBrowser": { + "title": "瀏覽目錄", + "pathPlaceholder": "輸入目錄路徑...", + "goHome": "回到主目錄", + "navigateUp": "返回上層目錄", + "select": "選擇", + "cancel": "取消", + "loading": "載入中...", + "emptyDirectory": "此目錄為空", + "errorLoadingDir": "載入目錄失敗", + "permissionDenied": "權限不足" } } diff --git a/src/lib/api.ts b/src/lib/api.ts index 1e45abc..c9889b0 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -36,6 +36,7 @@ import type { TerminalInfo, PromptInputBlock, FileTreeNode, + DirectoryEntry, FilePreviewContent, FileEditContent, FileSaveResult, @@ -1107,6 +1108,18 @@ export async function bootstrapFolderCommandsFromPackageJson( }) } +// Directory browser (for web/server mode) + +export async function getHomeDirectory(): Promise { + return getTransport().call("get_home_directory") +} + +export async function listDirectoryEntries( + path: string +): Promise { + return getTransport().call("list_directory_entries", { path }) +} + // File tree and git log commands export async function getFileTree( diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index ed680e5..56efc87 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -36,6 +36,7 @@ import type { TerminalInfo, PromptInputBlock, FileTreeNode, + DirectoryEntry, FilePreviewContent, FileEditContent, FileSaveResult, @@ -972,6 +973,18 @@ export async function bootstrapFolderCommandsFromPackageJson( }) } +// Directory browser + +export async function getHomeDirectory(): Promise { + return invoke("get_home_directory") +} + +export async function listDirectoryEntries( + path: string +): Promise { + return invoke("list_directory_entries", { path }) +} + // File tree and git log commands export async function getFileTree( diff --git a/src/lib/types.ts b/src/lib/types.ts index e3aea1a..4c360a3 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -730,6 +730,12 @@ export type FileTreeNode = | { kind: "file"; name: string; path: string } | { kind: "dir"; name: string; path: string; children: FileTreeNode[] } +export interface DirectoryEntry { + name: string + path: string + hasChildren: boolean +} + export interface FilePreviewContent { path: string content: string