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

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