优化web/server模式下的目录选择,现在支持目录树选择,而不是硬文本写入

This commit is contained in:
xintaofei
2026-03-30 14:59:23 +08:00
parent 9b9169f61d
commit 8d393b3b4f
23 changed files with 1077 additions and 344 deletions

View File

@@ -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<String, AppCommandError> {
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<Vec<DirectoryEntry>, 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<DirectoryEntry> = 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)] #[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn get_file_tree( pub async fn get_file_tree(
path: String, path: String,

View File

@@ -270,6 +270,8 @@ mod tauri_app {
folders::save_folder_opened_conversations, folders::save_folder_opened_conversations,
folders::start_file_tree_watch, folders::start_file_tree_watch,
folders::stop_file_tree_watch, folders::stop_file_tree_watch,
folders::get_home_directory,
folders::list_directory_entries,
folders::get_file_tree, folders::get_file_tree,
folders::read_file_base64, folders::read_file_base64,
folders::read_file_preview, folders::read_file_preview,

View File

@@ -110,6 +110,24 @@ pub async fn get_git_branch(
Ok(Json(result)) Ok(Json(result))
} }
pub async fn get_home_directory() -> Result<Json<String>, 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<ListDirectoryEntriesParams>,
) -> Result<Json<Vec<folder_commands::DirectoryEntry>>, AppCommandError> {
let result = folder_commands::list_directory_entries(params.path).await?;
Ok(Json(result))
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct GetFileTreeParams { pub struct GetFileTreeParams {

View File

@@ -50,6 +50,8 @@ pub fn build_router(state: Arc<AppState>, token: String, static_dir: std::path::
.route("/create_folder_directory", post(handlers::folders::create_folder_directory)) .route("/create_folder_directory", post(handlers::folders::create_folder_directory))
.route("/save_folder_opened_conversations", post(handlers::folders::save_folder_opened_conversations)) .route("/save_folder_opened_conversations", post(handlers::folders::save_folder_opened_conversations))
.route("/get_git_branch", post(handlers::folders::get_git_branch)) .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("/get_file_tree", post(handlers::folders::get_file_tree))
.route("/start_file_tree_watch", post(handlers::folders::start_file_tree_watch)) .route("/start_file_tree_watch", post(handlers::folders::start_file_tree_watch))
.route("/stop_file_tree_watch", post(handlers::folders::stop_file_tree_watch)) .route("/stop_file_tree_watch", post(handlers::folders::stop_file_tree_watch))

View File

@@ -24,9 +24,10 @@ import {
openFolderWindow, openFolderWindow,
openProjectBootWindow, openProjectBootWindow,
} from "@/lib/api" } from "@/lib/api"
import { openFileDialog } from "@/lib/platform" import { isDesktop, openFileDialog } from "@/lib/platform"
import { useFolderContext } from "@/contexts/folder-context" import { useFolderContext } from "@/contexts/folder-context"
import { CloneDialog } from "@/components/welcome/clone-dialog" import { CloneDialog } from "@/components/welcome/clone-dialog"
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
import type { FolderHistoryEntry } from "@/lib/types" import type { FolderHistoryEntry } from "@/lib/types"
export function FolderNameDropdown() { export function FolderNameDropdown() {
@@ -35,6 +36,7 @@ export function FolderNameDropdown() {
const [openFolders, setOpenFolders] = useState<FolderHistoryEntry[]>([]) const [openFolders, setOpenFolders] = useState<FolderHistoryEntry[]>([])
const [history, setHistory] = useState<FolderHistoryEntry[]>([]) const [history, setHistory] = useState<FolderHistoryEntry[]>([])
const [cloneOpen, setCloneOpen] = useState(false) const [cloneOpen, setCloneOpen] = useState(false)
const [browserOpen, setBrowserOpen] = useState(false)
const folderPath = folder?.path ?? "" const folderPath = folder?.path ?? ""
const folderName = folder?.name ?? t("fallbackFolderName") const folderName = folder?.name ?? t("fallbackFolderName")
@@ -57,11 +59,19 @@ export function FolderNameDropdown() {
} }
async function handleOpenFolder() { async function handleOpenFolder() {
const selected = await openFileDialog({ directory: true, multiple: false }) if (isDesktop()) {
if (selected) { const selected = await openFileDialog({
await openFolderWindow(Array.isArray(selected) ? selected[0] : selected, { directory: true,
newWindow: 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() {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} /> <CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} />
<DirectoryBrowserDialog
open={browserOpen}
onOpenChange={setBrowserOpen}
onSelect={(path) => {
openFolderWindow(path, { newWindow: true }).catch((err) => {
console.error("[FolderNameDropdown] failed to open folder:", err)
})
}}
/>
</> </>
) )
} }

View File

@@ -19,7 +19,7 @@ import {
} from "lucide-react" } from "lucide-react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { getGitBranch, openFolderWindow, openSettingsWindow } from "@/lib/api" 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 { useFolderContext } from "@/contexts/folder-context"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { useSidebarContext } from "@/contexts/sidebar-context" import { useSidebarContext } from "@/contexts/sidebar-context"
@@ -38,6 +38,7 @@ import { FolderNameDropdown } from "./folder-name-dropdown"
import { BranchDropdown } from "./branch-dropdown" import { BranchDropdown } from "./branch-dropdown"
import { CommandDropdown } from "./command-dropdown" import { CommandDropdown } from "./command-dropdown"
import { SearchCommandDialog } from "@/components/conversations/search-command-dialog" import { SearchCommandDialog } from "@/components/conversations/search-command-dialog"
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const MODE_TABS = [ const MODE_TABS = [
@@ -71,6 +72,7 @@ export function FolderTitleBar() {
const { shortcuts } = useShortcutSettings() const { shortcuts } = useShortcutSettings()
const [branch, setBranch] = useState<string | null>(null) const [branch, setBranch] = useState<string | null>(null)
const [searchOpen, setSearchOpen] = useState(false) const [searchOpen, setSearchOpen] = useState(false)
const [browserOpen, setBrowserOpen] = useState(false)
const intervalRef = useRef<ReturnType<typeof setInterval> | undefined>( const intervalRef = useRef<ReturnType<typeof setInterval> | undefined>(
undefined undefined
) )
@@ -78,13 +80,20 @@ export function FolderTitleBar() {
const folderPath = folder?.path ?? "" const folderPath = folder?.path ?? ""
const handleOpenFolder = useCallback(async () => { const handleOpenFolder = useCallback(async () => {
try { if (isDesktop()) {
const result = await openFileDialog({ directory: true, multiple: false }) try {
if (!result) return const result = await openFileDialog({
const selected = Array.isArray(result) ? result[0] : result directory: true,
await openFolderWindow(selected, { newWindow: true }) multiple: false,
} catch (err) { })
console.error("[FolderTitleBar] failed to open folder:", err) 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() {
} }
/> />
<SearchCommandDialog open={searchOpen} onOpenChange={setSearchOpen} /> <SearchCommandDialog open={searchOpen} onOpenChange={setSearchOpen} />
<DirectoryBrowserDialog
open={browserOpen}
onOpenChange={setBrowserOpen}
onSelect={(path) => {
openFolderWindow(path, { newWindow: true }).catch((err) => {
console.error("[FolderTitleBar] failed to open folder:", err)
})
}}
/>
</> </>
) )
} }

View File

@@ -34,13 +34,14 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { openFileDialog } from "@/lib/platform" import { isDesktop, openFileDialog } from "@/lib/platform"
import { import {
createShadcnProject, createShadcnProject,
openFolderWindow, openFolderWindow,
detectPackageManager, detectPackageManager,
} from "@/lib/api" } from "@/lib/api"
import { extractAppCommandError, toErrorMessage } from "@/lib/app-error" import { extractAppCommandError, toErrorMessage } from "@/lib/app-error"
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
import { import {
BASE_OPTIONS, BASE_OPTIONS,
FRAMEWORK_OPTIONS, FRAMEWORK_OPTIONS,
@@ -67,6 +68,7 @@ export function CreateProjectDialog({
const [rtl, setRtl] = useState(false) const [rtl, setRtl] = useState(false)
const [advancedOpen, setAdvancedOpen] = useState(false) const [advancedOpen, setAdvancedOpen] = useState(false)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [browserOpen, setBrowserOpen] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [pmVersion, setPmVersion] = useState<string | null>(null) const [pmVersion, setPmVersion] = useState<string | null>(null)
@@ -96,10 +98,14 @@ export function CreateProjectDialog({
}, [open, packageManager, checkPackageManager]) }, [open, packageManager, checkPackageManager])
const handleBrowse = async () => { const handleBrowse = async () => {
const result = await openFileDialog({ directory: true, multiple: false }) if (isDesktop()) {
if (!result) return const result = await openFileDialog({ directory: true, multiple: false })
const selected = Array.isArray(result) ? result[0] : result if (!result) return
setSaveDirectory(selected) const selected = Array.isArray(result) ? result[0] : result
setSaveDirectory(selected)
} else {
setBrowserOpen(true)
}
} }
const handleCreate = async () => { const handleCreate = async () => {
@@ -151,208 +157,216 @@ export function CreateProjectDialog({
pmInstalled === true pmInstalled === true
return ( return (
<Dialog <>
open={open} <Dialog
onOpenChange={(v) => { open={open}
onOpenChange(v) onOpenChange={(v) => {
if (!v) resetForm() onOpenChange(v)
}} if (!v) resetForm()
> }}
<DialogContent className="sm:max-w-md"> >
<DialogHeader> <DialogContent className="sm:max-w-md">
<DialogTitle>{t("createDialog.title")}</DialogTitle> <DialogHeader>
</DialogHeader> <DialogTitle>{t("createDialog.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>{t("createDialog.projectName")}</Label> <Label>{t("createDialog.projectName")}</Label>
<Input
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder={t("createDialog.projectNamePlaceholder")}
disabled={creating}
/>
</div>
<div className="space-y-1.5">
<Label>{t("createDialog.saveDirectory")}</Label>
<div className="flex gap-2">
<Input <Input
value={saveDirectory} value={projectName}
onChange={(e) => setSaveDirectory(e.target.value)} onChange={(e) => setProjectName(e.target.value)}
placeholder={t("createDialog.saveDirectoryPlaceholder")} placeholder={t("createDialog.projectNamePlaceholder")}
disabled={creating} disabled={creating}
className="flex-1"
/> />
<Button
variant="outline"
size="sm"
onClick={handleBrowse}
disabled={creating}
type="button"
>
<FolderOpen className="h-4 w-4" />
</Button>
</div> </div>
{saveDirectory && projectName.trim() && (
<p className="text-xs text-muted-foreground"> <div className="space-y-1.5">
{t("createDialog.projectPath", { <Label>{t("createDialog.saveDirectory")}</Label>
path: `${saveDirectory}/${projectName.trim()}`, <div className="flex gap-2">
})} <Input
</p> value={saveDirectory}
onChange={(e) => setSaveDirectory(e.target.value)}
placeholder={t("createDialog.saveDirectoryPlaceholder")}
disabled={creating}
className="flex-1"
/>
<Button
variant="outline"
size="sm"
onClick={handleBrowse}
disabled={creating}
type="button"
>
<FolderOpen className="h-4 w-4" />
</Button>
</div>
{saveDirectory && projectName.trim() && (
<p className="text-xs text-muted-foreground">
{t("createDialog.projectPath", {
path: `${saveDirectory}/${projectName.trim()}`,
})}
</p>
)}
</div>
<div className="space-y-1.5">
<Label>{t("createDialog.packageManager")}</Label>
<Tabs
value={packageManager}
onValueChange={setPackageManager}
className="gap-0"
>
<TabsList className="w-full">
{PACKAGE_MANAGER_OPTIONS.map((opt) => (
<TabsTrigger
key={opt.value}
value={opt.value}
className="flex-1"
disabled={creating}
>
{opt.label}
</TabsTrigger>
))}
</TabsList>
{PACKAGE_MANAGER_OPTIONS.map((opt) => (
<TabsContent key={opt.value} value={opt.value}>
<div className="flex h-8 items-center gap-1.5 text-sm">
{pmChecking ? (
<>
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">
{t("createDialog.pmChecking")}
</span>
</>
) : pmInstalled ? (
<>
<CircleCheck className="size-3.5 text-emerald-500" />
<span className="text-muted-foreground">
{opt.label} v{pmVersion}
</span>
</>
) : (
<>
<CircleX className="size-3.5 text-destructive" />
<span className="text-muted-foreground">
{t("createDialog.pmNotInstalled")}
</span>
</>
)}
</div>
</TabsContent>
))}
</Tabs>
</div>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-auto gap-1 px-0 text-xs text-muted-foreground"
disabled={creating}
>
<ChevronsUpDown className="size-3.5" />
{t("createDialog.advancedOptions")}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label>{t("createDialog.frameworkTemplate")}</Label>
<RadioGroup
value={framework}
onValueChange={setFramework}
disabled={creating}
className="grid grid-cols-2 gap-2"
>
{FRAMEWORK_OPTIONS.map((opt) => (
<FieldLabel key={opt.value} htmlFor={`fw-${opt.value}`}>
<Field orientation="horizontal">
<FieldContent>
<FieldTitle>{opt.label}</FieldTitle>
</FieldContent>
<RadioGroupItem
value={opt.value}
id={`fw-${opt.value}`}
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
</div>
<div className="space-y-1.5">
<Label>{t("createDialog.base")}</Label>
<RadioGroup
value={base}
onValueChange={setBase}
disabled={creating}
className="grid grid-cols-2 gap-2"
>
{BASE_OPTIONS.map((opt) => (
<FieldLabel key={opt.value} htmlFor={`base-${opt.value}`}>
<Field orientation="horizontal">
<FieldContent>
<FieldTitle>{opt.label}</FieldTitle>
</FieldContent>
<RadioGroupItem
value={opt.value}
id={`base-${opt.value}`}
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
</div>
<label className="flex cursor-pointer items-center gap-3 rounded-lg border p-3">
<Switch
checked={rtl}
onCheckedChange={setRtl}
disabled={creating}
/>
<div className="space-y-0.5">
<div className="text-sm font-medium">
{t("createDialog.enableRtl")}
</div>
<div className="text-xs text-muted-foreground">
{t("createDialog.enableRtlDescription")}
</div>
</div>
</label>
</CollapsibleContent>
</Collapsible>
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)} )}
</div> </div>
<div className="space-y-1.5"> <DialogFooter>
<Label>{t("createDialog.packageManager")}</Label> <Button
<Tabs variant="outline"
value={packageManager} onClick={() => onOpenChange(false)}
onValueChange={setPackageManager} disabled={creating}
className="gap-0"
> >
<TabsList className="w-full"> {t("createDialog.cancel")}
{PACKAGE_MANAGER_OPTIONS.map((opt) => ( </Button>
<TabsTrigger <Button onClick={handleCreate} disabled={!canCreate || creating}>
key={opt.value} {creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
value={opt.value} {creating ? t("createDialog.creating") : t("createDialog.create")}
className="flex-1" </Button>
disabled={creating} </DialogFooter>
> </DialogContent>
{opt.label} </Dialog>
</TabsTrigger>
))}
</TabsList>
{PACKAGE_MANAGER_OPTIONS.map((opt) => (
<TabsContent key={opt.value} value={opt.value}>
<div className="flex h-8 items-center gap-1.5 text-sm">
{pmChecking ? (
<>
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">
{t("createDialog.pmChecking")}
</span>
</>
) : pmInstalled ? (
<>
<CircleCheck className="size-3.5 text-emerald-500" />
<span className="text-muted-foreground">
{opt.label} v{pmVersion}
</span>
</>
) : (
<>
<CircleX className="size-3.5 text-destructive" />
<span className="text-muted-foreground">
{t("createDialog.pmNotInstalled")}
</span>
</>
)}
</div>
</TabsContent>
))}
</Tabs>
</div>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}> <DirectoryBrowserDialog
<CollapsibleTrigger asChild> open={browserOpen}
<Button onOpenChange={setBrowserOpen}
variant="ghost" onSelect={(path) => setSaveDirectory(path)}
size="sm" />
className="h-auto gap-1 px-0 text-xs text-muted-foreground" </>
disabled={creating}
>
<ChevronsUpDown className="size-3.5" />
{t("createDialog.advancedOptions")}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label>{t("createDialog.frameworkTemplate")}</Label>
<RadioGroup
value={framework}
onValueChange={setFramework}
disabled={creating}
className="grid grid-cols-2 gap-2"
>
{FRAMEWORK_OPTIONS.map((opt) => (
<FieldLabel key={opt.value} htmlFor={`fw-${opt.value}`}>
<Field orientation="horizontal">
<FieldContent>
<FieldTitle>{opt.label}</FieldTitle>
</FieldContent>
<RadioGroupItem
value={opt.value}
id={`fw-${opt.value}`}
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
</div>
<div className="space-y-1.5">
<Label>{t("createDialog.base")}</Label>
<RadioGroup
value={base}
onValueChange={setBase}
disabled={creating}
className="grid grid-cols-2 gap-2"
>
{BASE_OPTIONS.map((opt) => (
<FieldLabel key={opt.value} htmlFor={`base-${opt.value}`}>
<Field orientation="horizontal">
<FieldContent>
<FieldTitle>{opt.label}</FieldTitle>
</FieldContent>
<RadioGroupItem
value={opt.value}
id={`base-${opt.value}`}
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
</div>
<label className="flex cursor-pointer items-center gap-3 rounded-lg border p-3">
<Switch
checked={rtl}
onCheckedChange={setRtl}
disabled={creating}
/>
<div className="space-y-0.5">
<div className="text-sm font-medium">
{t("createDialog.enableRtl")}
</div>
<div className="text-xs text-muted-foreground">
{t("createDialog.enableRtlDescription")}
</div>
</div>
</label>
</CollapsibleContent>
</Collapsible>
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={creating}
>
{t("createDialog.cancel")}
</Button>
<Button onClick={handleCreate} disabled={!canCreate || creating}>
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{creating ? t("createDialog.creating") : t("createDialog.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) )
} }

View File

@@ -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<Map<string, DirectoryEntry[]>>(
new Map()
)
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
const [selectedPath, setSelectedPath] = useState<string | null>(null)
const [loading, setLoading] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null)
const initialized = useRef(false)
const loadEntries = useCallback(
async (path: string): Promise<DirectoryEntry[] | null> => {
// 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 (
<div
className="flex items-center gap-2 py-2 text-sm text-muted-foreground"
style={{ paddingLeft: `${depth * 20 + 8}px` }}
>
<Loader2 className="size-3.5 animate-spin" />
<span>{t("loading")}</span>
</div>
)
}
if (!children) return null
if (children.length === 0) {
return (
<div
className="py-2 text-sm text-muted-foreground"
style={{ paddingLeft: `${depth * 20 + 28}px` }}
>
{t("emptyDirectory")}
</div>
)
}
return children.map((entry) => {
const isExpanded = expandedPaths.has(entry.path)
const isSelected = selectedPath === entry.path
return (
<div key={entry.path}>
<button
className={cn(
"flex w-full items-center gap-1 rounded px-2 py-1 text-left text-sm transition-colors hover:bg-muted/50",
isSelected && "bg-accent text-accent-foreground"
)}
style={{ paddingLeft: `${depth * 20 + 8}px` }}
onClick={() => handleSelect(entry.path)}
onDoubleClick={() => handleDoubleClick(entry.path)}
type="button"
>
<span
className="shrink-0 p-0.5"
onClick={(e) => {
e.stopPropagation()
if (entry.hasChildren) {
handleToggleExpand(entry.path)
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation()
if (entry.hasChildren) {
handleToggleExpand(entry.path)
}
}
}}
role="button"
tabIndex={0}
>
<ChevronRight
className={cn(
"size-3.5 text-muted-foreground transition-transform",
isExpanded && "rotate-90",
!entry.hasChildren && "invisible"
)}
/>
</span>
{isExpanded ? (
<FolderOpenIcon className="size-4 shrink-0 text-blue-500" />
) : (
<FolderIcon className="size-4 shrink-0 text-blue-500" />
)}
<span className="truncate">{entry.name}</span>
</button>
{isExpanded && renderEntries(entry.path, depth + 1)}
</div>
)
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{title ?? t("title")}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0"
onClick={handleGoHome}
title={t("goHome")}
type="button"
>
<Home className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0"
onClick={handleNavigateUp}
title={t("navigateUp")}
type="button"
>
<ChevronUp className="size-4" />
</Button>
<Input
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handlePathInputKeyDown}
placeholder={t("pathPlaceholder")}
className="flex-1 h-8 text-sm font-mono"
/>
</div>
<ScrollArea className="h-[300px] rounded-md border">
<div className="p-1">
{renderEntries(rootPath, 0)}
{error && !loading.size && (
<div className="p-4 text-center text-sm text-destructive">
{error}
</div>
)}
</div>
</ScrollArea>
{selectedPath && (
<p className="truncate text-xs text-muted-foreground">
{selectedPath}
</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
type="button"
>
{t("cancel")}
</Button>
<Button
onClick={handleConfirm}
disabled={!selectedPath}
type="button"
>
{t("select")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,10 +1,10 @@
"use client" "use client"
import { useState } from "react" import { useState, useMemo } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { toast } from "sonner"
import { cloneRepository, openFolderWindow } from "@/lib/api" 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 { useGitCredential } from "@/contexts/git-credential-context"
import { import {
Dialog, Dialog,
@@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { FolderOpen, Loader2 } from "lucide-react" import { FolderOpen, Loader2 } from "lucide-react"
import { resolveCloneError } from "@/components/welcome/error-utils" import { resolveCloneError } from "@/components/welcome/error-utils"
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
interface CloneDialogProps { interface CloneDialogProps {
open: boolean open: boolean
@@ -30,27 +31,38 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) {
const [url, setUrl] = useState("") const [url, setUrl] = useState("")
const [targetDir, setTargetDir] = useState("") const [targetDir, setTargetDir] = useState("")
const [cloning, setCloning] = useState(false) const [cloning, setCloning] = useState(false)
const [browserOpen, setBrowserOpen] = useState(false)
const [error, setError] = useState<{ const [error, setError] = useState<{
message: string message: string
detail: string | null detail: string | null
} | null>(null) } | null>(null)
const repoName = useMemo(
() =>
url
.replace(/\.git$/, "")
.split("/")
.pop() ?? "repo",
[url]
)
const handleBrowse = async () => { const handleBrowse = async () => {
const selected = await openFileDialog({ directory: true, multiple: false }) if (isDesktop()) {
if (selected) { const selected = await openFileDialog({
setTargetDir(Array.isArray(selected) ? selected[0] : selected) directory: true,
multiple: false,
})
if (selected) {
setTargetDir(Array.isArray(selected) ? selected[0] : selected)
}
} else {
setBrowserOpen(true)
} }
} }
const handleClone = async () => { const handleClone = async () => {
if (!url || !targetDir) return if (!url || !targetDir) return
// Derive repo name from URL
const repoName =
url
.replace(/\.git$/, "")
.split("/")
.pop() ?? "repo"
const fullPath = `${targetDir}/${repoName}` const fullPath = `${targetDir}/${repoName}`
setCloning(true) setCloning(true)
@@ -85,84 +97,101 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) {
} }
return ( return (
<Dialog <>
open={isOpen} <Dialog
onOpenChange={(v) => { open={isOpen}
onOpenChange(v) onOpenChange={(v) => {
if (!v) resetForm() onOpenChange(v)
}} if (!v) resetForm()
> }}
<DialogContent className="sm:max-w-md"> >
<DialogHeader> <DialogContent className="sm:max-w-md">
<DialogTitle>{t("cloneDialog.title")}</DialogTitle> <DialogHeader>
</DialogHeader> <DialogTitle>{t("cloneDialog.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="repo-url">{t("cloneDialog.repositoryUrl")}</Label> <Label htmlFor="repo-url">{t("cloneDialog.repositoryUrl")}</Label>
<Input
id="repo-url"
placeholder={t("cloneDialog.repositoryUrlPlaceholder")}
value={url}
onChange={(e) => setUrl(e.target.value)}
disabled={cloning}
/>
</div>
<div className="space-y-2">
<Label htmlFor="target-dir">{t("cloneDialog.directory")}</Label>
<div className="flex gap-2">
<Input <Input
id="target-dir" id="repo-url"
placeholder={t("cloneDialog.directoryPlaceholder")} placeholder={t("cloneDialog.repositoryUrlPlaceholder")}
value={targetDir} value={url}
onChange={(e) => setTargetDir(e.target.value)} onChange={(e) => setUrl(e.target.value)}
disabled={cloning} disabled={cloning}
className="flex-1"
/> />
<Button
variant="outline"
size="icon"
onClick={handleBrowse}
disabled={cloning}
title={t("cloneDialog.browseDirectory")}
aria-label={t("cloneDialog.browseDirectory")}
type="button"
>
<FolderOpen className="h-4 w-4" />
</Button>
</div> </div>
</div>
{error && ( <div className="space-y-2">
<div className="space-y-1"> <Label htmlFor="target-dir">{t("cloneDialog.directory")}</Label>
<p className="text-sm text-destructive">{error.message}</p> <div className="flex gap-2">
{error.detail && ( <Input
<p className="text-xs text-muted-foreground">{error.detail}</p> id="target-dir"
placeholder={t("cloneDialog.directoryPlaceholder")}
value={targetDir}
onChange={(e) => setTargetDir(e.target.value)}
disabled={cloning}
className="flex-1"
/>
<Button
variant="outline"
size="icon"
onClick={handleBrowse}
disabled={cloning}
title={t("cloneDialog.browseDirectory")}
aria-label={t("cloneDialog.browseDirectory")}
type="button"
>
<FolderOpen className="h-4 w-4" />
</Button>
</div>
{targetDir && url && (
<p className="text-xs text-muted-foreground">
{t("cloneDialog.clonePath", {
path: `${targetDir}/${repoName}`,
})}
</p>
)} )}
</div> </div>
)}
</div>
<DialogFooter> {error && (
<Button <div className="space-y-1">
variant="outline" <p className="text-sm text-destructive">{error.message}</p>
onClick={() => onOpenChange(false)} {error.detail && (
disabled={cloning} <p className="text-xs text-muted-foreground">
type="button" {error.detail}
> </p>
{t("cloneDialog.cancel")} )}
</Button> </div>
<Button )}
onClick={handleClone} </div>
disabled={!url || !targetDir || cloning}
type="button" <DialogFooter>
> <Button
{cloning && <Loader2 className="h-4 w-4 mr-2 animate-spin" />} variant="outline"
{t("cloneDialog.clone")} onClick={() => onOpenChange(false)}
</Button> disabled={cloning}
</DialogFooter> type="button"
</DialogContent> >
</Dialog> {t("cloneDialog.cancel")}
</Button>
<Button
onClick={handleClone}
disabled={!url || !targetDir || cloning}
type="button"
>
{cloning && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{t("cloneDialog.clone")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DirectoryBrowserDialog
open={browserOpen}
onOpenChange={setBrowserOpen}
onSelect={(path) => setTargetDir(path)}
/>
</>
) )
} }

View File

@@ -5,22 +5,42 @@ import { FolderOpen, GitBranch, Rocket } from "lucide-react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { toast } from "sonner"
import { openFolderWindow, openProjectBootWindow } from "@/lib/api" import { openFolderWindow, openProjectBootWindow } from "@/lib/api"
import { openFileDialog } from "@/lib/platform" import { isDesktop, openFileDialog } from "@/lib/platform"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { CloneDialog } from "./clone-dialog" import { CloneDialog } from "./clone-dialog"
import { resolveWelcomeError } from "@/components/welcome/error-utils" import { resolveWelcomeError } from "@/components/welcome/error-utils"
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
export function FolderActions() { export function FolderActions() {
const t = useTranslations("WelcomePage") const t = useTranslations("WelcomePage")
const [cloneOpen, setCloneOpen] = useState(false) const [cloneOpen, setCloneOpen] = useState(false)
const [browserOpen, setBrowserOpen] = useState(false)
const handleOpen = async () => { const handleOpen = async () => {
const result = await openFileDialog({ directory: true, multiple: false }) if (isDesktop()) {
if (!result) return const result = await openFileDialog({
const selected = Array.isArray(result) ? result[0] : result 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 { try {
await openFolderWindow(selected) await openFolderWindow(path)
} catch (err) { } catch (err) {
console.error("[FolderActions] failed to open folder:", err) console.error("[FolderActions] failed to open folder:", err)
const resolvedError = resolveWelcomeError(err) const resolvedError = resolveWelcomeError(err)
@@ -31,44 +51,52 @@ export function FolderActions() {
} }
return ( return (
<div className="w-full flex flex-col gap-1 px-3"> <>
<Button <div className="w-full flex flex-col gap-1 px-3">
variant="ghost" <Button
className="justify-start gap-2 h-9" variant="ghost"
onClick={handleOpen} className="justify-start gap-2 h-9"
type="button" onClick={handleOpen}
> type="button"
<FolderOpen className="h-4 w-4" /> >
{t("openFolder")} <FolderOpen className="h-4 w-4" />
</Button> {t("openFolder")}
<Button </Button>
variant="ghost" <Button
className="justify-start gap-2 h-9" variant="ghost"
onClick={() => setCloneOpen(true)} className="justify-start gap-2 h-9"
type="button" onClick={() => setCloneOpen(true)}
> type="button"
<GitBranch className="h-4 w-4" /> >
{t("cloneRepository")} <GitBranch className="h-4 w-4" />
</Button> {t("cloneRepository")}
</Button>
<Button <Button
variant="ghost" variant="ghost"
className="justify-start gap-2 h-9" className="justify-start gap-2 h-9"
onClick={async () => { onClick={async () => {
try { try {
await openProjectBootWindow("welcome") await openProjectBootWindow("welcome")
} catch (err) { } catch (err) {
console.error("[FolderActions] failed to open project boot:", err) console.error("[FolderActions] failed to open project boot:", err)
toast.error(t("toasts.openProjectBootFailed")) toast.error(t("toasts.openProjectBootFailed"))
} }
}} }}
type="button" type="button"
> >
<Rocket className="h-4 w-4" /> <Rocket className="h-4 w-4" />
{t("projectBoot")} {t("projectBoot")}
</Button> </Button>
<CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} /> <CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} />
</div> </div>
<DirectoryBrowserDialog
open={browserOpen}
onOpenChange={setBrowserOpen}
onSelect={handleBrowserSelect}
/>
</>
) )
} }

View File

@@ -56,7 +56,8 @@
"directoryPlaceholder": "اختر مجلد الهدف...", "directoryPlaceholder": "اختر مجلد الهدف...",
"browseDirectory": "تصفح المجلد", "browseDirectory": "تصفح المجلد",
"cancel": "إلغاء", "cancel": "إلغاء",
"clone": "استنساخ" "clone": "استنساخ",
"clonePath": "مسار الاستنساخ: {path}"
} }
}, },
"GitCredentialDialog": { "GitCredentialDialog": {
@@ -1657,5 +1658,17 @@
"addressLabel": "عنوان الوصول", "addressLabel": "عنوان الوصول",
"tokenLabel": "رمز الوصول", "tokenLabel": "رمز الوصول",
"tokenHint": "أدخل هذا الرمز عند الوصول إلى عميل الويب لأول مرة" "tokenHint": "أدخل هذا الرمز عند الوصول إلى عميل الويب لأول مرة"
},
"DirectoryBrowser": {
"title": "تصفح المجلد",
"pathPlaceholder": "أدخل مسار المجلد...",
"goHome": "الذهاب إلى المجلد الرئيسي",
"navigateUp": "الذهاب إلى المجلد الأعلى",
"select": "اختيار",
"cancel": "إلغاء",
"loading": "جاري التحميل...",
"emptyDirectory": "هذا المجلد فارغ",
"errorLoadingDir": "فشل في تحميل المجلد",
"permissionDenied": "تم رفض الإذن"
} }
} }

View File

@@ -56,7 +56,8 @@
"directoryPlaceholder": "Zielverzeichnis auswählen...", "directoryPlaceholder": "Zielverzeichnis auswählen...",
"browseDirectory": "Verzeichnis durchsuchen", "browseDirectory": "Verzeichnis durchsuchen",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"clone": "Klonen" "clone": "Klonen",
"clonePath": "Klonpfad: {path}"
} }
}, },
"GitCredentialDialog": { "GitCredentialDialog": {
@@ -1657,5 +1658,17 @@
"addressLabel": "Zugriffsadresse", "addressLabel": "Zugriffsadresse",
"tokenLabel": "Zugriffstoken", "tokenLabel": "Zugriffstoken",
"tokenHint": "Geben Sie dieses Token beim ersten Zugriff auf den Web-Client ein" "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"
} }
} }

View File

@@ -56,7 +56,8 @@
"directoryPlaceholder": "Select target directory...", "directoryPlaceholder": "Select target directory...",
"browseDirectory": "Browse directory", "browseDirectory": "Browse directory",
"cancel": "Cancel", "cancel": "Cancel",
"clone": "Clone" "clone": "Clone",
"clonePath": "Clone path: {path}"
} }
}, },
"GitCredentialDialog": { "GitCredentialDialog": {
@@ -1657,5 +1658,17 @@
"addressLabel": "Access Address", "addressLabel": "Access Address",
"tokenLabel": "Access Token", "tokenLabel": "Access Token",
"tokenHint": "Enter this token when accessing the Web client for the first time" "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"
} }
} }

View File

@@ -56,7 +56,8 @@
"directoryPlaceholder": "Selecciona el directorio de destino...", "directoryPlaceholder": "Selecciona el directorio de destino...",
"browseDirectory": "Explorar directorio", "browseDirectory": "Explorar directorio",
"cancel": "Cancelar", "cancel": "Cancelar",
"clone": "Clonar" "clone": "Clonar",
"clonePath": "Ruta de clonación: {path}"
} }
}, },
"GitCredentialDialog": { "GitCredentialDialog": {
@@ -1657,5 +1658,17 @@
"addressLabel": "Dirección de acceso", "addressLabel": "Dirección de acceso",
"tokenLabel": "Token de acceso", "tokenLabel": "Token de acceso",
"tokenHint": "Ingrese este token al acceder al cliente Web por primera vez" "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"
} }
} }

View File

@@ -56,7 +56,8 @@
"directoryPlaceholder": "Sélectionnez le répertoire cible...", "directoryPlaceholder": "Sélectionnez le répertoire cible...",
"browseDirectory": "Parcourir le répertoire", "browseDirectory": "Parcourir le répertoire",
"cancel": "Annuler", "cancel": "Annuler",
"clone": "Cloner" "clone": "Cloner",
"clonePath": "Chemin de clonage : {path}"
} }
}, },
"GitCredentialDialog": { "GitCredentialDialog": {
@@ -1657,5 +1658,17 @@
"addressLabel": "Adresse d'accès", "addressLabel": "Adresse d'accès",
"tokenLabel": "Token d'accès", "tokenLabel": "Token d'accès",
"tokenHint": "Entrez ce token lors du premier accès au client Web" "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"
} }
} }

View File

@@ -56,7 +56,8 @@
"directoryPlaceholder": "保存先ディレクトリを選択...", "directoryPlaceholder": "保存先ディレクトリを選択...",
"browseDirectory": "ディレクトリを参照", "browseDirectory": "ディレクトリを参照",
"cancel": "キャンセル", "cancel": "キャンセル",
"clone": "クローン" "clone": "クローン",
"clonePath": "クローンパス: {path}"
} }
}, },
"GitCredentialDialog": { "GitCredentialDialog": {
@@ -1657,5 +1658,17 @@
"addressLabel": "アクセスアドレス", "addressLabel": "アクセスアドレス",
"tokenLabel": "アクセストークン", "tokenLabel": "アクセストークン",
"tokenHint": "Webクライアントの初回アクセス時にこのトークンを入力してください" "tokenHint": "Webクライアントの初回アクセス時にこのトークンを入力してください"
},
"DirectoryBrowser": {
"title": "ディレクトリを参照",
"pathPlaceholder": "ディレクトリパスを入力...",
"goHome": "ホームディレクトリへ",
"navigateUp": "親ディレクトリへ",
"select": "選択",
"cancel": "キャンセル",
"loading": "読み込み中...",
"emptyDirectory": "このディレクトリは空です",
"errorLoadingDir": "ディレクトリの読み込みに失敗しました",
"permissionDenied": "アクセス権がありません"
} }
} }

View File

@@ -56,7 +56,8 @@
"directoryPlaceholder": "대상 디렉터리 선택...", "directoryPlaceholder": "대상 디렉터리 선택...",
"browseDirectory": "디렉터리 찾아보기", "browseDirectory": "디렉터리 찾아보기",
"cancel": "취소", "cancel": "취소",
"clone": "클론" "clone": "클론",
"clonePath": "클론 경로: {path}"
} }
}, },
"GitCredentialDialog": { "GitCredentialDialog": {
@@ -1657,5 +1658,17 @@
"addressLabel": "접속 주소", "addressLabel": "접속 주소",
"tokenLabel": "접속 토큰", "tokenLabel": "접속 토큰",
"tokenHint": "웹 클라이언트 첫 접속 시 이 토큰을 입력하세요" "tokenHint": "웹 클라이언트 첫 접속 시 이 토큰을 입력하세요"
},
"DirectoryBrowser": {
"title": "디렉토리 찾아보기",
"pathPlaceholder": "디렉토리 경로 입력...",
"goHome": "홈 디렉토리로 이동",
"navigateUp": "상위 디렉토리로 이동",
"select": "선택",
"cancel": "취소",
"loading": "로딩 중...",
"emptyDirectory": "이 디렉토리는 비어 있습니다",
"errorLoadingDir": "디렉토리 로딩 실패",
"permissionDenied": "권한이 없습니다"
} }
} }

View File

@@ -56,7 +56,8 @@
"directoryPlaceholder": "Selecione o diretório de destino...", "directoryPlaceholder": "Selecione o diretório de destino...",
"browseDirectory": "Procurar diretório", "browseDirectory": "Procurar diretório",
"cancel": "Cancelar", "cancel": "Cancelar",
"clone": "Clonar" "clone": "Clonar",
"clonePath": "Caminho de clonagem: {path}"
} }
}, },
"GitCredentialDialog": { "GitCredentialDialog": {
@@ -1657,5 +1658,17 @@
"addressLabel": "Endereço de acesso", "addressLabel": "Endereço de acesso",
"tokenLabel": "Token de acesso", "tokenLabel": "Token de acesso",
"tokenHint": "Insira este token ao acessar o cliente Web pela primeira vez" "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"
} }
} }

