优化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)]
|
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
||||||
pub async fn get_file_tree(
|
pub async fn get_file_tree(
|
||||||
path: String,
|
path: String,
|
||||||
|
|||||||
@@ -270,6 +270,8 @@ mod tauri_app {
|
|||||||
folders::save_folder_opened_conversations,
|
folders::save_folder_opened_conversations,
|
||||||
folders::start_file_tree_watch,
|
folders::start_file_tree_watch,
|
||||||
folders::stop_file_tree_watch,
|
folders::stop_file_tree_watch,
|
||||||
|
folders::get_home_directory,
|
||||||
|
folders::list_directory_entries,
|
||||||
folders::get_file_tree,
|
folders::get_file_tree,
|
||||||
folders::read_file_base64,
|
folders::read_file_base64,
|
||||||
folders::read_file_preview,
|
folders::read_file_preview,
|
||||||
|
|||||||
@@ -110,6 +110,24 @@ pub async fn get_git_branch(
|
|||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_home_directory() -> Result<Json<String>, AppCommandError> {
|
||||||
|
let result = folder_commands::get_home_directory().await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ListDirectoryEntriesParams {
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_directory_entries(
|
||||||
|
Json(params): Json<ListDirectoryEntriesParams>,
|
||||||
|
) -> Result<Json<Vec<folder_commands::DirectoryEntry>>, AppCommandError> {
|
||||||
|
let result = folder_commands::list_directory_entries(params.path).await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct GetFileTreeParams {
|
pub struct GetFileTreeParams {
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ pub fn build_router(state: Arc<AppState>, token: String, static_dir: std::path::
|
|||||||
.route("/create_folder_directory", post(handlers::folders::create_folder_directory))
|
.route("/create_folder_directory", post(handlers::folders::create_folder_directory))
|
||||||
.route("/save_folder_opened_conversations", post(handlers::folders::save_folder_opened_conversations))
|
.route("/save_folder_opened_conversations", post(handlers::folders::save_folder_opened_conversations))
|
||||||
.route("/get_git_branch", post(handlers::folders::get_git_branch))
|
.route("/get_git_branch", post(handlers::folders::get_git_branch))
|
||||||
|
.route("/get_home_directory", post(handlers::folders::get_home_directory))
|
||||||
|
.route("/list_directory_entries", post(handlers::folders::list_directory_entries))
|
||||||
.route("/get_file_tree", post(handlers::folders::get_file_tree))
|
.route("/get_file_tree", post(handlers::folders::get_file_tree))
|
||||||
.route("/start_file_tree_watch", post(handlers::folders::start_file_tree_watch))
|
.route("/start_file_tree_watch", post(handlers::folders::start_file_tree_watch))
|
||||||
.route("/stop_file_tree_watch", post(handlers::folders::stop_file_tree_watch))
|
.route("/stop_file_tree_watch", post(handlers::folders::stop_file_tree_watch))
|
||||||
|
|||||||
@@ -24,9 +24,10 @@ import {
|
|||||||
openFolderWindow,
|
openFolderWindow,
|
||||||
openProjectBootWindow,
|
openProjectBootWindow,
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
import { openFileDialog } from "@/lib/platform"
|
import { isDesktop, openFileDialog } from "@/lib/platform"
|
||||||
import { useFolderContext } from "@/contexts/folder-context"
|
import { useFolderContext } from "@/contexts/folder-context"
|
||||||
import { CloneDialog } from "@/components/welcome/clone-dialog"
|
import { CloneDialog } from "@/components/welcome/clone-dialog"
|
||||||
|
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
|
||||||
import type { FolderHistoryEntry } from "@/lib/types"
|
import type { FolderHistoryEntry } from "@/lib/types"
|
||||||
|
|
||||||
export function FolderNameDropdown() {
|
export function FolderNameDropdown() {
|
||||||
@@ -35,6 +36,7 @@ export function FolderNameDropdown() {
|
|||||||
const [openFolders, setOpenFolders] = useState<FolderHistoryEntry[]>([])
|
const [openFolders, setOpenFolders] = useState<FolderHistoryEntry[]>([])
|
||||||
const [history, setHistory] = useState<FolderHistoryEntry[]>([])
|
const [history, setHistory] = useState<FolderHistoryEntry[]>([])
|
||||||
const [cloneOpen, setCloneOpen] = useState(false)
|
const [cloneOpen, setCloneOpen] = useState(false)
|
||||||
|
const [browserOpen, setBrowserOpen] = useState(false)
|
||||||
|
|
||||||
const folderPath = folder?.path ?? ""
|
const folderPath = folder?.path ?? ""
|
||||||
const folderName = folder?.name ?? t("fallbackFolderName")
|
const folderName = folder?.name ?? t("fallbackFolderName")
|
||||||
@@ -57,11 +59,19 @@ export function FolderNameDropdown() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleOpenFolder() {
|
async function handleOpenFolder() {
|
||||||
const selected = await openFileDialog({ directory: true, multiple: false })
|
if (isDesktop()) {
|
||||||
if (selected) {
|
const selected = await openFileDialog({
|
||||||
await openFolderWindow(Array.isArray(selected) ? selected[0] : selected, {
|
directory: true,
|
||||||
newWindow: true,
|
multiple: false,
|
||||||
})
|
})
|
||||||
|
if (selected) {
|
||||||
|
await openFolderWindow(
|
||||||
|
Array.isArray(selected) ? selected[0] : selected,
|
||||||
|
{ newWindow: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setBrowserOpen(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +159,15 @@ export function FolderNameDropdown() {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} />
|
<CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} />
|
||||||
|
<DirectoryBrowserDialog
|
||||||
|
open={browserOpen}
|
||||||
|
onOpenChange={setBrowserOpen}
|
||||||
|
onSelect={(path) => {
|
||||||
|
openFolderWindow(path, { newWindow: true }).catch((err) => {
|
||||||
|
console.error("[FolderNameDropdown] failed to open folder:", err)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { getGitBranch, openFolderWindow, openSettingsWindow } from "@/lib/api"
|
import { getGitBranch, openFolderWindow, openSettingsWindow } from "@/lib/api"
|
||||||
import { openFileDialog } from "@/lib/platform"
|
import { isDesktop, openFileDialog } from "@/lib/platform"
|
||||||
import { useFolderContext } from "@/contexts/folder-context"
|
import { useFolderContext } from "@/contexts/folder-context"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { useSidebarContext } from "@/contexts/sidebar-context"
|
import { useSidebarContext } from "@/contexts/sidebar-context"
|
||||||
@@ -38,6 +38,7 @@ import { FolderNameDropdown } from "./folder-name-dropdown"
|
|||||||
import { BranchDropdown } from "./branch-dropdown"
|
import { BranchDropdown } from "./branch-dropdown"
|
||||||
import { CommandDropdown } from "./command-dropdown"
|
import { CommandDropdown } from "./command-dropdown"
|
||||||
import { SearchCommandDialog } from "@/components/conversations/search-command-dialog"
|
import { SearchCommandDialog } from "@/components/conversations/search-command-dialog"
|
||||||
|
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const MODE_TABS = [
|
const MODE_TABS = [
|
||||||
@@ -71,6 +72,7 @@ export function FolderTitleBar() {
|
|||||||
const { shortcuts } = useShortcutSettings()
|
const { shortcuts } = useShortcutSettings()
|
||||||
const [branch, setBranch] = useState<string | null>(null)
|
const [branch, setBranch] = useState<string | null>(null)
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
|
const [browserOpen, setBrowserOpen] = useState(false)
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | undefined>(
|
const intervalRef = useRef<ReturnType<typeof setInterval> | undefined>(
|
||||||
undefined
|
undefined
|
||||||
)
|
)
|
||||||
@@ -78,13 +80,20 @@ export function FolderTitleBar() {
|
|||||||
const folderPath = folder?.path ?? ""
|
const folderPath = folder?.path ?? ""
|
||||||
|
|
||||||
const handleOpenFolder = useCallback(async () => {
|
const handleOpenFolder = useCallback(async () => {
|
||||||
try {
|
if (isDesktop()) {
|
||||||
const result = await openFileDialog({ directory: true, multiple: false })
|
try {
|
||||||
if (!result) return
|
const result = await openFileDialog({
|
||||||
const selected = Array.isArray(result) ? result[0] : result
|
directory: true,
|
||||||
await openFolderWindow(selected, { newWindow: true })
|
multiple: false,
|
||||||
} catch (err) {
|
})
|
||||||
console.error("[FolderTitleBar] failed to open folder:", err)
|
if (!result) return
|
||||||
|
const selected = Array.isArray(result) ? result[0] : result
|
||||||
|
await openFolderWindow(selected, { newWindow: true })
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[FolderTitleBar] failed to open folder:", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setBrowserOpen(true)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -399,6 +408,15 @@ export function FolderTitleBar() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SearchCommandDialog open={searchOpen} onOpenChange={setSearchOpen} />
|
<SearchCommandDialog open={searchOpen} onOpenChange={setSearchOpen} />
|
||||||
|
<DirectoryBrowserDialog
|
||||||
|
open={browserOpen}
|
||||||
|
onOpenChange={setBrowserOpen}
|
||||||
|
onSelect={(path) => {
|
||||||
|
openFolderWindow(path, { newWindow: true }).catch((err) => {
|
||||||
|
console.error("[FolderTitleBar] failed to open folder:", err)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,13 +34,14 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { openFileDialog } from "@/lib/platform"
|
import { isDesktop, openFileDialog } from "@/lib/platform"
|
||||||
import {
|
import {
|
||||||
createShadcnProject,
|
createShadcnProject,
|
||||||
openFolderWindow,
|
openFolderWindow,
|
||||||
detectPackageManager,
|
detectPackageManager,
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
import { extractAppCommandError, toErrorMessage } from "@/lib/app-error"
|
import { extractAppCommandError, toErrorMessage } from "@/lib/app-error"
|
||||||
|
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
|
||||||
import {
|
import {
|
||||||
BASE_OPTIONS,
|
BASE_OPTIONS,
|
||||||
FRAMEWORK_OPTIONS,
|
FRAMEWORK_OPTIONS,
|
||||||
@@ -67,6 +68,7 @@ export function CreateProjectDialog({
|
|||||||
const [rtl, setRtl] = useState(false)
|
const [rtl, setRtl] = useState(false)
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [browserOpen, setBrowserOpen] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [pmVersion, setPmVersion] = useState<string | null>(null)
|
const [pmVersion, setPmVersion] = useState<string | null>(null)
|
||||||
@@ -96,10 +98,14 @@ export function CreateProjectDialog({
|
|||||||
}, [open, packageManager, checkPackageManager])
|
}, [open, packageManager, checkPackageManager])
|
||||||
|
|
||||||
const handleBrowse = async () => {
|
const handleBrowse = async () => {
|
||||||
const result = await openFileDialog({ directory: true, multiple: false })
|
if (isDesktop()) {
|
||||||
if (!result) return
|
const result = await openFileDialog({ directory: true, multiple: false })
|
||||||
const selected = Array.isArray(result) ? result[0] : result
|
if (!result) return
|
||||||
setSaveDirectory(selected)
|
const selected = Array.isArray(result) ? result[0] : result
|
||||||
|
setSaveDirectory(selected)
|
||||||
|
} else {
|
||||||
|
setBrowserOpen(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
@@ -151,208 +157,216 @@ export function CreateProjectDialog({
|
|||||||
pmInstalled === true
|
pmInstalled === true
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<>
|
||||||
open={open}
|
<Dialog
|
||||||
onOpenChange={(v) => {
|
open={open}
|
||||||
onOpenChange(v)
|
onOpenChange={(v) => {
|
||||||
if (!v) resetForm()
|
onOpenChange(v)
|
||||||
}}
|
if (!v) resetForm()
|
||||||
>
|
}}
|
||||||
<DialogContent className="sm:max-w-md">
|
>
|
||||||
<DialogHeader>
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogTitle>{t("createDialog.title")}</DialogTitle>
|
<DialogHeader>
|
||||||
</DialogHeader>
|
<DialogTitle>{t("createDialog.title")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>{t("createDialog.projectName")}</Label>
|
<Label>{t("createDialog.projectName")}</Label>
|
||||||
<Input
|
|
||||||
value={projectName}
|
|
||||||
onChange={(e) => setProjectName(e.target.value)}
|
|
||||||
placeholder={t("createDialog.projectNamePlaceholder")}
|
|
||||||
disabled={creating}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>{t("createDialog.saveDirectory")}</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
<Input
|
||||||
value={saveDirectory}
|
value={projectName}
|
||||||
onChange={(e) => setSaveDirectory(e.target.value)}
|
onChange={(e) => setProjectName(e.target.value)}
|
||||||
placeholder={t("createDialog.saveDirectoryPlaceholder")}
|
placeholder={t("createDialog.projectNamePlaceholder")}
|
||||||
disabled={creating}
|
disabled={creating}
|
||||||
className="flex-1"
|
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleBrowse}
|
|
||||||
disabled={creating}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<FolderOpen className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{saveDirectory && projectName.trim() && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="space-y-1.5">
|
||||||
{t("createDialog.projectPath", {
|
<Label>{t("createDialog.saveDirectory")}</Label>
|
||||||
path: `${saveDirectory}/${projectName.trim()}`,
|
<div className="flex gap-2">
|
||||||
})}
|
<Input
|
||||||
</p>
|
value={saveDirectory}
|
||||||
|
onChange={(e) => setSaveDirectory(e.target.value)}
|
||||||
|
placeholder={t("createDialog.saveDirectoryPlaceholder")}
|
||||||
|
disabled={creating}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleBrowse}
|
||||||
|
disabled={creating}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{saveDirectory && projectName.trim() && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("createDialog.projectPath", {
|
||||||
|
path: `${saveDirectory}/${projectName.trim()}`,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t("createDialog.packageManager")}</Label>
|
||||||
|
<Tabs
|
||||||
|
value={packageManager}
|
||||||
|
onValueChange={setPackageManager}
|
||||||
|
className="gap-0"
|
||||||
|
>
|
||||||
|
<TabsList className="w-full">
|
||||||
|
{PACKAGE_MANAGER_OPTIONS.map((opt) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={opt.value}
|
||||||
|
value={opt.value}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
{PACKAGE_MANAGER_OPTIONS.map((opt) => (
|
||||||
|
<TabsContent key={opt.value} value={opt.value}>
|
||||||
|
<div className="flex h-8 items-center gap-1.5 text-sm">
|
||||||
|
{pmChecking ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("createDialog.pmChecking")}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : pmInstalled ? (
|
||||||
|
<>
|
||||||
|
<CircleCheck className="size-3.5 text-emerald-500" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{opt.label} v{pmVersion}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CircleX className="size-3.5 text-destructive" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("createDialog.pmNotInstalled")}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto gap-1 px-0 text-xs text-muted-foreground"
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
<ChevronsUpDown className="size-3.5" />
|
||||||
|
{t("createDialog.advancedOptions")}
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="space-y-4 pt-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t("createDialog.frameworkTemplate")}</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={framework}
|
||||||
|
onValueChange={setFramework}
|
||||||
|
disabled={creating}
|
||||||
|
className="grid grid-cols-2 gap-2"
|
||||||
|
>
|
||||||
|
{FRAMEWORK_OPTIONS.map((opt) => (
|
||||||
|
<FieldLabel key={opt.value} htmlFor={`fw-${opt.value}`}>
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<FieldContent>
|
||||||
|
<FieldTitle>{opt.label}</FieldTitle>
|
||||||
|
</FieldContent>
|
||||||
|
<RadioGroupItem
|
||||||
|
value={opt.value}
|
||||||
|
id={`fw-${opt.value}`}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FieldLabel>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t("createDialog.base")}</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={base}
|
||||||
|
onValueChange={setBase}
|
||||||
|
disabled={creating}
|
||||||
|
className="grid grid-cols-2 gap-2"
|
||||||
|
>
|
||||||
|
{BASE_OPTIONS.map((opt) => (
|
||||||
|
<FieldLabel key={opt.value} htmlFor={`base-${opt.value}`}>
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<FieldContent>
|
||||||
|
<FieldTitle>{opt.label}</FieldTitle>
|
||||||
|
</FieldContent>
|
||||||
|
<RadioGroupItem
|
||||||
|
value={opt.value}
|
||||||
|
id={`base-${opt.value}`}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FieldLabel>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex cursor-pointer items-center gap-3 rounded-lg border p-3">
|
||||||
|
<Switch
|
||||||
|
checked={rtl}
|
||||||
|
onCheckedChange={setRtl}
|
||||||
|
disabled={creating}
|
||||||
|
/>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{t("createDialog.enableRtl")}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("createDialog.enableRtlDescription")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<DialogFooter>
|
||||||
<Label>{t("createDialog.packageManager")}</Label>
|
<Button
|
||||||
<Tabs
|
variant="outline"
|
||||||
value={packageManager}
|
onClick={() => onOpenChange(false)}
|
||||||
onValueChange={setPackageManager}
|
disabled={creating}
|
||||||
className="gap-0"
|
|
||||||
>
|
>
|
||||||
<TabsList className="w-full">
|
{t("createDialog.cancel")}
|
||||||
{PACKAGE_MANAGER_OPTIONS.map((opt) => (
|
</Button>
|
||||||
<TabsTrigger
|
<Button onClick={handleCreate} disabled={!canCreate || creating}>
|
||||||
key={opt.value}
|
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
value={opt.value}
|
{creating ? t("createDialog.creating") : t("createDialog.create")}
|
||||||
className="flex-1"
|
</Button>
|
||||||
disabled={creating}
|
</DialogFooter>
|
||||||
>
|
</DialogContent>
|
||||||
{opt.label}
|
</Dialog>
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
{PACKAGE_MANAGER_OPTIONS.map((opt) => (
|
|
||||||
<TabsContent key={opt.value} value={opt.value}>
|
|
||||||
<div className="flex h-8 items-center gap-1.5 text-sm">
|
|
||||||
{pmChecking ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{t("createDialog.pmChecking")}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : pmInstalled ? (
|
|
||||||
<>
|
|
||||||
<CircleCheck className="size-3.5 text-emerald-500" />
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{opt.label} v{pmVersion}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CircleX className="size-3.5 text-destructive" />
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{t("createDialog.pmNotInstalled")}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
<DirectoryBrowserDialog
|
||||||
<CollapsibleTrigger asChild>
|
open={browserOpen}
|
||||||
<Button
|
onOpenChange={setBrowserOpen}
|
||||||
variant="ghost"
|
onSelect={(path) => setSaveDirectory(path)}
|
||||||
size="sm"
|
/>
|
||||||
className="h-auto gap-1 px-0 text-xs text-muted-foreground"
|
</>
|
||||||
disabled={creating}
|
|
||||||
>
|
|
||||||
<ChevronsUpDown className="size-3.5" />
|
|
||||||
{t("createDialog.advancedOptions")}
|
|
||||||
</Button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent className="space-y-4 pt-2">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>{t("createDialog.frameworkTemplate")}</Label>
|
|
||||||
<RadioGroup
|
|
||||||
value={framework}
|
|
||||||
onValueChange={setFramework}
|
|
||||||
disabled={creating}
|
|
||||||
className="grid grid-cols-2 gap-2"
|
|
||||||
>
|
|
||||||
{FRAMEWORK_OPTIONS.map((opt) => (
|
|
||||||
<FieldLabel key={opt.value} htmlFor={`fw-${opt.value}`}>
|
|
||||||
<Field orientation="horizontal">
|
|
||||||
<FieldContent>
|
|
||||||
<FieldTitle>{opt.label}</FieldTitle>
|
|
||||||
</FieldContent>
|
|
||||||
<RadioGroupItem
|
|
||||||
value={opt.value}
|
|
||||||
id={`fw-${opt.value}`}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</FieldLabel>
|
|
||||||
))}
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>{t("createDialog.base")}</Label>
|
|
||||||
<RadioGroup
|
|
||||||
value={base}
|
|
||||||
onValueChange={setBase}
|
|
||||||
disabled={creating}
|
|
||||||
className="grid grid-cols-2 gap-2"
|
|
||||||
>
|
|
||||||
{BASE_OPTIONS.map((opt) => (
|
|
||||||
<FieldLabel key={opt.value} htmlFor={`base-${opt.value}`}>
|
|
||||||
<Field orientation="horizontal">
|
|
||||||
<FieldContent>
|
|
||||||
<FieldTitle>{opt.label}</FieldTitle>
|
|
||||||
</FieldContent>
|
|
||||||
<RadioGroupItem
|
|
||||||
value={opt.value}
|
|
||||||
id={`base-${opt.value}`}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</FieldLabel>
|
|
||||||
))}
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex cursor-pointer items-center gap-3 rounded-lg border p-3">
|
|
||||||
<Switch
|
|
||||||
checked={rtl}
|
|
||||||
onCheckedChange={setRtl}
|
|
||||||
disabled={creating}
|
|
||||||
/>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="text-sm font-medium">
|
|
||||||
{t("createDialog.enableRtl")}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{t("createDialog.enableRtlDescription")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
disabled={creating}
|
|
||||||
>
|
|
||||||
{t("createDialog.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleCreate} disabled={!canCreate || creating}>
|
|
||||||
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
{creating ? t("createDialog.creating") : t("createDialog.create")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState, useMemo } from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { cloneRepository, openFolderWindow } from "@/lib/api"
|
import { cloneRepository, openFolderWindow } from "@/lib/api"
|
||||||
import { openFileDialog } from "@/lib/platform"
|
import { isDesktop, openFileDialog } from "@/lib/platform"
|
||||||
import { useGitCredential } from "@/contexts/git-credential-context"
|
import { useGitCredential } from "@/contexts/git-credential-context"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { FolderOpen, Loader2 } from "lucide-react"
|
import { FolderOpen, Loader2 } from "lucide-react"
|
||||||
import { resolveCloneError } from "@/components/welcome/error-utils"
|
import { resolveCloneError } from "@/components/welcome/error-utils"
|
||||||
|
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
|
||||||
|
|
||||||
interface CloneDialogProps {
|
interface CloneDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -30,27 +31,38 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) {
|
|||||||
const [url, setUrl] = useState("")
|
const [url, setUrl] = useState("")
|
||||||
const [targetDir, setTargetDir] = useState("")
|
const [targetDir, setTargetDir] = useState("")
|
||||||
const [cloning, setCloning] = useState(false)
|
const [cloning, setCloning] = useState(false)
|
||||||
|
const [browserOpen, setBrowserOpen] = useState(false)
|
||||||
const [error, setError] = useState<{
|
const [error, setError] = useState<{
|
||||||
message: string
|
message: string
|
||||||
detail: string | null
|
detail: string | null
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
|
const repoName = useMemo(
|
||||||
|
() =>
|
||||||
|
url
|
||||||
|
.replace(/\.git$/, "")
|
||||||
|
.split("/")
|
||||||
|
.pop() ?? "repo",
|
||||||
|
[url]
|
||||||
|
)
|
||||||
|
|
||||||
const handleBrowse = async () => {
|
const handleBrowse = async () => {
|
||||||
const selected = await openFileDialog({ directory: true, multiple: false })
|
if (isDesktop()) {
|
||||||
if (selected) {
|
const selected = await openFileDialog({
|
||||||
setTargetDir(Array.isArray(selected) ? selected[0] : selected)
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
})
|
||||||
|
if (selected) {
|
||||||
|
setTargetDir(Array.isArray(selected) ? selected[0] : selected)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setBrowserOpen(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClone = async () => {
|
const handleClone = async () => {
|
||||||
if (!url || !targetDir) return
|
if (!url || !targetDir) return
|
||||||
|
|
||||||
// Derive repo name from URL
|
|
||||||
const repoName =
|
|
||||||
url
|
|
||||||
.replace(/\.git$/, "")
|
|
||||||
.split("/")
|
|
||||||
.pop() ?? "repo"
|
|
||||||
const fullPath = `${targetDir}/${repoName}`
|
const fullPath = `${targetDir}/${repoName}`
|
||||||
|
|
||||||
setCloning(true)
|
setCloning(true)
|
||||||
@@ -85,84 +97,101 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<>
|
||||||
open={isOpen}
|
<Dialog
|
||||||
onOpenChange={(v) => {
|
open={isOpen}
|
||||||
onOpenChange(v)
|
onOpenChange={(v) => {
|
||||||
if (!v) resetForm()
|
onOpenChange(v)
|
||||||
}}
|
if (!v) resetForm()
|
||||||
>
|
}}
|
||||||
<DialogContent className="sm:max-w-md">
|
>
|
||||||
<DialogHeader>
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogTitle>{t("cloneDialog.title")}</DialogTitle>
|
<DialogHeader>
|
||||||
</DialogHeader>
|
<DialogTitle>{t("cloneDialog.title")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="repo-url">{t("cloneDialog.repositoryUrl")}</Label>
|
<Label htmlFor="repo-url">{t("cloneDialog.repositoryUrl")}</Label>
|
||||||
<Input
|
|
||||||
id="repo-url"
|
|
||||||
placeholder={t("cloneDialog.repositoryUrlPlaceholder")}
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
|
||||||
disabled={cloning}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="target-dir">{t("cloneDialog.directory")}</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
<Input
|
||||||
id="target-dir"
|
id="repo-url"
|
||||||
placeholder={t("cloneDialog.directoryPlaceholder")}
|
placeholder={t("cloneDialog.repositoryUrlPlaceholder")}
|
||||||
value={targetDir}
|
value={url}
|
||||||
onChange={(e) => setTargetDir(e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
disabled={cloning}
|
disabled={cloning}
|
||||||
className="flex-1"
|
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={handleBrowse}
|
|
||||||
disabled={cloning}
|
|
||||||
title={t("cloneDialog.browseDirectory")}
|
|
||||||
aria-label={t("cloneDialog.browseDirectory")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<FolderOpen className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
<div className="space-y-2">
|
||||||
<div className="space-y-1">
|
<Label htmlFor="target-dir">{t("cloneDialog.directory")}</Label>
|
||||||
<p className="text-sm text-destructive">{error.message}</p>
|
<div className="flex gap-2">
|
||||||
{error.detail && (
|
<Input
|
||||||
<p className="text-xs text-muted-foreground">{error.detail}</p>
|
id="target-dir"
|
||||||
|
placeholder={t("cloneDialog.directoryPlaceholder")}
|
||||||
|
value={targetDir}
|
||||||
|
onChange={(e) => setTargetDir(e.target.value)}
|
||||||
|
disabled={cloning}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleBrowse}
|
||||||
|
disabled={cloning}
|
||||||
|
title={t("cloneDialog.browseDirectory")}
|
||||||
|
aria-label={t("cloneDialog.browseDirectory")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{targetDir && url && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("cloneDialog.clonePath", {
|
||||||
|
path: `${targetDir}/${repoName}`,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
{error && (
|
||||||
<Button
|
<div className="space-y-1">
|
||||||
variant="outline"
|
<p className="text-sm text-destructive">{error.message}</p>
|
||||||
onClick={() => onOpenChange(false)}
|
{error.detail && (
|
||||||
disabled={cloning}
|
<p className="text-xs text-muted-foreground">
|
||||||
type="button"
|
{error.detail}
|
||||||
>
|
</p>
|
||||||
{t("cloneDialog.cancel")}
|
)}
|
||||||
</Button>
|
</div>
|
||||||
<Button
|
)}
|
||||||
onClick={handleClone}
|
</div>
|
||||||
disabled={!url || !targetDir || cloning}
|
|
||||||
type="button"
|
<DialogFooter>
|
||||||
>
|
<Button
|
||||||
{cloning && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
variant="outline"
|
||||||
{t("cloneDialog.clone")}
|
onClick={() => onOpenChange(false)}
|
||||||
</Button>
|
disabled={cloning}
|
||||||
</DialogFooter>
|
type="button"
|
||||||
</DialogContent>
|
>
|
||||||
</Dialog>
|
{t("cloneDialog.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleClone}
|
||||||
|
disabled={!url || !targetDir || cloning}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{cloning && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
{t("cloneDialog.clone")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<DirectoryBrowserDialog
|
||||||
|
open={browserOpen}
|
||||||
|
onOpenChange={setBrowserOpen}
|
||||||
|
onSelect={(path) => setTargetDir(path)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,22 +5,42 @@ import { FolderOpen, GitBranch, Rocket } from "lucide-react"
|
|||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { openFolderWindow, openProjectBootWindow } from "@/lib/api"
|
import { openFolderWindow, openProjectBootWindow } from "@/lib/api"
|
||||||
import { openFileDialog } from "@/lib/platform"
|
import { isDesktop, openFileDialog } from "@/lib/platform"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { CloneDialog } from "./clone-dialog"
|
import { CloneDialog } from "./clone-dialog"
|
||||||
import { resolveWelcomeError } from "@/components/welcome/error-utils"
|
import { resolveWelcomeError } from "@/components/welcome/error-utils"
|
||||||
|
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
|
||||||
|
|
||||||
export function FolderActions() {
|
export function FolderActions() {
|
||||||
const t = useTranslations("WelcomePage")
|
const t = useTranslations("WelcomePage")
|
||||||
const [cloneOpen, setCloneOpen] = useState(false)
|
const [cloneOpen, setCloneOpen] = useState(false)
|
||||||
|
const [browserOpen, setBrowserOpen] = useState(false)
|
||||||
|
|
||||||
const handleOpen = async () => {
|
const handleOpen = async () => {
|
||||||
const result = await openFileDialog({ directory: true, multiple: false })
|
if (isDesktop()) {
|
||||||
if (!result) return
|
const result = await openFileDialog({
|
||||||
const selected = Array.isArray(result) ? result[0] : result
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
})
|
||||||
|
if (!result) return
|
||||||
|
const selected = Array.isArray(result) ? result[0] : result
|
||||||
|
try {
|
||||||
|
await openFolderWindow(selected)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[FolderActions] failed to open folder:", err)
|
||||||
|
const resolvedError = resolveWelcomeError(err)
|
||||||
|
toast.error(t("toasts.openFolderFailed"), {
|
||||||
|
description: resolvedError.detail ?? t(resolvedError.key),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setBrowserOpen(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBrowserSelect = async (path: string) => {
|
||||||
try {
|
try {
|
||||||
await openFolderWindow(selected)
|
await openFolderWindow(path)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[FolderActions] failed to open folder:", err)
|
console.error("[FolderActions] failed to open folder:", err)
|
||||||
const resolvedError = resolveWelcomeError(err)
|
const resolvedError = resolveWelcomeError(err)
|
||||||
@@ -31,44 +51,52 @@ export function FolderActions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col gap-1 px-3">
|
<>
|
||||||
<Button
|
<div className="w-full flex flex-col gap-1 px-3">
|
||||||
variant="ghost"
|
<Button
|
||||||
className="justify-start gap-2 h-9"
|
variant="ghost"
|
||||||
onClick={handleOpen}
|
className="justify-start gap-2 h-9"
|
||||||
type="button"
|
onClick={handleOpen}
|
||||||
>
|
type="button"
|
||||||
<FolderOpen className="h-4 w-4" />
|
>
|
||||||
{t("openFolder")}
|
<FolderOpen className="h-4 w-4" />
|
||||||
</Button>
|
{t("openFolder")}
|
||||||
<Button
|
</Button>
|
||||||
variant="ghost"
|
<Button
|
||||||
className="justify-start gap-2 h-9"
|
variant="ghost"
|
||||||
onClick={() => setCloneOpen(true)}
|
className="justify-start gap-2 h-9"
|
||||||
type="button"
|
onClick={() => setCloneOpen(true)}
|
||||||
>
|
type="button"
|
||||||
<GitBranch className="h-4 w-4" />
|
>
|
||||||
{t("cloneRepository")}
|
<GitBranch className="h-4 w-4" />
|
||||||
</Button>
|
{t("cloneRepository")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="justify-start gap-2 h-9"
|
className="justify-start gap-2 h-9"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
await openProjectBootWindow("welcome")
|
await openProjectBootWindow("welcome")
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[FolderActions] failed to open project boot:", err)
|
console.error("[FolderActions] failed to open project boot:", err)
|
||||||
toast.error(t("toasts.openProjectBootFailed"))
|
toast.error(t("toasts.openProjectBootFailed"))
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Rocket className="h-4 w-4" />
|
<Rocket className="h-4 w-4" />
|
||||||
{t("projectBoot")}
|
{t("projectBoot")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} />
|
<CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DirectoryBrowserDialog
|
||||||
|
open={browserOpen}
|
||||||
|
onOpenChange={setBrowserOpen}
|
||||||
|
onSelect={handleBrowserSelect}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,8 @@
|
|||||||
"directoryPlaceholder": "اختر مجلد الهدف...",
|
"directoryPlaceholder": "اختر مجلد الهدف...",
|
||||||
"browseDirectory": "تصفح المجلد",
|
"browseDirectory": "تصفح المجلد",
|
||||||
"cancel": "إلغاء",
|
"cancel": "إلغاء",
|
||||||
"clone": "استنساخ"
|
"clone": "استنساخ",
|
||||||
|
"clonePath": "مسار الاستنساخ: {path}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"GitCredentialDialog": {
|
"GitCredentialDialog": {
|
||||||
@@ -1657,5 +1658,17 @@
|
|||||||
"addressLabel": "عنوان الوصول",
|
"addressLabel": "عنوان الوصول",
|
||||||
"tokenLabel": "رمز الوصول",
|
"tokenLabel": "رمز الوصول",
|
||||||
"tokenHint": "أدخل هذا الرمز عند الوصول إلى عميل الويب لأول مرة"
|
"tokenHint": "أدخل هذا الرمز عند الوصول إلى عميل الويب لأول مرة"
|
||||||
|
},
|
||||||
|
"DirectoryBrowser": {
|
||||||
|
"title": "تصفح المجلد",
|
||||||
|
"pathPlaceholder": "أدخل مسار المجلد...",
|
||||||
|
"goHome": "الذهاب إلى المجلد الرئيسي",
|
||||||
|
"navigateUp": "الذهاب إلى المجلد الأعلى",
|
||||||
|
"select": "اختيار",
|
||||||
|
"cancel": "إلغاء",
|
||||||
|
"loading": "جاري التحميل...",
|
||||||
|
"emptyDirectory": "هذا المجلد فارغ",
|
||||||
|
"errorLoadingDir": "فشل في تحميل المجلد",
|
||||||
|
"permissionDenied": "تم رفض الإذن"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,8 @@
|
|||||||
"directoryPlaceholder": "Zielverzeichnis auswählen...",
|
"directoryPlaceholder": "Zielverzeichnis auswählen...",
|
||||||
"browseDirectory": "Verzeichnis durchsuchen",
|
"browseDirectory": "Verzeichnis durchsuchen",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"clone": "Klonen"
|
"clone": "Klonen",
|
||||||
|
"clonePath": "Klonpfad: {path}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"GitCredentialDialog": {
|
"GitCredentialDialog": {
|
||||||
@@ -1657,5 +1658,17 @@
|
|||||||
"addressLabel": "Zugriffsadresse",
|
"addressLabel": "Zugriffsadresse",
|
||||||
"tokenLabel": "Zugriffstoken",
|
"tokenLabel": "Zugriffstoken",
|
||||||
"tokenHint": "Geben Sie dieses Token beim ersten Zugriff auf den Web-Client ein"
|
"tokenHint": "Geben Sie dieses Token beim ersten Zugriff auf den Web-Client ein"
|
||||||
|
},
|
||||||
|
"DirectoryBrowser": {
|
||||||
|
"title": "Verzeichnis durchsuchen",
|
||||||
|
"pathPlaceholder": "Verzeichnispfad eingeben...",
|
||||||
|
"goHome": "Zum Heimverzeichnis",
|
||||||
|
"navigateUp": "Zum übergeordneten Verzeichnis",
|
||||||
|
"select": "Auswählen",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"loading": "Wird geladen...",
|
||||||
|
"emptyDirectory": "Dieses Verzeichnis ist leer",
|
||||||
|
"errorLoadingDir": "Verzeichnis konnte nicht geladen werden",
|
||||||
|
"permissionDenied": "Zugriff verweigert"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,8 @@
|
|||||||
"directoryPlaceholder": "Select target directory...",
|
"directoryPlaceholder": "Select target directory...",
|
||||||
"browseDirectory": "Browse directory",
|
"browseDirectory": "Browse directory",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"clone": "Clone"
|
"clone": "Clone",
|
||||||
|
"clonePath": "Clone path: {path}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"GitCredentialDialog": {
|
"GitCredentialDialog": {
|
||||||
@@ -1657,5 +1658,17 @@
|
|||||||
"addressLabel": "Access Address",
|
"addressLabel": "Access Address",
|
||||||
"tokenLabel": "Access Token",
|
"tokenLabel": "Access Token",
|
||||||
"tokenHint": "Enter this token when accessing the Web client for the first time"
|
"tokenHint": "Enter this token when accessing the Web client for the first time"
|
||||||
|
},
|
||||||
|
"DirectoryBrowser": {
|
||||||
|
"title": "Browse Directory",
|
||||||
|
"pathPlaceholder": "Enter directory path...",
|
||||||
|
"goHome": "Go to home directory",
|
||||||
|
"navigateUp": "Go to parent directory",
|
||||||
|
"select": "Select",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"emptyDirectory": "This directory is empty",
|
||||||
|
"errorLoadingDir": "Failed to load directory",
|
||||||
|
"permissionDenied": "Permission denied"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,8 @@
|
|||||||
"directoryPlaceholder": "Selecciona el directorio de destino...",
|
"directoryPlaceholder": "Selecciona el directorio de destino...",
|
||||||
"browseDirectory": "Explorar directorio",
|
"browseDirectory": "Explorar directorio",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"clone": "Clonar"
|
"clone": "Clonar",
|
||||||
|
"clonePath": "Ruta de clonación: {path}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"GitCredentialDialog": {
|
"GitCredentialDialog": {
|
||||||
@@ -1657,5 +1658,17 @@
|
|||||||
"addressLabel": "Dirección de acceso",
|
"addressLabel": "Dirección de acceso",
|
||||||
"tokenLabel": "Token de acceso",
|
"tokenLabel": "Token de acceso",
|
||||||
"tokenHint": "Ingrese este token al acceder al cliente Web por primera vez"
|
"tokenHint": "Ingrese este token al acceder al cliente Web por primera vez"
|
||||||
|
},
|
||||||
|
"DirectoryBrowser": {
|
||||||
|
"title": "Explorar directorio",
|
||||||
|
"pathPlaceholder": "Ingrese la ruta del directorio...",
|
||||||
|
"goHome": "Ir al directorio principal",
|
||||||
|
"navigateUp": "Ir al directorio superior",
|
||||||
|
"select": "Seleccionar",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"loading": "Cargando...",
|
||||||
|
"emptyDirectory": "Este directorio está vacío",
|
||||||
|
"errorLoadingDir": "Error al cargar el directorio",
|
||||||
|
"permissionDenied": "Permiso denegado"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,8 @@
|
|||||||
"directoryPlaceholder": "Sélectionnez le répertoire cible...",
|
"directoryPlaceholder": "Sélectionnez le répertoire cible...",
|
||||||
"browseDirectory": "Parcourir le répertoire",
|
"browseDirectory": "Parcourir le répertoire",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"clone": "Cloner"
|
"clone": "Cloner",
|
||||||
|
"clonePath": "Chemin de clonage : {path}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"GitCredentialDialog": {
|
"GitCredentialDialog": {
|
||||||
@@ -1657,5 +1658,17 @@
|
|||||||
"addressLabel": "Adresse d'accès",
|
"addressLabel": "Adresse d'accès",
|
||||||
"tokenLabel": "Token d'accès",
|
"tokenLabel": "Token d'accès",
|
||||||
"tokenHint": "Entrez ce token lors du premier accès au client Web"
|
"tokenHint": "Entrez ce token lors du premier accès au client Web"
|
||||||
|
},
|
||||||
|
"DirectoryBrowser": {
|
||||||
|
"title": "Parcourir le répertoire",
|
||||||
|
"pathPlaceholder": "Entrez le chemin du répertoire...",
|
||||||
|
"goHome": "Aller au répertoire personnel",
|
||||||
|
"navigateUp": "Aller au répertoire parent",
|
||||||
|
"select": "Sélectionner",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"loading": "Chargement...",
|
||||||
|
"emptyDirectory": "Ce répertoire est vide",
|
||||||
|
"errorLoadingDir": "Échec du chargement du répertoire",
|
||||||
|
"permissionDenied": "Permission refusée"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,8 @@
|
|||||||
"directoryPlaceholder": "保存先ディレクトリを選択...",
|
"directoryPlaceholder": "保存先ディレクトリを選択...",
|
||||||
"browseDirectory": "ディレクトリを参照",
|
"browseDirectory": "ディレクトリを参照",
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"clone": "クローン"
|
"clone": "クローン",
|
||||||
|
"clonePath": "クローンパス: {path}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"GitCredentialDialog": {
|
"GitCredentialDialog": {
|
||||||
@@ -1657,5 +1658,17 @@
|
|||||||
"addressLabel": "アクセスアドレス",
|
"addressLabel": "アクセスアドレス",
|
||||||
"tokenLabel": "アクセストークン",
|
"tokenLabel": "アクセストークン",
|
||||||
"tokenHint": "Webクライアントの初回アクセス時にこのトークンを入力してください"
|
"tokenHint": "Webクライアントの初回アクセス時にこのトークンを入力してください"
|
||||||
|
},
|
||||||
|
"DirectoryBrowser": {
|
||||||
|
"title": "ディレクトリを参照",
|
||||||
|
"pathPlaceholder": "ディレクトリパスを入力...",
|
||||||
|
"goHome": "ホームディレクトリへ",
|
||||||
|
"navigateUp": "親ディレクトリへ",
|
||||||
|
"select": "選択",
|
||||||
|
"cancel": "キャンセル",
|
||||||
|
"loading": "読み込み中...",
|
||||||
|
"emptyDirectory": "このディレクトリは空です",
|
||||||
|
"errorLoadingDir": "ディレクトリの読み込みに失敗しました",
|
||||||
|
"permissionDenied": "アクセス権がありません"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,8 @@
|
|||||||
"directoryPlaceholder": "대상 디렉터리 선택...",
|
"directoryPlaceholder": "대상 디렉터리 선택...",
|
||||||
"browseDirectory": "디렉터리 찾아보기",
|
"browseDirectory": "디렉터리 찾아보기",
|
||||||
"cancel": "취소",
|
"cancel": "취소",
|
||||||
"clone": "클론"
|
"clone": "클론",
|
||||||
|
"clonePath": "클론 경로: {path}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"GitCredentialDialog": {
|
"GitCredentialDialog": {
|
||||||
@@ -1657,5 +1658,17 @@
|
|||||||
"addressLabel": "접속 주소",
|
"addressLabel": "접속 주소",
|
||||||
"tokenLabel": "접속 토큰",
|
"tokenLabel": "접속 토큰",
|
||||||
"tokenHint": "웹 클라이언트 첫 접속 시 이 토큰을 입력하세요"
|
"tokenHint": "웹 클라이언트 첫 접속 시 이 토큰을 입력하세요"
|
||||||
|
},
|
||||||
|
"DirectoryBrowser": {
|
||||||
|
"title": "디렉토리 찾아보기",
|
||||||
|
"pathPlaceholder": "디렉토리 경로 입력...",
|
||||||
|
"goHome": "홈 디렉토리로 이동",
|
||||||
|
"navigateUp": "상위 디렉토리로 이동",
|
||||||
|
"select": "선택",
|
||||||
|
"cancel": "취소",
|
||||||
|
"loading": "로딩 중...",
|
||||||
|
"emptyDirectory": "이 디렉토리는 비어 있습니다",
|
||||||
|
"errorLoadingDir": "디렉토리 로딩 실패",
|
||||||
|
"permissionDenied": "권한이 없습니다"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,8 @@
|
|||||||
"directoryPlaceholder": "Selecione o diretório de destino...",
|
"directoryPlaceholder": "Selecione o diretório de destino...",
|
||||||
"browseDirectory": "Procurar diretório",
|
"browseDirectory": "Procurar diretório",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"clone": "Clonar"
|
"clone": "Clonar",
|
||||||
|
"clonePath": "Caminho de clonagem: {path}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"GitCredentialDialog": {
|
"GitCredentialDialog": {
|
||||||
@@ -1657,5 +1658,17 @@
|
|||||||
"addressLabel": "Endereço de acesso",
|
"addressLabel": "Endereço de acesso",
|
||||||
"tokenLabel": "Token de acesso",
|
"tokenLabel": "Token de acesso",
|
||||||
"tokenHint": "Insira este token ao acessar o cliente Web pela primeira vez"
|
"tokenHint": "Insira este token ao acessar o cliente Web pela primeira vez"
|
||||||
|
},
|
||||||
|
"DirectoryBrowser": {
|
||||||
|
"title": "Explorar diretório",
|
||||||
|
"pathPlaceholder": "Digite o caminho do diretório...",
|
||||||
|
"goHome": "Ir para o diretório inicial",
|
||||||
|
"navigateUp": "Ir para o diretório superior",
|
||||||
|
"select": "Selecionar",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"loading": "Carregando...",
|
||||||
|
"emptyDirectory": "Este diretório está vazio",
|
||||||
|
"errorLoadingDir": "Falha ao carregar o diretório",
|
||||||
|
"permissionDenied": "Permissão negada"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,8 @@
|
|||||||
"directoryPlaceholder": "选择目标目录...",
|
"directoryPlaceholder": "选择目标目录...",
|
||||||
"browseDirectory": "浏览目录",
|
"browseDirectory": "浏览目录",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"clone": "克隆"
|
"clone": "克隆",
|
||||||
|
"clonePath": "克隆路径: {path}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"GitCredentialDialog": {
|
"GitCredentialDialog": {
|
||||||
@@ -1657,5 +1658,17 @@
|
|||||||
"addressLabel": "访问地址",
|
"addressLabel": "访问地址",
|
||||||
"tokenLabel": "访问 Token",
|
"tokenLabel": "访问 Token",
|
||||||
"tokenHint": "Web 客户端首次访问时需输入此 Token"
|
"tokenHint": "Web 客户端首次访问时需输入此 Token"
|
||||||
|
},
|
||||||
|
"DirectoryBrowser": {
|
||||||
|
"title": "浏览目录",
|
||||||
|
"pathPlaceholder": "输入目录路径...",
|
||||||
|
"goHome": "回到主目录",
|
||||||
|
"navigateUp": "返回上级目录",
|
||||||
|
"select": "选择",
|
||||||
|
"cancel": "取消",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"emptyDirectory": "此目录为空",
|
||||||
|
"errorLoadingDir": "加载目录失败",
|
||||||
|
"permissionDenied": "权限不足"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,8 @@
|
|||||||
"directoryPlaceholder": "選擇目標目錄...",
|
"directoryPlaceholder": "選擇目標目錄...",
|
||||||
"browseDirectory": "瀏覽目錄",
|
"browseDirectory": "瀏覽目錄",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"clone": "複製"
|
"clone": "複製",
|
||||||
|
"clonePath": "克隆路徑: {path}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"GitCredentialDialog": {
|
"GitCredentialDialog": {
|
||||||
@@ -1657,5 +1658,17 @@
|
|||||||
"addressLabel": "存取位址",
|
"addressLabel": "存取位址",
|
||||||
"tokenLabel": "存取 Token",
|
"tokenLabel": "存取 Token",
|
||||||
"tokenHint": "Web 用戶端首次存取時需輸入此 Token"
|
"tokenHint": "Web 用戶端首次存取時需輸入此 Token"
|
||||||
|
},
|
||||||
|
"DirectoryBrowser": {
|
||||||
|
"title": "瀏覽目錄",
|
||||||
|
"pathPlaceholder": "輸入目錄路徑...",
|
||||||
|
"goHome": "回到主目錄",
|
||||||
|
"navigateUp": "返回上層目錄",
|
||||||
|
"select": "選擇",
|
||||||
|
"cancel": "取消",
|
||||||
|
"loading": "載入中...",
|
||||||
|
"emptyDirectory": "此目錄為空",
|
||||||
|
"errorLoadingDir": "載入目錄失敗",
|
||||||
|
"permissionDenied": "權限不足"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import type {
|
|||||||
TerminalInfo,
|
TerminalInfo,
|
||||||
PromptInputBlock,
|
PromptInputBlock,
|
||||||
FileTreeNode,
|
FileTreeNode,
|
||||||
|
DirectoryEntry,
|
||||||
FilePreviewContent,
|
FilePreviewContent,
|
||||||
FileEditContent,
|
FileEditContent,
|
||||||
FileSaveResult,
|
FileSaveResult,
|
||||||
@@ -1107,6 +1108,18 @@ export async function bootstrapFolderCommandsFromPackageJson(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Directory browser (for web/server mode)
|
||||||
|
|
||||||
|
export async function getHomeDirectory(): Promise<string> {
|
||||||
|
return getTransport().call("get_home_directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDirectoryEntries(
|
||||||
|
path: string
|
||||||
|
): Promise<DirectoryEntry[]> {
|
||||||
|
return getTransport().call("list_directory_entries", { path })
|
||||||
|
}
|
||||||
|
|
||||||
// File tree and git log commands
|
// File tree and git log commands
|
||||||
|
|
||||||
export async function getFileTree(
|
export async function getFileTree(
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import type {
|
|||||||
TerminalInfo,
|
TerminalInfo,
|
||||||
PromptInputBlock,
|
PromptInputBlock,
|
||||||
FileTreeNode,
|
FileTreeNode,
|
||||||
|
DirectoryEntry,
|
||||||
FilePreviewContent,
|
FilePreviewContent,
|
||||||
FileEditContent,
|
FileEditContent,
|
||||||
FileSaveResult,
|
FileSaveResult,
|
||||||
@@ -972,6 +973,18 @@ export async function bootstrapFolderCommandsFromPackageJson(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Directory browser
|
||||||
|
|
||||||
|
export async function getHomeDirectory(): Promise<string> {
|
||||||
|
return invoke("get_home_directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDirectoryEntries(
|
||||||
|
path: string
|
||||||
|
): Promise<DirectoryEntry[]> {
|
||||||
|
return invoke("list_directory_entries", { path })
|
||||||
|
}
|
||||||
|
|
||||||
// File tree and git log commands
|
// File tree and git log commands
|
||||||
|
|
||||||
export async function getFileTree(
|
export async function getFileTree(
|
||||||
|
|||||||
@@ -730,6 +730,12 @@ export type FileTreeNode =
|
|||||||
| { kind: "file"; name: string; path: string }
|
| { kind: "file"; name: string; path: string }
|
||||||
| { kind: "dir"; name: string; path: string; children: FileTreeNode[] }
|
| { kind: "dir"; name: string; path: string; children: FileTreeNode[] }
|
||||||
|
|
||||||
|
export interface DirectoryEntry {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
hasChildren: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface FilePreviewContent {
|
export interface FilePreviewContent {
|
||||||
path: string
|
path: string
|
||||||
content: string
|
content: string
|
||||||
|
|||||||
Reference in New Issue
Block a user