优化web/server模式下的目录选择,现在支持目录树选择,而不是硬文本写入
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user