Merge pull request #1466 from Runfusion/gsxdsm/issue-1454-manage-projet-bug
fix: empty folder selection reverts in DirectoryPicker + add create-folder button
This commit is contained in:
@@ -7000,6 +7000,14 @@ export function browseDirectory(
|
||||
return api<BrowseDirectoryResult>(fullPath);
|
||||
}
|
||||
|
||||
/** Create a new directory */
|
||||
export function createDirectory(path: string): Promise<{ success: true; path: string }> {
|
||||
return api<{ success: true; path: string }>("/create-directory", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Register a new project */
|
||||
export function registerProject(input: ProjectCreateInput): Promise<ProjectInfo> {
|
||||
return api<ProjectInfo>("/projects", {
|
||||
|
||||
@@ -188,3 +188,47 @@
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
}
|
||||
|
||||
/* Create folder */
|
||||
.directory-picker-create-folder-toggle {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.directory-picker-create-folder-toggle:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.directory-picker-create-folder-toggle:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.directory-picker-create-folder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.directory-picker-create-folder-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.directory-picker-create-folder-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.directory-picker-create-folder-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
border: 1px solid var(--color-error);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-error);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Folder, FolderOpen, ChevronRight, ChevronUp, Loader2, Eye, EyeOff, AlertCircle } from "lucide-react";
|
||||
import { browseDirectory, type BrowseDirectoryResult } from "../api";
|
||||
import { Folder, FolderOpen, ChevronRight, ChevronUp, Loader2, Eye, EyeOff, AlertCircle, Plus } from "lucide-react";
|
||||
import { browseDirectory, createDirectory, type BrowseDirectoryResult } from "../api";
|
||||
import { getPathBreadcrumbs } from "../utils/pathDisplay";
|
||||
import "./DirectoryPicker.css";
|
||||
|
||||
@@ -25,6 +25,8 @@ interface BrowserState {
|
||||
parentPath: string | null;
|
||||
entries: BrowseDirectoryResult["entries"];
|
||||
showHidden: boolean;
|
||||
createFolderOpen: boolean;
|
||||
createFolderError: string | null;
|
||||
}
|
||||
|
||||
export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown, nodeId, localNodeId }: DirectoryPickerProps) {
|
||||
@@ -37,7 +39,10 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
|
||||
parentPath: null,
|
||||
entries: [],
|
||||
showHidden: false,
|
||||
createFolderOpen: false,
|
||||
createFolderError: null,
|
||||
});
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
|
||||
const fetchEntries = useCallback(async (path?: string, showHidden = false) => {
|
||||
setBrowser((prev) => ({ ...prev, loading: true, error: null }));
|
||||
@@ -69,12 +74,12 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Fetch when browser opens
|
||||
// Fetch when browser opens (only for the initial open before any path has been fetched)
|
||||
useEffect(() => {
|
||||
if (browser.isOpen && !browser.loading && browser.entries.length === 0 && !browser.error) {
|
||||
if (browser.isOpen && !browser.loading && !browser.currentPath && !browser.error) {
|
||||
fetchEntries(value || undefined, browser.showHidden);
|
||||
}
|
||||
}, [browser.isOpen, browser.loading, browser.entries.length, browser.error, value, browser.showHidden, fetchEntries, nodeId, localNodeId]);
|
||||
}, [browser.isOpen, browser.loading, browser.currentPath, browser.error, value, browser.showHidden, fetchEntries, nodeId, localNodeId]);
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(path: string) => {
|
||||
@@ -100,7 +105,62 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
|
||||
if (browser.isOpen && browser.currentPath) {
|
||||
fetchEntries(browser.currentPath, browser.showHidden);
|
||||
}
|
||||
}, [browser.showHidden]);
|
||||
}, [browser.showHidden, fetchEntries]);
|
||||
|
||||
const handleToggleCreateFolder = useCallback(() => {
|
||||
setBrowser((prev) => ({
|
||||
...prev,
|
||||
createFolderOpen: !prev.createFolderOpen,
|
||||
createFolderError: null,
|
||||
}));
|
||||
setNewFolderName("");
|
||||
}, []);
|
||||
|
||||
const handleCreateFolder = useCallback(async () => {
|
||||
if (!newFolderName.trim() || !browser.currentPath) return;
|
||||
|
||||
// Validate folder name doesn't contain path separators or traversal
|
||||
const trimmedName = newFolderName.trim();
|
||||
if (trimmedName.includes("/") || trimmedName.includes("\\") || trimmedName.includes("..")) {
|
||||
setBrowser((prev) => ({
|
||||
...prev,
|
||||
createFolderError: t("dirPicker.createFolderError", "Folder name cannot contain path separators or '..'"),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize path separator for the current platform by using the same
|
||||
// separator already present in currentPath
|
||||
const sep = browser.currentPath.includes("\\") ? "\\" : "/";
|
||||
const folderPath = browser.currentPath.endsWith(sep)
|
||||
? browser.currentPath + trimmedName
|
||||
: browser.currentPath + sep + trimmedName;
|
||||
|
||||
setBrowser((prev) => ({ ...prev, loading: true, createFolderError: null }));
|
||||
try {
|
||||
await createDirectory(folderPath);
|
||||
setNewFolderName("");
|
||||
setBrowser((prev) => ({ ...prev, createFolderOpen: false }));
|
||||
// Refresh entries to show the new folder
|
||||
await fetchEntries(browser.currentPath, browser.showHidden);
|
||||
} catch (err) {
|
||||
setBrowser((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
createFolderError: err instanceof Error ? err.message : "Failed to create folder",
|
||||
}));
|
||||
}
|
||||
}, [newFolderName, browser.currentPath, browser.showHidden, fetchEntries]);
|
||||
|
||||
const handleCreateFolderKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleCreateFolder();
|
||||
} else if (e.key === "Escape") {
|
||||
setBrowser((prev) => ({ ...prev, createFolderOpen: false, createFolderError: null }));
|
||||
setNewFolderName("");
|
||||
}
|
||||
}, [handleCreateFolder]);
|
||||
|
||||
const breadcrumbs = browser.currentPath ? getPathBreadcrumbs(browser.currentPath) : [];
|
||||
|
||||
@@ -171,6 +231,16 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
|
||||
{browser.showHidden ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
<span>{browser.showHidden ? t("dirPicker.hideHidden", "Hide hidden") : t("dirPicker.showHidden", "Show hidden")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary directory-picker-create-folder-toggle"
|
||||
onClick={handleToggleCreateFolder}
|
||||
aria-label={t("dirPicker.createFolderAria", "Create new folder")}
|
||||
title={t("dirPicker.createFolderTitle", "Create folder")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>{t("dirPicker.createFolder", "New folder")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -209,6 +279,44 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create folder input */}
|
||||
{browser.createFolderOpen && (
|
||||
<div className="directory-picker-create-folder">
|
||||
<input
|
||||
type="text"
|
||||
className="input directory-picker-create-folder-input"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
onKeyDown={handleCreateFolderKeyDown}
|
||||
placeholder={t("dirPicker.newFolderPlaceholder", "Folder name")}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="directory-picker-create-folder-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={handleCreateFolder}
|
||||
disabled={!newFolderName.trim() || browser.loading || !browser.currentPath}
|
||||
>
|
||||
{t("dirPicker.createFolderConfirm", "Create")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={handleToggleCreateFolder}
|
||||
>
|
||||
{t("dirPicker.cancel", "Cancel")}
|
||||
</button>
|
||||
</div>
|
||||
{browser.createFolderError && (
|
||||
<div className="directory-picker-create-folder-error">
|
||||
<AlertCircle size={14} />
|
||||
<span>{browser.createFolderError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="directory-picker-actions">
|
||||
<span className="directory-picker-selected-path" title={browser.currentPath}>
|
||||
|
||||
@@ -15,17 +15,20 @@ vi.mock("lucide-react", async () => {
|
||||
Eye: ({ size, ...props }: any) => <span {...props}>👁</span>,
|
||||
EyeOff: ({ size, ...props }: any) => <span {...props}>🙈</span>,
|
||||
AlertCircle: ({ size, ...props }: any) => <span {...props}>⚠</span>,
|
||||
Plus: ({ size, ...props }: any) => <span {...props}>+</span>,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the API
|
||||
vi.mock("../../api", () => ({
|
||||
browseDirectory: vi.fn(),
|
||||
createDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
import { browseDirectory } from "../../api";
|
||||
import { browseDirectory, createDirectory } from "../../api";
|
||||
|
||||
const mockBrowseDirectory = vi.mocked(browseDirectory);
|
||||
const mockCreateDirectory = vi.mocked(createDirectory);
|
||||
|
||||
describe("DirectoryPicker", () => {
|
||||
beforeEach(() => {
|
||||
@@ -243,4 +246,117 @@ describe("DirectoryPicker", () => {
|
||||
expect(mockBrowseDirectory).toHaveBeenCalledWith("/home/user/projects", false, "remote-1", "local-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not revert to previous folder when navigating into an empty directory (FN-XXXX)", async () => {
|
||||
// First load: home directory with a folder
|
||||
mockBrowseDirectory.mockResolvedValueOnce({
|
||||
currentPath: "/home/user",
|
||||
parentPath: "/home",
|
||||
entries: [
|
||||
{ name: "empty-folder", path: "/home/user/empty-folder", hasChildren: false },
|
||||
],
|
||||
});
|
||||
|
||||
// Second load: the empty folder itself (no subdirectories)
|
||||
mockBrowseDirectory.mockResolvedValueOnce({
|
||||
currentPath: "/home/user/empty-folder",
|
||||
parentPath: "/home/user",
|
||||
entries: [],
|
||||
});
|
||||
|
||||
render(<DirectoryPicker value="" onChange={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Browse"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("empty-folder")).toBeDefined();
|
||||
});
|
||||
|
||||
// Navigate into the empty folder
|
||||
fireEvent.click(screen.getByText("empty-folder"));
|
||||
|
||||
// Should show "No subdirectories" in the empty folder, NOT re-fetch the parent
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No subdirectories")).toBeDefined();
|
||||
});
|
||||
|
||||
// The current path should remain the empty folder, not revert to /home/user
|
||||
expect(mockBrowseDirectory).toHaveBeenLastCalledWith(
|
||||
"/home/user/empty-folder",
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows creating a new folder", async () => {
|
||||
mockBrowseDirectory.mockResolvedValue({
|
||||
currentPath: "/home/user",
|
||||
parentPath: "/home",
|
||||
entries: [
|
||||
{ name: "projects", path: "/home/user/projects", hasChildren: true },
|
||||
],
|
||||
});
|
||||
|
||||
mockCreateDirectory.mockResolvedValue({ success: true, path: "/home/user/my-new-folder" });
|
||||
|
||||
render(<DirectoryPicker value="" onChange={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Browse"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("projects")).toBeDefined();
|
||||
});
|
||||
|
||||
// Click "New folder" button
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create new folder" }));
|
||||
|
||||
// Type folder name
|
||||
const input = screen.getByPlaceholderText("Folder name");
|
||||
fireEvent.change(input, { target: { value: "my-new-folder" } });
|
||||
|
||||
// Click Create
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateDirectory).toHaveBeenCalledWith("/home/user/my-new-folder");
|
||||
});
|
||||
|
||||
// Should refresh the directory listing
|
||||
await waitFor(() => {
|
||||
expect(mockBrowseDirectory).toHaveBeenLastCalledWith("/home/user", false, undefined, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error when creating folder fails", async () => {
|
||||
mockBrowseDirectory.mockResolvedValue({
|
||||
currentPath: "/home/user",
|
||||
parentPath: "/home",
|
||||
entries: [],
|
||||
});
|
||||
|
||||
mockCreateDirectory.mockRejectedValue(new Error("Permission denied"));
|
||||
|
||||
render(<DirectoryPicker value="" onChange={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Browse"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No subdirectories")).toBeDefined();
|
||||
});
|
||||
|
||||
// Click "New folder" button
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create new folder" }));
|
||||
|
||||
// Type folder name
|
||||
const input = screen.getByPlaceholderText("Folder name");
|
||||
fireEvent.change(input, { target: { value: "test-folder" } });
|
||||
|
||||
// Click Create
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Permission denied")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4455,6 +4455,88 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/create-directory
|
||||
* Create a new directory at the specified path.
|
||||
* Body: { path: string }
|
||||
* Returns: { success: true, path: string }
|
||||
*/
|
||||
router.post("/create-directory", async (req, res) => {
|
||||
try {
|
||||
const { resolve, isAbsolute } = await import("node:path");
|
||||
const { mkdir, stat } = await import("node:fs/promises");
|
||||
|
||||
const rawPath = typeof req.body?.path === "string" ? req.body.path.trim() : "";
|
||||
if (!rawPath) {
|
||||
throw badRequest("Path is required");
|
||||
}
|
||||
|
||||
// Validate: must be absolute, no .. traversal
|
||||
if (!isAbsolute(rawPath)) {
|
||||
throw badRequest("Path must be absolute");
|
||||
}
|
||||
if (rawPath.includes("..")) {
|
||||
throw badRequest("Path must not contain '..' traversal");
|
||||
}
|
||||
const resolvedPath = resolve(rawPath);
|
||||
|
||||
// Check if path already exists
|
||||
try {
|
||||
const existingStat = await stat(resolvedPath);
|
||||
if (existingStat.isDirectory()) {
|
||||
throw badRequest("Directory already exists");
|
||||
}
|
||||
throw badRequest("A file already exists at this path");
|
||||
} catch (err: unknown) {
|
||||
const e = err as { code?: string };
|
||||
if (e.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
// ENOENT means it doesn't exist — proceed
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
const { dirname } = await import("node:path");
|
||||
const parentPath = dirname(resolvedPath);
|
||||
try {
|
||||
const parentStat = await stat(parentPath);
|
||||
if (!parentStat.isDirectory()) {
|
||||
throw badRequest("Parent path is not a directory");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const e = err as { code?: string };
|
||||
if (e.code === "ENOENT") {
|
||||
throw badRequest("Parent directory does not exist");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Create the directory
|
||||
try {
|
||||
await mkdir(resolvedPath);
|
||||
} catch (err: unknown) {
|
||||
const e = err as NodeJS.ErrnoException;
|
||||
if (e.code === "EEXIST") {
|
||||
throw badRequest("Directory already exists");
|
||||
}
|
||||
if (e.code === "ENOENT") {
|
||||
throw badRequest("Parent directory does not exist");
|
||||
}
|
||||
if (e.code === "ENOTDIR") {
|
||||
throw badRequest("Parent path is not a directory");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
res.json({ success: true, path: resolvedPath });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Registrar order is API-contract sensitive. Keep this domain sequence stable:
|
||||
// 1) project routes (`/projects/across-nodes|detect` before `/projects/:id`)
|
||||
// 2) node CRUD/operational routes
|
||||
|
||||
@@ -1630,12 +1630,19 @@
|
||||
"dirPicker": {
|
||||
"ariaLabel": "Directory browser",
|
||||
"browse": "Browse",
|
||||
"cancel": "Cancel",
|
||||
"closeBrowser": "Close directory browser",
|
||||
"createFolder": "New folder",
|
||||
"createFolderAria": "Create new folder",
|
||||
"createFolderConfirm": "Create",
|
||||
"createFolderError": "Folder name cannot contain path separators or '..'",
|
||||
"createFolderTitle": "Create folder",
|
||||
"defaultPlaceholder": "/path/to/your/project",
|
||||
"hideHidden": "Hide hidden",
|
||||
"hideHiddenAria": "Hide hidden directories",
|
||||
"hideHiddenTitle": "Hide hidden",
|
||||
"loading": "Loading…",
|
||||
"newFolderPlaceholder": "Folder name",
|
||||
"noSubdirs": "No subdirectories",
|
||||
"openBrowser": "Browse directories",
|
||||
"parentDir": "Go to parent directory",
|
||||
|
||||
@@ -1629,12 +1629,19 @@
|
||||
"dirPicker": {
|
||||
"ariaLabel": "Explorador de directorios",
|
||||
"browse": "Examinar",
|
||||
"cancel": "Cancelar",
|
||||
"closeBrowser": "Cerrar explorador de directorios",
|
||||
"createFolder": "Nueva carpeta",
|
||||
"createFolderAria": "Crear nueva carpeta",
|
||||
"createFolderConfirm": "Crear",
|
||||
"createFolderError": "El nombre de la carpeta no puede contener separadores de ruta o '..'",
|
||||
"createFolderTitle": "Crear carpeta",
|
||||
"defaultPlaceholder": "/ruta/a/tu/proyecto",
|
||||
"hideHidden": "Ocultar ocultos",
|
||||
"hideHiddenAria": "Ocultar directorios ocultos",
|
||||
"hideHiddenTitle": "Ocultar ocultos",
|
||||
"loading": "Cargando…",
|
||||
"newFolderPlaceholder": "Nombre de carpeta",
|
||||
"noSubdirs": "Sin subdirectorios",
|
||||
"openBrowser": "Examinar directorios",
|
||||
"parentDir": "Ir al directorio padre",
|
||||
|
||||
@@ -1629,12 +1629,19 @@
|
||||
"dirPicker": {
|
||||
"ariaLabel": "Navigateur de répertoires",
|
||||
"browse": "Parcourir",
|
||||
"cancel": "Annuler",
|
||||
"closeBrowser": "Fermer le navigateur de répertoires",
|
||||
"createFolder": "Nouveau dossier",
|
||||
"createFolderAria": "Créer un nouveau dossier",
|
||||
"createFolderConfirm": "Créer",
|
||||
"createFolderError": "Le nom du dossier ne peut pas contenir de séparateurs de chemin ou '..'",
|
||||
"createFolderTitle": "Créer un dossier",
|
||||
"defaultPlaceholder": "/chemin/vers/votre/projet",
|
||||
"hideHidden": "Masquer les fichiers cachés",
|
||||
"hideHiddenAria": "Masquer les répertoires cachés",
|
||||
"hideHiddenTitle": "Masquer les fichiers cachés",
|
||||
"loading": "Chargement…",
|
||||
"newFolderPlaceholder": "Nom du dossier",
|
||||
"noSubdirs": "Aucun sous-répertoire",
|
||||
"openBrowser": "Parcourir les répertoires",
|
||||
"parentDir": "Aller au répertoire parent",
|
||||
|
||||
@@ -1629,12 +1629,19 @@
|
||||
"dirPicker": {
|
||||
"ariaLabel": "디렉터리 브라우저",
|
||||
"browse": "찾아보기",
|
||||
"cancel": "취소",
|
||||
"closeBrowser": "디렉터리 브라우저 닫기",
|
||||
"createFolder": "새 폴더",
|
||||
"createFolderAria": "새 폴더 만들기",
|
||||
"createFolderConfirm": "만들기",
|
||||
"createFolderError": "폴더 이름에 경로 구분자나 '..'를 포함할 수 없습니다",
|
||||
"createFolderTitle": "폴더 만들기",
|
||||
"defaultPlaceholder": "/path/to/your/project",
|
||||
"hideHidden": "숨김 파일 숨기기",
|
||||
"hideHiddenAria": "숨겨진 디렉터리 숨기기",
|
||||
"hideHiddenTitle": "숨김 파일 숨기기",
|
||||
"loading": "로드 중…",
|
||||
"newFolderPlaceholder": "폴더 이름",
|
||||
"noSubdirs": "하위 디렉터리 없음",
|
||||
"openBrowser": "디렉터리 찾아보기",
|
||||
"parentDir": "상위 디렉터리로 이동",
|
||||
|
||||
@@ -1629,12 +1629,19 @@
|
||||
"dirPicker": {
|
||||
"ariaLabel": "目录浏览器",
|
||||
"browse": "浏览",
|
||||
"cancel": "取消",
|
||||
"closeBrowser": "关闭目录浏览器",
|
||||
"createFolder": "新建文件夹",
|
||||
"createFolderAria": "创建新文件夹",
|
||||
"createFolderConfirm": "创建",
|
||||
"createFolderError": "文件夹名称不能包含路径分隔符或 '..'",
|
||||
"createFolderTitle": "创建文件夹",
|
||||
"defaultPlaceholder": "/path/to/your/project",
|
||||
"hideHidden": "隐藏隐藏",
|
||||
"hideHiddenAria": "隐藏隐藏目录",
|
||||
"hideHiddenTitle": "隐藏隐藏",
|
||||
"loading": "加载中…",
|
||||
"newFolderPlaceholder": "文件夹名称",
|
||||
"noSubdirs": "无子目录",
|
||||
"openBrowser": "浏览目录",
|
||||
"parentDir": "进入父目录",
|
||||
|
||||
@@ -1629,12 +1629,19 @@
|
||||
"dirPicker": {
|
||||
"ariaLabel": "目錄瀏覽器",
|
||||
"browse": "瀏覽",
|
||||
"cancel": "取消",
|
||||
"closeBrowser": "關閉目錄瀏覽器",
|
||||
"createFolder": "新增資料夾",
|
||||
"createFolderAria": "建立新資料夾",
|
||||
"createFolderConfirm": "建立",
|
||||
"createFolderError": "資料夾名稱不能包含路徑分隔符或 '..'",
|
||||
"createFolderTitle": "建立資料夾",
|
||||
"defaultPlaceholder": "/path/to/your/project",
|
||||
"hideHidden": "隱藏隱藏",
|
||||
"hideHiddenAria": "隱藏隱藏目錄",
|
||||
"hideHiddenTitle": "隱藏隱藏",
|
||||
"loading": "載入中…",
|
||||
"newFolderPlaceholder": "資料夾名稱",
|
||||
"noSubdirs": "無子目錄",
|
||||
"openBrowser": "瀏覽目錄",
|
||||
"parentDir": "進入上層目錄",
|
||||
|
||||
Reference in New Issue
Block a user