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:
2026-10-13 14:43:03 +07:00
parent b0dd5af22f
commit 763759189f
22 changed files with 1174 additions and 110 deletions
@@ -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();
+612 -72
View File
@@ -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",
]);
});
});