优化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

@@ -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)
})
}}
/>
</>
)
}

View File

@@ -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)
})
}}
/>
</>
)
}

View File

@@ -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)}
/>
</>
)
}

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"
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)}
/>
</>
)
}

View File

@@ -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}
/>
</>
)
}