feat(server-mode): native-like folder browser, a developer kept to their own folder, no password minimum
- Folder browser (the Browse fallback for a remote browser): back/forward/up, a clickable breadcrumb that becomes a path field (Ctrl+L), Places (Home, the user's workspaces, recent picks), a filter, Name/Modified columns, Show hidden and New folder. A click selects; a double-click or Enter opens a folder or picks a file; Backspace goes up; typing jumps to a name. - GET /api/lanes/browse: `hidden=1` (never the dot entries at the top of a non-admin's own folder), each entry's `mtime`, the answer's `home`. - POST /api/lanes/browse/mkdir: one plain folder name inside an existing folder; a non-admin only in their own folder, none in server mode without one; under S3 a member's folder is made as the member. - scope.ownFolder falls back to server.conf's USERS_ROOT under S3, so a settings file without AGENTHUB_USERS_ROOT no longer opens the host's home to every developer. - Passwords: any non-empty password is accepted (no 8-character minimum). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
* @description Unit tests for the Browse button beside path fields: a chosen
|
||||
* path fills the field, a Cancel changes nothing, and a folder or a file falls
|
||||
* back to the in-app browser on ANY native-picker failure (a LAN browser's
|
||||
* EREMOTE included).
|
||||
* EREMOTE included), where a double-click on a file picks it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -15,7 +15,7 @@ import { toast } from "../../lib/toast";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: { lanes: { pickPath: vi.fn(), browse: vi.fn() } },
|
||||
api: { lanes: { pickPath: vi.fn(), browse: vi.fn(), list: vi.fn(() => new Promise(() => {})) } },
|
||||
}));
|
||||
vi.mock("../../lib/toast", () => ({
|
||||
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
|
||||
@@ -55,7 +55,7 @@ describe("PathPickButton", () => {
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the in-app browser for a file too, and a click on a file picks it", async () => {
|
||||
it("opens the in-app browser for a file too, and a double-click on a file picks it", async () => {
|
||||
const onPick = vi.fn();
|
||||
vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EREMOTE"));
|
||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||
@@ -68,9 +68,9 @@ describe("PathPickButton", () => {
|
||||
render(<PathPickButton kind="file" start="~/.ssh" onPick={onPick} />);
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Browse for a file" });
|
||||
expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true);
|
||||
expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true, false);
|
||||
expect(within(dialog).queryByText("Select this folder")).toBeNull();
|
||||
await userEvent.click(await within(dialog).findByText("id_ed25519"));
|
||||
await userEvent.dblClick(await within(dialog).findByText("id_ed25519"));
|
||||
expect(onPick).toHaveBeenCalledWith("/home/u/.ssh/id_ed25519");
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(toast.info).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,20 +1,97 @@
|
||||
/**
|
||||
* @file A server-backed folder (or file) browser for the path inputs' Browse
|
||||
* button. Browsers refuse to expose an absolute filesystem path from a native
|
||||
* picker, so path selection here is done by browsing `GET /api/lanes/browse`
|
||||
* (immediate subdirectories of a path, plus its files for `kind="file"`)
|
||||
* instead — breadcrumb-free, just an up-one-level button and a
|
||||
* click-to-descend list; in file mode a click on a file picks it.
|
||||
* button, laid out like the OS's own picker. Browsers refuse to expose an
|
||||
* absolute filesystem path from a native picker, so path selection here is
|
||||
* done by browsing `GET /api/lanes/browse` instead: back/forward/up, a
|
||||
* clickable breadcrumb that turns into a path field (Ctrl+L), a Places column
|
||||
* (Home, the user's workspaces, recent picks), a filter, Name/Modified columns,
|
||||
* hidden entries on demand and New folder (`POST /api/lanes/browse/mkdir`).
|
||||
* A click selects, a double-click or Enter opens a folder (picks a file),
|
||||
* Backspace goes up and typing jumps to a name.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen, FolderGit2, ArrowUp, File } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
Clock,
|
||||
Eye,
|
||||
File,
|
||||
FolderGit2,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
Home,
|
||||
Pencil,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { useWorkLanes } from "../../hooks/useLanes";
|
||||
|
||||
export function FolderBrowseModal({
|
||||
type Listing = Awaited<ReturnType<typeof api.lanes.browse>>;
|
||||
type Entry = Listing["entries"][number];
|
||||
|
||||
const RECENT_KEY = "agenthub.browse.recent";
|
||||
const HIDDEN_KEY = "agenthub.browse.hidden";
|
||||
const RECENT_MAX = 8;
|
||||
|
||||
function readStored<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as T) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(key: string, value: unknown) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// A private window or blocked storage: the preference just isn't kept.
|
||||
}
|
||||
}
|
||||
|
||||
/** `[label, path]` for each level of `p`, root first. */
|
||||
function crumbs(p: string): [string, string][] {
|
||||
const win = /^[A-Za-z]:\\/.test(p);
|
||||
const sep = win ? "\\" : "/";
|
||||
const parts = p.split(sep).filter(Boolean);
|
||||
const out: [string, string][] = win ? [] : [["/", "/"]];
|
||||
let acc = "";
|
||||
for (const part of parts) {
|
||||
acc = win ? (acc ? `${acc}\\${part}` : `${part}\\`) : `${acc}/${part}`;
|
||||
out.push([part, acc]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const baseName = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() || p;
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
/** `file` lists files too, and picking one is the only way to select. */
|
||||
kind?: "dir" | "file";
|
||||
/** Path to start browsing from; omitted defaults server-side to the home dir. */
|
||||
initialPath?: string;
|
||||
onSelect: (path: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mounted only while open, so a closed picker (one behind every Browse
|
||||
* button) asks the server for nothing, and each opening starts fresh.
|
||||
*/
|
||||
export function FolderBrowseModal(props: Props) {
|
||||
return props.open ? <BrowseDialog {...props} /> : null;
|
||||
}
|
||||
|
||||
function BrowseDialog({
|
||||
open,
|
||||
kind = "dir",
|
||||
initialPath,
|
||||
@@ -30,20 +107,62 @@ export function FolderBrowseModal({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [listing, setListing] = useState<Awaited<ReturnType<typeof api.lanes.browse>> | null>(null);
|
||||
const { lanes } = useWorkLanes();
|
||||
const [listing, setListing] = useState<Listing | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [history, setHistory] = useState<{ stack: string[]; at: number }>({ stack: [], at: -1 });
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [pathText, setPathText] = useState("");
|
||||
const [sort, setSort] = useState<{ key: "name" | "mtime"; dir: 1 | -1 }>({
|
||||
key: "name",
|
||||
dir: 1,
|
||||
});
|
||||
const [showHidden, setShowHidden] = useState(() => readStored(HIDDEN_KEY, false));
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [recent, setRecent] = useState<string[]>(() => readStored(RECENT_KEY, []));
|
||||
// The highest folder this user may open (a member's own folder answers no
|
||||
// parent) — breadcrumbs above it are not links.
|
||||
const [floor, setFloor] = useState<string | null>(null);
|
||||
const [home, setHome] = useState<string | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const crumbRef = useRef<HTMLDivElement>(null);
|
||||
const typeAhead = useRef({ text: "", at: 0 });
|
||||
|
||||
const load = async (path?: string) => {
|
||||
const load = async (
|
||||
target?: string,
|
||||
{ push = true, hidden = showHidden, select = null as string | null } = {}
|
||||
) => {
|
||||
setError(null);
|
||||
try {
|
||||
setListing(await (kind === "file" ? api.lanes.browse(path, true) : api.lanes.browse(path)));
|
||||
const next = await api.lanes.browse(target, kind === "file", hidden);
|
||||
setListing(next);
|
||||
setSelected(select);
|
||||
setFilter("");
|
||||
setEditing(false);
|
||||
if (next.home) setHome(next.home);
|
||||
if (next.parent === null && crumbs(next.path).length > 1) setFloor(next.path);
|
||||
if (push) {
|
||||
setHistory((h) => {
|
||||
if (h.stack[h.at] === next.path) return h;
|
||||
const stack = [...h.stack.slice(0, h.at + 1), next.path];
|
||||
return { stack, at: stack.length - 1 };
|
||||
});
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void load(initialPath);
|
||||
if (!open) return;
|
||||
setHistory({ stack: [], at: -1 });
|
||||
setCreating(false);
|
||||
void load(initialPath);
|
||||
// Only re-run when the modal actually opens - not on every initialPath
|
||||
// keystroke in the field behind it.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -54,8 +173,164 @@ export function FolderBrowseModal({
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
useDialog(open, panel, onClose);
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const needle = filter.trim().toLowerCase();
|
||||
const list = (listing?.entries ?? []).filter(
|
||||
(e) => !needle || e.name.toLowerCase().includes(needle)
|
||||
);
|
||||
const by = (a: Entry, b: Entry) =>
|
||||
sort.key === "mtime"
|
||||
? ((a.mtime ?? 0) - (b.mtime ?? 0)) * sort.dir
|
||||
: a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }) *
|
||||
sort.dir;
|
||||
// Folders first, as every file picker lists them.
|
||||
return [...list].sort((a, b) => Number(!!a.file) - Number(!!b.file) || by(a, b));
|
||||
}, [listing, filter, sort]);
|
||||
|
||||
const places = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
return lanes
|
||||
.filter((l) => l.cwd && !seen.has(l.cwd) && seen.add(l.cwd))
|
||||
.map((l) => ({ label: l.title || baseName(l.cwd), path: l.cwd }));
|
||||
}, [lanes]);
|
||||
|
||||
// A deep path keeps its LAST levels in view, like a native path bar.
|
||||
useEffect(() => {
|
||||
const el = crumbRef.current;
|
||||
if (el) el.scrollLeft = el.scrollWidth;
|
||||
}, [listing?.path, editing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
const el = Array.from(listRef.current?.querySelectorAll<HTMLElement>("[data-path]") ?? []).find(
|
||||
(x) => x.dataset.path === selected
|
||||
);
|
||||
el?.scrollIntoView?.({ block: "nearest" });
|
||||
}, [selected]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const finish = (picked: string) => {
|
||||
const folder = kind === "file" ? (listing?.path ?? picked) : picked;
|
||||
const next = [folder, ...recent.filter((r) => r !== folder)].slice(0, RECENT_MAX);
|
||||
setRecent(next);
|
||||
writeStored(RECENT_KEY, next);
|
||||
onSelect(picked);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const activate = (entry: Entry) => {
|
||||
if (entry.file) finish(entry.path);
|
||||
else void load(entry.path);
|
||||
};
|
||||
|
||||
const go = (delta: -1 | 1) => {
|
||||
const at = history.at + delta;
|
||||
const target = history.stack[at];
|
||||
if (target === undefined) return;
|
||||
void load(target, { push: false }).then((ok) => {
|
||||
if (ok) setHistory((h) => ({ ...h, at }));
|
||||
});
|
||||
};
|
||||
|
||||
const goUp = () => {
|
||||
if (!listing?.parent) return;
|
||||
const from = listing.path;
|
||||
void load(listing.parent, { select: from });
|
||||
};
|
||||
|
||||
const selectedEntry = entries.find((e) => e.path === selected) ?? null;
|
||||
const target =
|
||||
kind === "file"
|
||||
? selectedEntry?.file
|
||||
? selectedEntry.path
|
||||
: null
|
||||
: selectedEntry && !selectedEntry.file
|
||||
? selectedEntry.path
|
||||
: (listing?.path ?? null);
|
||||
|
||||
const onListKey = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const idx = entries.findIndex((x) => x.path === selected);
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (e.altKey && e.key === "ArrowUp") return goUp();
|
||||
const next =
|
||||
e.key === "ArrowDown" ? Math.min(entries.length - 1, idx + 1) : Math.max(0, idx - 1);
|
||||
if (entries[next]) setSelected(entries[next].path);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Home" || e.key === "End") {
|
||||
e.preventDefault();
|
||||
const pick = e.key === "Home" ? entries[0] : entries[entries.length - 1];
|
||||
if (pick) setSelected(pick.path);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (selectedEntry) activate(selectedEntry);
|
||||
else if (kind === "dir" && target) finish(target);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
goUp();
|
||||
return;
|
||||
}
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
// Typing jumps to the first name that starts with what was typed.
|
||||
const now = Date.now();
|
||||
const ta = typeAhead.current;
|
||||
ta.text = now - ta.at > 800 ? e.key.toLowerCase() : ta.text + e.key.toLowerCase();
|
||||
ta.at = now;
|
||||
const hit = entries.find((x) => x.name.toLowerCase().startsWith(ta.text));
|
||||
if (hit) setSelected(hit.path);
|
||||
}
|
||||
};
|
||||
|
||||
const onPanelKey = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "l") {
|
||||
e.preventDefault();
|
||||
setPathText(listing?.path ?? "");
|
||||
setEditing(true);
|
||||
} else if (e.altKey && e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
go(-1);
|
||||
} else if (e.altKey && e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
go(1);
|
||||
}
|
||||
};
|
||||
|
||||
const createFolder = async () => {
|
||||
const name = newName.trim();
|
||||
if (!name || !listing) return;
|
||||
setError(null);
|
||||
try {
|
||||
const made = await api.lanes.mkdir(listing.path, name);
|
||||
setCreating(false);
|
||||
setNewName("");
|
||||
await load(listing.path, { push: false, select: made.path });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleHidden = () => {
|
||||
const next = !showHidden;
|
||||
setShowHidden(next);
|
||||
writeStored(HIDDEN_KEY, next);
|
||||
if (listing) void load(listing.path, { push: false, hidden: next, select: selected });
|
||||
};
|
||||
|
||||
const iconBtn =
|
||||
"flex h-7 w-7 items-center justify-center rounded-md text-fg-secondary hover:bg-surface-2 disabled:opacity-40 disabled:hover:bg-transparent";
|
||||
const placeBtn = (active: boolean) =>
|
||||
`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs ${
|
||||
active ? "bg-surface-2 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"
|
||||
}`;
|
||||
const path = listing?.path ?? "";
|
||||
const floorDepth = floor ? crumbs(floor).length : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
@@ -64,82 +339,347 @@ export function FolderBrowseModal({
|
||||
>
|
||||
<div
|
||||
ref={panel}
|
||||
className="relative flex max-h-[70vh] w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
className="relative flex h-[min(80vh,640px)] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={onPanelKey}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={kind === "file" ? t("browse.fileTitle") : t("browse.title")}
|
||||
data-tour="folder-browse"
|
||||
>
|
||||
<div className="border-b border-border p-3">
|
||||
<div className="truncate font-mono text-xs text-fg-secondary" title={listing?.path}>
|
||||
{listing?.path || "…"}
|
||||
{/* Toolbar: history, up, and the breadcrumb / path field. */}
|
||||
<div className="flex items-center gap-1 border-b border-border p-2">
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
onClick={() => go(-1)}
|
||||
disabled={history.at <= 0}
|
||||
title={t("browse.back")}
|
||||
aria-label={t("browse.back")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
onClick={() => go(1)}
|
||||
disabled={history.at >= history.stack.length - 1}
|
||||
title={t("browse.forward")}
|
||||
aria-label={t("browse.forward")}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
onClick={goUp}
|
||||
disabled={!listing?.parent}
|
||||
title={t("browse.up")}
|
||||
aria-label={t("browse.up")}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={pathText}
|
||||
onChange={(e) => setPathText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
if (pathText.trim()) void load(pathText.trim());
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
onBlur={() => setEditing(false)}
|
||||
aria-label={t("browse.editPath")}
|
||||
spellCheck={false}
|
||||
className="input min-w-0 flex-1 font-mono text-xs"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
ref={crumbRef}
|
||||
className="flex min-w-0 flex-1 items-center overflow-x-auto rounded-md border border-border-light px-1 py-0.5"
|
||||
onDoubleClick={() => {
|
||||
setPathText(path);
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
{crumbs(path).map(([label, p], i, all) => {
|
||||
const last = i === all.length - 1;
|
||||
const locked = i + 1 < floorDepth;
|
||||
return (
|
||||
<span key={p} className="flex shrink-0 items-center">
|
||||
{i > 0 && all[i - 1]?.[0] !== "/" && (
|
||||
<span className="px-0.5 text-fg-muted">›</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={last || locked}
|
||||
onClick={() => void load(p, { select: all[i + 1]?.[1] ?? null })}
|
||||
className={`rounded px-1 py-0.5 font-mono text-xs ${
|
||||
last
|
||||
? "font-medium text-fg-primary"
|
||||
: locked
|
||||
? "text-fg-muted"
|
||||
: "text-fg-secondary hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
setPathText(path);
|
||||
setEditing((v) => !v);
|
||||
}}
|
||||
title={t("browse.editPath")}
|
||||
aria-label={t("browse.editPath")}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Places */}
|
||||
<nav
|
||||
className="hidden w-44 shrink-0 overflow-y-auto border-r border-border p-2 sm:block"
|
||||
aria-label={t("browse.places")}
|
||||
>
|
||||
{home && (
|
||||
<button
|
||||
type="button"
|
||||
className={placeBtn(path === home)}
|
||||
onClick={() => void load(home)}
|
||||
title={home}
|
||||
>
|
||||
<Home className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{t("browse.home")}</span>
|
||||
</button>
|
||||
)}
|
||||
{places.length > 0 && (
|
||||
<>
|
||||
<div className="mt-3 flex items-center gap-1 px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
|
||||
<Star className="h-3 w-3" />
|
||||
{t("browse.workspaces")}
|
||||
</div>
|
||||
{places.map((pl) => (
|
||||
<button
|
||||
key={pl.path}
|
||||
type="button"
|
||||
className={placeBtn(path === pl.path)}
|
||||
onClick={() => void load(pl.path)}
|
||||
title={pl.path}
|
||||
>
|
||||
<FolderGit2 className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
<span className="truncate">{pl.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{recent.length > 0 && (
|
||||
<>
|
||||
<div className="mt-3 flex items-center gap-1 px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
|
||||
<Clock className="h-3 w-3" />
|
||||
{t("browse.recent")}
|
||||
</div>
|
||||
{recent.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
className={placeBtn(path === r)}
|
||||
onClick={() => void load(r)}
|
||||
title={r}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{baseName(r)}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Listing */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-border p-2">
|
||||
<input
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (entries[0]) setSelected(entries[0].path);
|
||||
listRef.current?.focus();
|
||||
} else if (isSubmitEnter(e) && entries.length === 1 && entries[0]) {
|
||||
e.preventDefault();
|
||||
activate(entries[0]);
|
||||
}
|
||||
}}
|
||||
placeholder={t("browse.filter")}
|
||||
aria-label={t("browse.filter")}
|
||||
className="input min-w-0 flex-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center border-b border-border px-3 py-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
|
||||
{(["name", "mtime"] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSort((s) => ({ key, dir: s.key === key ? (s.dir === 1 ? -1 : 1) : 1 }))
|
||||
}
|
||||
className={`${key === "name" ? "flex-1 text-left" : "hidden w-32 text-right sm:block"} hover:text-fg-secondary`}
|
||||
>
|
||||
{key === "name" ? t("browse.name") : t("browse.modified")}
|
||||
{sort.key === key && (sort.dir === 1 ? " ↑" : " ↓")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
ref={listRef}
|
||||
tabIndex={0}
|
||||
role="listbox"
|
||||
aria-label={path}
|
||||
onKeyDown={onListKey}
|
||||
className="min-h-0 flex-1 overflow-y-auto p-1 outline-none"
|
||||
>
|
||||
{error && (
|
||||
<p role="alert" className="p-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{creating && (
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<FolderPlus className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<input
|
||||
autoFocus
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void createFolder();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setCreating(false);
|
||||
}
|
||||
}}
|
||||
placeholder={t("browse.newFolderName")}
|
||||
aria-label={t("browse.newFolderName")}
|
||||
className="input min-w-0 flex-1 text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void createFolder()}
|
||||
disabled={!newName.trim()}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{t("browse.create")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{entries.map((entry) => {
|
||||
const isSel = entry.path === selected;
|
||||
return (
|
||||
<div
|
||||
key={entry.path}
|
||||
data-path={entry.path}
|
||||
role="option"
|
||||
aria-selected={isSel}
|
||||
onClick={() => setSelected(entry.path)}
|
||||
onDoubleClick={() => activate(entry)}
|
||||
className={`flex cursor-default select-none items-center gap-2 rounded px-2 py-1.5 text-xs ${
|
||||
isSel ? "bg-accent-solid text-white" : "text-fg-primary hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{entry.file ? (
|
||||
<File className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-fg-muted"}`} />
|
||||
) : entry.isGitRepo ? (
|
||||
<FolderGit2
|
||||
className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-accent"}`}
|
||||
/>
|
||||
) : (
|
||||
<FolderOpen
|
||||
className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-fg-muted"}`}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
|
||||
<span
|
||||
className={`hidden w-32 shrink-0 text-right tabular-nums sm:block ${isSel ? "" : "text-fg-muted"}`}
|
||||
>
|
||||
{entry.mtime ? formatDateTime(new Date(entry.mtime).toISOString()) : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{listing && entries.length === 0 && (
|
||||
<p className="p-2 text-xs text-fg-muted">
|
||||
{filter.trim() ? t("browse.noMatch") : t("browse.empty")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-1">
|
||||
{error && (
|
||||
<p role="alert" className="p-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{listing?.parent && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => load(listing.parent!)}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
..
|
||||
</button>
|
||||
)}
|
||||
{listing?.entries.map((entry) => (
|
||||
<button
|
||||
key={entry.path}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!entry.file) return void load(entry.path);
|
||||
onSelect(entry.path);
|
||||
onClose();
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
|
||||
>
|
||||
{entry.file ? (
|
||||
<File className="h-3.5 w-3.5 text-fg-muted" />
|
||||
) : entry.isGitRepo ? (
|
||||
<FolderGit2 className="h-3.5 w-3.5 text-accent" />
|
||||
) : (
|
||||
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
|
||||
)}
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{listing && listing.entries.length === 0 && !listing.parent && (
|
||||
<p className="p-2 text-xs text-fg-muted">{t("browse.empty")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-ghost border border-border text-xs"
|
||||
{/* Footer: what will be picked, and the actions. */}
|
||||
<div className="border-t border-border p-2">
|
||||
<div
|
||||
// rtl so a long path loses its START to the ellipsis, not the folder.
|
||||
className="truncate px-1 pb-2 text-left font-mono text-xs text-fg-secondary [direction:rtl]"
|
||||
title={target ?? undefined}
|
||||
>
|
||||
{t("destructive.cancel")}
|
||||
</button>
|
||||
{kind === "dir" && (
|
||||
<bdi>{target ?? "…"}</bdi>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!listing}
|
||||
onClick={() => {
|
||||
if (listing) onSelect(listing.path);
|
||||
onClose();
|
||||
setNewName("");
|
||||
setCreating(true);
|
||||
}}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
disabled={!listing}
|
||||
className="btn-ghost flex items-center gap-1 border border-border text-xs disabled:opacity-50"
|
||||
>
|
||||
{t("browse.select")}
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
{t("browse.newFolder")}
|
||||
</button>
|
||||
)}
|
||||
<label className="flex cursor-pointer items-center gap-1 text-xs text-fg-secondary">
|
||||
<input type="checkbox" checked={showHidden} onChange={toggleHidden} />
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
{t("browse.showHidden")}
|
||||
</label>
|
||||
<span className="hidden flex-1 truncate text-[10px] text-fg-muted md:block">
|
||||
{t("browse.hint")}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
{t("destructive.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!target}
|
||||
onClick={() => target && finish(target)}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{kind === "file" ? t("browse.selectFile") : t("browse.select")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,8 @@ vi.mock("../../../lib/api", () => ({
|
||||
ensure: vi.fn(),
|
||||
browse: vi.fn(),
|
||||
pickPath: vi.fn(),
|
||||
// The folder browser's Places list the user's workspaces.
|
||||
list: vi.fn(() => Promise.resolve({ lanes: [], counts: {} })),
|
||||
get: vi.fn(),
|
||||
profileInit: vi.fn(),
|
||||
mcpSync: vi.fn(),
|
||||
@@ -614,7 +616,7 @@ describe("AddLaneModal — folder browse", () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the browser, lists subdirectories, and selecting one fills the path field", async () => {
|
||||
it("opens the browser, lists subdirectories, and a double-click opens one", async () => {
|
||||
noNativePicker();
|
||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||
path: "/Users/tester",
|
||||
@@ -627,8 +629,8 @@ describe("AddLaneModal — folder browse", () => {
|
||||
await user.click(screen.getByTitle("Browse for a folder"));
|
||||
expect(await screen.findByText("projects")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText("projects"));
|
||||
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects");
|
||||
await user.dblClick(screen.getByText("projects"));
|
||||
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects", false, false);
|
||||
});
|
||||
|
||||
it("Escape closes only the folder browser, not the whole modal", async () => {
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @file FolderBrowseModal.test.tsx
|
||||
* @description Unit tests for the in-app folder browser laid out like a
|
||||
* native picker: a click selects and Select picks that folder (else the
|
||||
* current one), a double-click opens, Backspace and the breadcrumb go up,
|
||||
* the filter narrows the list, Places open Home and a workspace, the path
|
||||
* field (Ctrl+L) opens a typed path, New folder makes one and selects it,
|
||||
* Show hidden re-lists with dot entries, and a Select is remembered as Recent.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { api } from "../../../lib/api";
|
||||
import { FolderBrowseModal } from "../FolderBrowseModal";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { browse: vi.fn(), mkdir: vi.fn(), list: vi.fn() } },
|
||||
}));
|
||||
|
||||
const TREE: Record<string, string[]> = {
|
||||
"/home/u": ["proj", "notes"],
|
||||
"/home/u/proj": ["api", "web"],
|
||||
"/home/u/proj/api": [],
|
||||
"/home/u/proj/web": [],
|
||||
"/home/u/notes": [],
|
||||
"/srv/ws": [],
|
||||
};
|
||||
|
||||
function listing(p: string, hidden = false) {
|
||||
const names = [...(TREE[p] ?? []), ...(hidden ? [".cfg"] : [])];
|
||||
return {
|
||||
path: p,
|
||||
parent: p === "/home/u" ? null : p.slice(0, p.lastIndexOf("/")) || "/",
|
||||
home: "/home/u",
|
||||
entries: names.map((n, i) => ({
|
||||
name: n,
|
||||
path: `${p}/${n}`,
|
||||
isGitRepo: n === "api",
|
||||
mtime: 1_700_000_000_000 + i,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.mocked(api.lanes.browse)
|
||||
.mockReset()
|
||||
.mockImplementation(async (p?: string, _f?: boolean, hidden?: boolean) => {
|
||||
const at = p || "/home/u";
|
||||
if (!(at in TREE)) throw new Error("path does not exist");
|
||||
return listing(at, hidden);
|
||||
});
|
||||
vi.mocked(api.lanes.mkdir).mockReset();
|
||||
vi.mocked(api.lanes.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
lanes: [{ id: 1, title: "Shop API", cwd: "/srv/ws" }],
|
||||
counts: {},
|
||||
} as never);
|
||||
});
|
||||
|
||||
const open = (onSelect = vi.fn(), initialPath?: string) => {
|
||||
render(
|
||||
<FolderBrowseModal open initialPath={initialPath} onSelect={onSelect} onClose={vi.fn()} />
|
||||
);
|
||||
return screen.findByRole("dialog", { name: "Browse for a folder" });
|
||||
};
|
||||
|
||||
const option = (dialog: HTMLElement, name: string) =>
|
||||
within(dialog).findByRole("option", { name: new RegExp(name) });
|
||||
|
||||
describe("FolderBrowseModal", () => {
|
||||
it("click selects, double-click opens, Select picks the selection else the folder", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const dialog = await open(onSelect);
|
||||
await userEvent.click(await option(dialog, "proj"));
|
||||
expect(await option(dialog, "proj")).toHaveAttribute("aria-selected", "true");
|
||||
await userEvent.dblClick(await option(dialog, "proj"));
|
||||
await option(dialog, "web");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Select this folder" }));
|
||||
expect(onSelect).toHaveBeenCalledWith("/home/u/proj");
|
||||
});
|
||||
|
||||
it("Enter opens the selected folder and Backspace goes back up to it", async () => {
|
||||
const dialog = await open();
|
||||
const list = within(dialog).getByRole("listbox");
|
||||
await userEvent.click(await option(dialog, "proj"));
|
||||
await userEvent.keyboard("{Enter}");
|
||||
await option(dialog, "api");
|
||||
list.focus();
|
||||
await userEvent.keyboard("{Backspace}");
|
||||
await waitFor(() =>
|
||||
expect(within(dialog).getByRole("option", { name: /proj/ })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("the breadcrumb, Back and Forward move between folders", async () => {
|
||||
const dialog = await open(vi.fn(), "/home/u/proj/api");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "proj" }));
|
||||
await option(dialog, "web");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: /^Back/ }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj/api", false, false)
|
||||
);
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: /^Forward/ }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj", false, false)
|
||||
);
|
||||
// Above the user's own folder (it answered no parent) nothing is a link.
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: /^Home$/ }));
|
||||
await option(dialog, "notes");
|
||||
expect(within(dialog).getByRole("button", { name: "home" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("the filter narrows the list, and Enter on a single match opens it", async () => {
|
||||
const dialog = await open();
|
||||
await option(dialog, "notes");
|
||||
await userEvent.type(within(dialog).getByRole("textbox", { name: "Filter…" }), "pro");
|
||||
expect(within(dialog).queryByRole("option", { name: /notes/ })).toBeNull();
|
||||
await userEvent.keyboard("{Enter}");
|
||||
await option(dialog, "web");
|
||||
});
|
||||
|
||||
it("Places open a workspace and Home; Ctrl+L opens a typed path", async () => {
|
||||
const dialog = await open(vi.fn(), "/home/u/proj");
|
||||
await userEvent.click(await within(dialog).findByRole("button", { name: /Shop API/ }));
|
||||
await waitFor(() => expect(api.lanes.browse).toHaveBeenLastCalledWith("/srv/ws", false, false));
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: /^Home$/ }));
|
||||
await option(dialog, "notes");
|
||||
await userEvent.keyboard("{Control>}l{/Control}");
|
||||
const field = within(dialog).getByRole("textbox", { name: "Type a path (Ctrl+L)" });
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, "/home/u/proj/web{Enter}");
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj/web", false, false)
|
||||
);
|
||||
});
|
||||
|
||||
it("New folder makes one in the current folder and selects it", async () => {
|
||||
vi.mocked(api.lanes.mkdir).mockImplementation(async (parent, name) => {
|
||||
TREE[parent] = [...(TREE[parent] ?? []), name];
|
||||
return { path: `${parent}/${name}` };
|
||||
});
|
||||
const dialog = await open();
|
||||
await option(dialog, "proj");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: /New folder/ }));
|
||||
await userEvent.type(
|
||||
within(dialog).getByRole("textbox", { name: "Folder name" }),
|
||||
"fresh{Enter}"
|
||||
);
|
||||
expect(api.lanes.mkdir).toHaveBeenCalledWith("/home/u", "fresh");
|
||||
await waitFor(async () =>
|
||||
expect(await option(dialog, "fresh")).toHaveAttribute("aria-selected", "true")
|
||||
);
|
||||
TREE["/home/u"] = ["proj", "notes"];
|
||||
});
|
||||
|
||||
it("Show hidden re-lists with dot entries, and a pick is kept as Recent", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const dialog = await open(onSelect);
|
||||
await option(dialog, "proj");
|
||||
expect(within(dialog).queryByRole("option", { name: /\.cfg/ })).toBeNull();
|
||||
await userEvent.click(within(dialog).getByRole("checkbox", { name: /Show hidden/ }));
|
||||
await option(dialog, "\\.cfg");
|
||||
await userEvent.dblClick(await option(dialog, "proj"));
|
||||
await option(dialog, "web");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Select this folder" }));
|
||||
expect(onSelect).toHaveBeenCalledWith("/home/u/proj");
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.browse.recent") || "[]")).toEqual([
|
||||
"/home/u/proj",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,24 @@
|
||||
"browse.empty": "This folder is empty.",
|
||||
"browse.fileTitle": "Browse for a file",
|
||||
"browse.select": "Select this folder",
|
||||
"browse.back": "Back (Alt+←)",
|
||||
"browse.create": "Create",
|
||||
"browse.editPath": "Type a path (Ctrl+L)",
|
||||
"browse.filter": "Filter…",
|
||||
"browse.forward": "Forward (Alt+→)",
|
||||
"browse.hint": "Double-click or Enter opens · Backspace goes up · type to jump",
|
||||
"browse.home": "Home",
|
||||
"browse.modified": "Modified",
|
||||
"browse.name": "Name",
|
||||
"browse.newFolder": "New folder",
|
||||
"browse.newFolderName": "Folder name",
|
||||
"browse.noMatch": "Nothing matches the filter.",
|
||||
"browse.places": "Places",
|
||||
"browse.recent": "Recent",
|
||||
"browse.selectFile": "Select file",
|
||||
"browse.showHidden": "Show hidden",
|
||||
"browse.up": "Up one level (Backspace)",
|
||||
"browse.workspaces": "Workspaces",
|
||||
"browse.title": "Browse for a folder",
|
||||
"changes.back": "All files",
|
||||
"changes.bin": "bin",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"username": "Username",
|
||||
"usernameHint": "Letters, digits, '.', '_' or '-'",
|
||||
"password": "Password",
|
||||
"passwordHint": "At least 8 characters",
|
||||
"passwordHint": "No minimum length",
|
||||
"newPassword": "New password",
|
||||
"changeRole": "Change role",
|
||||
"disable": "Disable",
|
||||
@@ -64,7 +64,7 @@
|
||||
"ESELF": "You cannot change your own role or disable yourself.",
|
||||
"EEXISTS": "That username is taken.",
|
||||
"EBADUSER": "Username: letters, digits, '.', '_' or '-' (up to 64).",
|
||||
"EBADPASSWORD": "Password: at least 8 characters."
|
||||
"EBADPASSWORD": "Password cannot be empty."
|
||||
},
|
||||
"readOnly": "Analysts can view accounts but not change them.",
|
||||
"auditTitle": "Audit log",
|
||||
|
||||
@@ -42,6 +42,24 @@
|
||||
"browse.empty": "Thư mục này trống.",
|
||||
"browse.fileTitle": "Duyệt chọn tệp",
|
||||
"browse.select": "Chọn thư mục này",
|
||||
"browse.back": "Quay lại (Alt+←)",
|
||||
"browse.create": "Tạo",
|
||||
"browse.editPath": "Gõ đường dẫn (Ctrl+L)",
|
||||
"browse.filter": "Lọc…",
|
||||
"browse.forward": "Tiến (Alt+→)",
|
||||
"browse.hint": "Nháy đúp hoặc Enter để mở · Backspace để lên · gõ chữ để nhảy tới",
|
||||
"browse.home": "Thư mục chính",
|
||||
"browse.modified": "Ngày sửa",
|
||||
"browse.name": "Tên",
|
||||
"browse.newFolder": "Thư mục mới",
|
||||
"browse.newFolderName": "Tên thư mục",
|
||||
"browse.noMatch": "Không có mục nào khớp bộ lọc.",
|
||||
"browse.places": "Vị trí",
|
||||
"browse.recent": "Gần đây",
|
||||
"browse.selectFile": "Chọn tệp",
|
||||
"browse.showHidden": "Hiện mục ẩn",
|
||||
"browse.up": "Lên một cấp (Backspace)",
|
||||
"browse.workspaces": "Workspace",
|
||||
"browse.title": "Duyệt chọn thư mục",
|
||||
"changes.back": "Tất cả file",
|
||||
"changes.bin": "bin",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"username": "Tên đăng nhập",
|
||||
"usernameHint": "Chữ, số, '.', '_' hoặc '-'",
|
||||
"password": "Mật khẩu",
|
||||
"passwordHint": "Ít nhất 8 ký tự",
|
||||
"passwordHint": "Không giới hạn độ dài tối thiểu",
|
||||
"newPassword": "Mật khẩu mới",
|
||||
"changeRole": "Đổi vai trò",
|
||||
"disable": "Khoá",
|
||||
@@ -64,7 +64,7 @@
|
||||
"ESELF": "Bạn không thể tự đổi vai trò hay tự khoá chính mình.",
|
||||
"EEXISTS": "Tên đăng nhập này đã có người dùng.",
|
||||
"EBADUSER": "Tên đăng nhập: chữ, số, '.', '_' hoặc '-' (tối đa 64 ký tự).",
|
||||
"EBADPASSWORD": "Mật khẩu: ít nhất 8 ký tự."
|
||||
"EBADPASSWORD": "Mật khẩu không được để trống."
|
||||
},
|
||||
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
|
||||
"auditTitle": "Nhật ký audit",
|
||||
|
||||
+23
-3
@@ -2971,19 +2971,39 @@ export const api = {
|
||||
* filesystem paths from a native picker, so this drives a server-backed one.
|
||||
* @param path Absolute path to list; defaults server-side to the home dir.
|
||||
* @param files List files too (`file: true`) — a file field's picker.
|
||||
* @returns `{ path, parent, entries }` — `parent` is null at the root.
|
||||
* @param hidden List dot entries too (never at the top of a member's home).
|
||||
* @returns `{ path, parent, entries, home }` — `parent` is null at the
|
||||
* root, `home` is the folder the browser's Home place opens.
|
||||
*/
|
||||
browse: (path?: string, files?: boolean) => {
|
||||
browse: (path?: string, files?: boolean, hidden?: boolean) => {
|
||||
const q = new URLSearchParams();
|
||||
if (path) q.set("path", path);
|
||||
if (files) q.set("files", "1");
|
||||
if (hidden) q.set("hidden", "1");
|
||||
const qs = q.toString();
|
||||
return request<{
|
||||
path: string;
|
||||
parent: string | null;
|
||||
entries: { name: string; path: string; isGitRepo: boolean; file?: boolean }[];
|
||||
home?: string;
|
||||
entries: {
|
||||
name: string;
|
||||
path: string;
|
||||
isGitRepo: boolean;
|
||||
file?: boolean;
|
||||
mtime?: number | null;
|
||||
}[];
|
||||
}>(`/lanes/browse${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
/**
|
||||
* POST /api/lanes/browse/mkdir — the folder browser's New folder: one
|
||||
* folder `name` inside `parent`. Throws `.code` `EBADNAME`, `EEXISTS`,
|
||||
* `EFOLDER` (outside the caller's own folder).
|
||||
*/
|
||||
mkdir: (parent: string, name: string) =>
|
||||
request<{ path: string }>("/lanes/browse/mkdir", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ parent, name }),
|
||||
}),
|
||||
/**
|
||||
* POST /api/lanes/pick-path — open the OS's own folder/file picker on the
|
||||
* server's desktop and wait for the user. Throws `.code` `EREMOTE` (not a
|
||||
|
||||
Reference in New Issue
Block a user