优化web/server模式下的目录选择,现在支持目录树选择,而不是硬文本写入
This commit is contained in:
@@ -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)]
|
||||
pub async fn get_file_tree(
|
||||
path: String,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -110,6 +110,24 @@ pub async fn get_git_branch(
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetFileTreeParams {
|
||||
|
||||
@@ -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("/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))
|
||||
|
||||
@@ -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<FolderHistoryEntry[]>([])
|
||||
const [history, setHistory] = useState<FolderHistoryEntry[]>([])
|
||||
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() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<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)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [browserOpen, setBrowserOpen] = useState(false)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | 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() {
|
||||
}
|
||||
/>
|
||||
<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)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
|
||||
const [pmVersion, setPmVersion] = useState<string | null>(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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
onOpenChange(v)
|
||||
if (!v) resetForm()
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("createDialog.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
onOpenChange(v)
|
||||
if (!v) resetForm()
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("createDialog.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<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">
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("createDialog.projectName")}</Label>
|
||||
<Input
|
||||
value={saveDirectory}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<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 className="space-y-1.5">
|
||||
<Label>{t("createDialog.saveDirectory")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
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 className="space-y-1.5">
|
||||
<Label>{t("createDialog.packageManager")}</Label>
|
||||
<Tabs
|
||||
value={packageManager}
|
||||
onValueChange={setPackageManager}
|
||||
className="gap-0"
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={creating}
|
||||
>
|
||||
<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>
|
||||
{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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<DirectoryBrowserDialog
|
||||
open={browserOpen}
|
||||
onOpenChange={setBrowserOpen}
|
||||
onSelect={(path) => setSaveDirectory(path)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
349
src/components/shared/directory-browser-dialog.tsx
Normal file
349
src/components/shared/directory-browser-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(v) => {
|
||||
onOpenChange(v)
|
||||
if (!v) resetForm()
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("cloneDialog.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<>
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(v) => {
|
||||
onOpenChange(v)
|
||||
if (!v) resetForm()
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("cloneDialog.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<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">
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repo-url">{t("cloneDialog.repositoryUrl")}</Label>
|
||||
<Input
|
||||
id="target-dir"
|
||||
placeholder={t("cloneDialog.directoryPlaceholder")}
|
||||
value={targetDir}
|
||||
onChange={(e) => setTargetDir(e.target.value)}
|
||||
id="repo-url"
|
||||
placeholder={t("cloneDialog.repositoryUrlPlaceholder")}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(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>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-destructive">{error.message}</p>
|
||||
{error.detail && (
|
||||
<p className="text-xs text-muted-foreground">{error.detail}</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="target-dir">{t("cloneDialog.directory")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
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>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={cloning}
|
||||
type="button"
|
||||
>
|
||||
{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>
|
||||
{error && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-destructive">{error.message}</p>
|
||||
{error.detail && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{error.detail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={cloning}
|
||||
type="button"
|
||||
>
|
||||
{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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="w-full flex flex-col gap-1 px-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start gap-2 h-9"
|
||||
onClick={handleOpen}
|
||||
type="button"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
{t("openFolder")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start gap-2 h-9"
|
||||
onClick={() => setCloneOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
{t("cloneRepository")}
|
||||
</Button>
|
||||
<>
|
||||
<div className="w-full flex flex-col gap-1 px-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start gap-2 h-9"
|
||||
onClick={handleOpen}
|
||||
type="button"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
{t("openFolder")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start gap-2 h-9"
|
||||
onClick={() => setCloneOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
{t("cloneRepository")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start gap-2 h-9"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await openProjectBootWindow("welcome")
|
||||
} catch (err) {
|
||||
console.error("[FolderActions] failed to open project boot:", err)
|
||||
toast.error(t("toasts.openProjectBootFailed"))
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Rocket className="h-4 w-4" />
|
||||
{t("projectBoot")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start gap-2 h-9"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await openProjectBootWindow("welcome")
|
||||
} catch (err) {
|
||||
console.error("[FolderActions] failed to open project boot:", err)
|
||||
toast.error(t("toasts.openProjectBootFailed"))
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Rocket className="h-4 w-4" />
|
||||
{t("projectBoot")}
|
||||
</Button>
|
||||
|
||||
<CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} />
|
||||
</div>
|
||||
<CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} />
|
||||
</div>
|
||||
|
||||
<DirectoryBrowserDialog
|
||||
open={browserOpen}
|
||||
onOpenChange={setBrowserOpen}
|
||||
onSelect={handleBrowserSelect}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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": "تم رفض الإذن"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "アクセス権がありません"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "권한이 없습니다"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "权限不足"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "權限不足"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
|
||||
export async function getFileTree(
|
||||
|
||||
@@ -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<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
|
||||
|
||||
export async function getFileTree(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user