View File

@@ -56,7 +56,8 @@
"directoryPlaceholder": "选择目标目录...", "directoryPlaceholder": "选择目标目录...",
"browseDirectory": "浏览目录", "browseDirectory": "浏览目录",
"cancel": "取消", "cancel": "取消",
"clone": "克隆" "clone": "克隆",
"clonePath": "克隆路径: {path}"
} }
}, },
"GitCredentialDialog": { "GitCredentialDialog": {
@@ -1657,5 +1658,17 @@
"addressLabel": "访问地址", "addressLabel": "访问地址",
"tokenLabel": "访问 Token", "tokenLabel": "访问 Token",
"tokenHint": "Web 客户端首次访问时需输入此 Token" "tokenHint": "Web 客户端首次访问时需输入此 Token"
},
"DirectoryBrowser": {
"title": "浏览目录",
"pathPlaceholder": "输入目录路径...",
"goHome": "回到主目录",
"navigateUp": "返回上级目录",
"select": "选择",
"cancel": "取消",
"loading": "加载中...",
"emptyDirectory": "此目录为空",
"errorLoadingDir": "加载目录失败",
"permissionDenied": "权限不足"
} }
} }

View File

@@ -56,7 +56,8 @@
"directoryPlaceholder": "選擇目標目錄...", "directoryPlaceholder": "選擇目標目錄...",
"browseDirectory": "瀏覽目錄", "browseDirectory": "瀏覽目錄",
"cancel": "取消", "cancel": "取消",
"clone": "複製" "clone": "複製",
"clonePath": "克隆路徑: {path}"
} }
}, },
"GitCredentialDialog": { "GitCredentialDialog": {
@@ -1657,5 +1658,17 @@
"addressLabel": "存取位址", "addressLabel": "存取位址",
"tokenLabel": "存取 Token", "tokenLabel": "存取 Token",
"tokenHint": "Web 用戶端首次存取時需輸入此 Token" "tokenHint": "Web 用戶端首次存取時需輸入此 Token"
},
"DirectoryBrowser": {
"title": "瀏覽目錄",
"pathPlaceholder": "輸入目錄路徑...",
"goHome": "回到主目錄",
"navigateUp": "返回上層目錄",
"select": "選擇",
"cancel": "取消",
"loading": "載入中...",
"emptyDirectory": "此目錄為空",
"errorLoadingDir": "載入目錄失敗",
"permissionDenied": "權限不足"
} }
} }

View File

@@ -36,6 +36,7 @@ import type {
TerminalInfo, TerminalInfo,
PromptInputBlock, PromptInputBlock,
FileTreeNode, FileTreeNode,
DirectoryEntry,
FilePreviewContent, FilePreviewContent,
FileEditContent, FileEditContent,
FileSaveResult, FileSaveResult,
@@ -1107,6 +1108,18 @@ export async function bootstrapFolderCommandsFromPackageJson(
}) })
} }
// Directory browser (for web/server mode)
export async function getHomeDirectory(): Promise<string> {
return getTransport().call("get_home_directory")
}
export async function listDirectoryEntries(
path: string
): Promise<DirectoryEntry[]> {
return getTransport().call("list_directory_entries", { path })
}
// File tree and git log commands // File tree and git log commands
export async function getFileTree( export async function getFileTree(

View File

@@ -36,6 +36,7 @@ import type {
TerminalInfo, TerminalInfo,
PromptInputBlock, PromptInputBlock,
FileTreeNode, FileTreeNode,
DirectoryEntry,
FilePreviewContent, FilePreviewContent,
FileEditContent, FileEditContent,
FileSaveResult, FileSaveResult,
@@ -972,6 +973,18 @@ export async function bootstrapFolderCommandsFromPackageJson(
}) })
} }
// Directory browser
export async function getHomeDirectory(): Promise<string> {
return invoke("get_home_directory")
}
export async function listDirectoryEntries(
path: string
): Promise<DirectoryEntry[]> {
return invoke("list_directory_entries", { path })
}
// File tree and git log commands // File tree and git log commands
export async function getFileTree( export async function getFileTree(

View File

@@ -730,6 +730,12 @@ export type FileTreeNode =
| { kind: "file"; name: string; path: string } | { kind: "file"; name: string; path: string }
| { kind: "dir"; name: string; path: string; children: FileTreeNode[] } | { kind: "dir"; name: string; path: string; children: FileTreeNode[] }
export interface DirectoryEntry {
name: string
path: string
hasChildren: boolean
}
export interface FilePreviewContent { export interface FilePreviewContent {
path: string path: string
content: string content: string