feat(web): refine add-project folder picker UX
- Keyboard nav (arrows/typeahead/Enter) now works when focus is on the modal dialog, not only after clicking into the browser pane - Announce git-repo status to screen readers via folder-row aria-label - Square the drive picker to match the toolbar buttons - Redraw the broken refresh icon as a clean circular arrow - Drop the redundant current-path line below the breadcrumb - Remove the Recent sidebar; reclaim its width for the folder list - Add a selectable "this folder" row so the current directory - and git repos with no subfolders - can be added - Reveal a descend chevron on row hover/selection - Place Project ID and Project Name on one two-column row Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c7f847a10c
commit
b0188d68ea
|
|
@ -4736,7 +4736,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
height: 28px;
|
||||
min-width: 76px;
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-base);
|
||||
border-radius: 0;
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
|
|
@ -4751,8 +4751,8 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
}
|
||||
|
||||
.add-project-modal__drive-select:focus {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
outline: 1px solid var(--color-accent);
|
||||
outline-offset: -1px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
|
|
@ -4772,88 +4772,9 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
min-height: 0;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 176px minmax(0, 1fr);
|
||||
background: var(--color-bg-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.add-project-browser__sidebar {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--color-border-subtle);
|
||||
background: var(--color-bg-sidebar);
|
||||
}
|
||||
|
||||
.add-project-browser__sidebar-section {
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.add-project-browser__sidebar-toggle {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.add-project-browser__sidebar-toggle:hover,
|
||||
.add-project-browser__sidebar-toggle:focus-visible {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-surface);
|
||||
}
|
||||
|
||||
.add-project-browser__sidebar-chevron {
|
||||
color: var(--color-text-tertiary);
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.add-project-browser__sidebar-chevron.is-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.add-project-browser__sidebar-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 6px 8px;
|
||||
}
|
||||
|
||||
.add-project-browser__sidebar-item {
|
||||
min-width: 0;
|
||||
min-height: 30px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0 6px;
|
||||
text-align: left;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.add-project-browser__sidebar-item:hover,
|
||||
.add-project-browser__sidebar-item:focus-visible {
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.add-project-browser__main {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
background: var(--color-bg-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.add-project-browser__current {
|
||||
|
|
@ -4895,14 +4816,6 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.add-project-browser__current-path {
|
||||
margin-top: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--color-text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.add-project-browser__rows {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
|
|
@ -4944,15 +4857,54 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
}
|
||||
|
||||
.add-project-browser__row-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.add-project-browser__badge {
|
||||
margin-left: auto;
|
||||
.add-project-browser__row-tag {
|
||||
flex: 0 0 auto;
|
||||
margin-left: 8px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.add-project-browser__row-chevron {
|
||||
flex: 0 0 auto;
|
||||
margin-left: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-tertiary);
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
|
||||
.add-project-browser__row:hover .add-project-browser__row-chevron,
|
||||
.add-project-browser__row.is-selected .add-project-browser__row-chevron {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.add-project-browser__row--current {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.add-project-browser__empty-row {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.add-project-browser__badge {
|
||||
flex: 0 0 auto;
|
||||
margin-left: 8px;
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 999px;
|
||||
padding: 1px 6px;
|
||||
|
|
@ -5006,6 +4958,25 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
.add-project-modal__formrow {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.add-project-modal__field {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.add-project-modal__field .add-project-modal__selection-path {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.add-project-modal__selection-label {
|
||||
flex: none;
|
||||
font-family: var(--font-sans);
|
||||
|
|
@ -5173,20 +5144,10 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
display: block;
|
||||
}
|
||||
|
||||
.add-project-browser {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.add-project-browser__sidebar {
|
||||
max-height: 112px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.add-project-browser__sidebar-body {
|
||||
max-height: 76px;
|
||||
overflow-y: auto;
|
||||
.add-project-modal__formrow {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.add-project-modal__footer {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
const RECENT_PATHS_KEY = "ao:add-project:recent";
|
||||
const WINDOWS_DRIVE_ROOT_PATTERN = /^[A-Za-z]:[\\/]?$/;
|
||||
const WINDOWS_ABSOLUTE_PATTERN = /^[A-Za-z]:[\\/]/;
|
||||
const UNC_ROOT_PATTERN = /^\\\\[^\\]+\\[^\\]+\\?$/;
|
||||
|
|
@ -78,22 +77,6 @@ export function getBreadcrumbs(currentPath: string): Array<{ label: string; path
|
|||
return crumbs;
|
||||
}
|
||||
|
||||
export function loadRecentPaths(): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const parsed = JSON.parse(window.localStorage.getItem(RECENT_PATHS_KEY) ?? "[]");
|
||||
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveRecentPath(input: string) {
|
||||
if (typeof window === "undefined") return;
|
||||
const next = [input, ...loadRecentPaths().filter((value) => value !== input)].slice(0, 5);
|
||||
window.localStorage.setItem(RECENT_PATHS_KEY, JSON.stringify(next));
|
||||
}
|
||||
|
||||
function Glyph({
|
||||
children,
|
||||
className,
|
||||
|
|
@ -106,28 +89,6 @@ function Glyph({
|
|||
return <svg aria-hidden="true" viewBox={viewBox} className={className}>{children}</svg>;
|
||||
}
|
||||
|
||||
export function SidebarSection({
|
||||
title,
|
||||
open,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="add-project-browser__sidebar-section">
|
||||
<button type="button" className="add-project-browser__sidebar-toggle" onClick={onToggle}>
|
||||
<span>{title}</span>
|
||||
<span className={`add-project-browser__sidebar-chevron${open ? " is-open" : ""}`}>∨</span>
|
||||
</button>
|
||||
{open ? <div className="add-project-browser__sidebar-body">{children}</div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const iconPath = { fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinejoin: "miter" as const };
|
||||
const iconStroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.75, strokeLinecap: "square" as const };
|
||||
const compoundStroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "square" as const, strokeLinejoin: "miter" as const };
|
||||
|
|
@ -153,7 +114,7 @@ export function ArrowUpIcon() {
|
|||
}
|
||||
|
||||
export function RefreshIcon() {
|
||||
return <Glyph className="add-project-modal__toolicon"><path d="M12 5V2.75H9.75M4.5 11A4.75 4.75 0 0 0 12 5M3.75 11v2.25H6m5.5-8.25A4.75 4.75 0 0 1 4 11" {...compoundStroke} /></Glyph>;
|
||||
return <Glyph className="add-project-modal__toolicon"><path d="M13 8A5 5 0 1 1 8 3M6.3 1.7 8 3 6.3 4.3" {...compoundStroke} /></Glyph>;
|
||||
}
|
||||
|
||||
export function SortChevronIcon() {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
deriveProjectIdFromPath,
|
||||
deriveProjectNameFromPath,
|
||||
joinBrowsePath,
|
||||
saveRecentPath,
|
||||
} from "@/components/AddProjectModal.parts";
|
||||
import { DirectoryBrowser } from "@/components/DirectoryBrowser";
|
||||
import { useDirectoryBrowser } from "@/hooks/useDirectoryBrowser";
|
||||
|
|
@ -125,7 +124,6 @@ export function AddProjectModal({ open, onClose }: AddProjectModalProps) {
|
|||
else setNetworkError(message);
|
||||
return;
|
||||
}
|
||||
saveRecentPath(resolvedPath);
|
||||
const nextProjectId = body?.projectId ?? projectId.trim();
|
||||
onClose();
|
||||
router.push(`/projects/${encodeURIComponent(nextProjectId)}`);
|
||||
|
|
@ -224,27 +222,29 @@ export function AddProjectModal({ open, onClose }: AddProjectModalProps) {
|
|||
<span className="add-project-modal__selection-label">Selected</span>
|
||||
<span className="add-project-modal__selection-path">{selectedBrowsePath || "No directory selected"}</span>
|
||||
</div>
|
||||
<div className="add-project-modal__pathbar add-project-modal__pathbar--selection">
|
||||
<label className="add-project-modal__selection-label" htmlFor="project-id-input">
|
||||
Project ID
|
||||
</label>
|
||||
<input
|
||||
id="project-id-input"
|
||||
value={projectIdInput}
|
||||
onChange={(event) => setProjectIdInput(event.target.value)}
|
||||
className="add-project-modal__selection-path"
|
||||
/>
|
||||
</div>
|
||||
<div className="add-project-modal__pathbar add-project-modal__pathbar--selection">
|
||||
<label className="add-project-modal__selection-label" htmlFor="project-name-input">
|
||||
Project name
|
||||
</label>
|
||||
<input
|
||||
id="project-name-input"
|
||||
value={projectNameInput}
|
||||
onChange={(event) => setProjectNameInput(event.target.value)}
|
||||
className="add-project-modal__selection-path"
|
||||
/>
|
||||
<div className="add-project-modal__pathbar add-project-modal__formrow">
|
||||
<div className="add-project-modal__field">
|
||||
<label className="add-project-modal__selection-label" htmlFor="project-id-input">
|
||||
Project ID
|
||||
</label>
|
||||
<input
|
||||
id="project-id-input"
|
||||
value={projectIdInput}
|
||||
onChange={(event) => setProjectIdInput(event.target.value)}
|
||||
className="add-project-modal__selection-path"
|
||||
/>
|
||||
</div>
|
||||
<div className="add-project-modal__field">
|
||||
<label className="add-project-modal__selection-label" htmlFor="project-name-input">
|
||||
Project name
|
||||
</label>
|
||||
<input
|
||||
id="project-name-input"
|
||||
value={projectNameInput}
|
||||
onChange={(event) => setProjectNameInput(event.target.value)}
|
||||
className="add-project-modal__selection-path"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{selectedNotice}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
ChevronLeftIcon,
|
||||
|
|
@ -8,9 +8,7 @@ import {
|
|||
FolderIcon,
|
||||
getBreadcrumbs,
|
||||
joinBrowsePath,
|
||||
loadRecentPaths,
|
||||
RefreshIcon,
|
||||
SidebarSection,
|
||||
} from "@/components/AddProjectModal.parts";
|
||||
import type { UseDirectoryBrowser } from "@/hooks/useDirectoryBrowser";
|
||||
|
||||
|
|
@ -28,13 +26,17 @@ function isFolderRowTarget(target: EventTarget | null): boolean {
|
|||
return target instanceof HTMLElement && target.classList.contains("add-project-browser__row");
|
||||
}
|
||||
|
||||
// A button/link handles Enter natively (breadcrumbs, toolbar buttons). Enter-to-descend
|
||||
// must skip those so it does not double-fire alongside the element's own activation.
|
||||
function isActivatableElement(target: EventTarget | null): boolean {
|
||||
return target instanceof HTMLButtonElement || target instanceof HTMLAnchorElement;
|
||||
}
|
||||
|
||||
export function DirectoryBrowser({ browser }: DirectoryBrowserProps) {
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const typeaheadBufferRef = useRef("");
|
||||
const typeaheadLastTsRef = useRef(0);
|
||||
const [recentOpen, setRecentOpen] = useState(true);
|
||||
const [recentPaths] = useState(() => loadRecentPaths());
|
||||
const selectedIndex = useMemo(
|
||||
() =>
|
||||
browser.directoryEntries.findIndex(
|
||||
|
|
@ -46,11 +48,18 @@ export function DirectoryBrowser({ browser }: DirectoryBrowserProps) {
|
|||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target;
|
||||
const isInsideBrowser =
|
||||
target instanceof Node &&
|
||||
(toolbarRef.current?.contains(target) || contentRef.current?.contains(target));
|
||||
if (!isInsideBrowser) return;
|
||||
if (isTextEditingTarget(event.target)) return;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (isTextEditingTarget(target)) return;
|
||||
// In scope when the event comes from inside the browser panes OR from an
|
||||
// ancestor that contains them (the modal dialog focused on open, document.body).
|
||||
// The plain ancestor `.contains` checks fail for that case because the dialog
|
||||
// is an ancestor of the panes, not a descendant.
|
||||
const content = contentRef.current;
|
||||
const inScope =
|
||||
(content?.contains(target) ?? false) ||
|
||||
(toolbarRef.current?.contains(target) ?? false) ||
|
||||
(content !== null && target.contains(content));
|
||||
if (!inScope) return;
|
||||
|
||||
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
const now = Date.now();
|
||||
|
|
@ -86,7 +95,7 @@ export function DirectoryBrowser({ browser }: DirectoryBrowserProps) {
|
|||
if (
|
||||
event.key === "Enter" &&
|
||||
selectedIndex >= 0 &&
|
||||
isFolderRowTarget(event.target) &&
|
||||
(isFolderRowTarget(target) || !isActivatableElement(target)) &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
|
|
@ -101,6 +110,11 @@ export function DirectoryBrowser({ browser }: DirectoryBrowserProps) {
|
|||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [browser, selectedIndex]);
|
||||
|
||||
const breadcrumbs = getBreadcrumbs(browser.browsePath);
|
||||
const currentName = breadcrumbs[breadcrumbs.length - 1]?.label ?? browser.browsePath;
|
||||
const showCurrentFolder = browser.browsePath !== "~" && browser.currentDirectory !== null;
|
||||
const currentSelected = browser.selectedBrowsePath === browser.browsePath;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={toolbarRef} className="add-project-modal__toolbar">
|
||||
|
|
@ -169,70 +183,74 @@ export function DirectoryBrowser({ browser }: DirectoryBrowserProps) {
|
|||
</div>
|
||||
|
||||
<div className="add-project-modal__content">
|
||||
<div className="add-project-browser">
|
||||
<aside className="add-project-browser__sidebar">
|
||||
<SidebarSection title="Recent" open={recentOpen} onToggle={() => setRecentOpen((open) => !open)}>
|
||||
{recentPaths.map((path) => (
|
||||
<div ref={contentRef} className="add-project-browser">
|
||||
<div className="add-project-browser__current">
|
||||
<div className="add-project-browser__current-label">Current folder</div>
|
||||
<div className="add-project-browser__breadcrumb">
|
||||
{breadcrumbs.map((crumb) => (
|
||||
<button
|
||||
key={path}
|
||||
key={crumb.path}
|
||||
type="button"
|
||||
className="add-project-browser__sidebar-item"
|
||||
onClick={() => void browser.browse(path, { selectedPath: path })}
|
||||
className="add-project-browser__crumb"
|
||||
onClick={() => void browser.browse(crumb.path)}
|
||||
>
|
||||
{path}
|
||||
{crumb.label}
|
||||
</button>
|
||||
))}
|
||||
</SidebarSection>
|
||||
</aside>
|
||||
<div ref={contentRef} className="add-project-browser__main">
|
||||
<div className="add-project-browser__current">
|
||||
<div className="add-project-browser__current-label">Current folder</div>
|
||||
<div className="add-project-browser__breadcrumb">
|
||||
{getBreadcrumbs(browser.browsePath).map((crumb) => (
|
||||
<button
|
||||
key={crumb.path}
|
||||
type="button"
|
||||
className="add-project-browser__crumb"
|
||||
onClick={() => void browser.browse(crumb.path)}
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="add-project-browser__current-path">{browser.browsePath}</div>
|
||||
</div>
|
||||
{browser.error ? (
|
||||
<div className="add-project-browser__state add-project-browser__state--error">
|
||||
<p className="add-project-browser__state-title">Directory browser unavailable</p>
|
||||
<p className="add-project-browser__state-copy">{browser.error}</p>
|
||||
</div>
|
||||
) : browser.loading ? (
|
||||
<div className="add-project-browser__state">
|
||||
<p className="add-project-browser__state-title">Loading folders</p>
|
||||
<p className="add-project-browser__state-copy">Fetching directories for this location.</p>
|
||||
</div>
|
||||
) : browser.directoryEntries.length === 0 ? (
|
||||
<div className="add-project-browser__state">
|
||||
<p className="add-project-browser__state-title">No visible folders here</p>
|
||||
<p className="add-project-browser__state-copy">Try navigating up or picking a different location.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="add-project-browser__rows">
|
||||
{browser.parentPath ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={browser.goUp}
|
||||
className="add-project-browser__row add-project-browser__row--parent"
|
||||
>
|
||||
..
|
||||
</button>
|
||||
) : null}
|
||||
{browser.directoryEntries.map((entry) => {
|
||||
</div>
|
||||
{browser.error ? (
|
||||
<div className="add-project-browser__state add-project-browser__state--error">
|
||||
<p className="add-project-browser__state-title">Directory browser unavailable</p>
|
||||
<p className="add-project-browser__state-copy">{browser.error}</p>
|
||||
</div>
|
||||
) : browser.loading ? (
|
||||
<div className="add-project-browser__state">
|
||||
<p className="add-project-browser__state-title">Loading folders</p>
|
||||
<p className="add-project-browser__state-copy">Fetching directories for this location.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="add-project-browser__rows">
|
||||
{showCurrentFolder ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
browser.currentDirectory?.isGitRepo
|
||||
? `${currentName}, current folder, Git repository`
|
||||
: `${currentName}, current folder`
|
||||
}
|
||||
onClick={() => browser.setSelectedBrowsePath(browser.browsePath)}
|
||||
className={`add-project-browser__row add-project-browser__row--current${currentSelected ? " is-selected" : ""}`}
|
||||
>
|
||||
<FolderIcon className="add-project-browser__row-icon" />
|
||||
<span className="add-project-browser__row-name">{currentName}</span>
|
||||
<span className="add-project-browser__row-tag">this folder</span>
|
||||
{browser.currentDirectory?.isGitRepo ? (
|
||||
<span className="add-project-browser__badge" aria-hidden="true">
|
||||
git
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
) : null}
|
||||
{browser.parentPath ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={browser.goUp}
|
||||
className="add-project-browser__row add-project-browser__row--parent"
|
||||
>
|
||||
..
|
||||
</button>
|
||||
) : null}
|
||||
{browser.directoryEntries.length === 0 ? (
|
||||
<p className="add-project-browser__empty-row">No subfolders in this location.</p>
|
||||
) : (
|
||||
browser.directoryEntries.map((entry) => {
|
||||
const nextPath = joinBrowsePath(browser.browsePath, entry.name);
|
||||
return (
|
||||
<button
|
||||
key={nextPath}
|
||||
type="button"
|
||||
aria-label={entry.isGitRepo ? `${entry.name}, Git repository` : entry.name}
|
||||
onClick={() => browser.setSelectedBrowsePath(nextPath)}
|
||||
onDoubleClick={() => void browser.browse(nextPath)}
|
||||
className={`add-project-browser__row${browser.selectedBrowsePath === nextPath ? " is-selected" : ""}`}
|
||||
|
|
@ -244,12 +262,15 @@ export function DirectoryBrowser({ browser }: DirectoryBrowserProps) {
|
|||
git
|
||||
</span>
|
||||
) : null}
|
||||
<span className="add-project-browser__row-chevron" aria-hidden="true">
|
||||
<ChevronRightIcon />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||
import { DirectoryBrowser } from "@/components/DirectoryBrowser";
|
||||
import { useDirectoryBrowser, type UseDirectoryBrowser } from "@/hooks/useDirectoryBrowser";
|
||||
|
||||
const localStorageDescriptor = Object.getOwnPropertyDescriptor(window, "localStorage");
|
||||
|
||||
function Harness() {
|
||||
const browser = useDirectoryBrowser();
|
||||
useEffect(() => {
|
||||
|
|
@ -17,7 +15,6 @@ function Harness() {
|
|||
describe("DirectoryBrowser", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
if (localStorageDescriptor) Object.defineProperty(window, "localStorage", localStorageDescriptor);
|
||||
});
|
||||
|
||||
it("renders folders from the browse API and selects on click", async () => {
|
||||
|
|
@ -57,7 +54,7 @@ describe("DirectoryBrowser", () => {
|
|||
|
||||
render(<Harness />);
|
||||
|
||||
const repoRow = await screen.findByRole("button", { name: "repo" });
|
||||
const repoRow = await screen.findByRole("button", { name: "repo, Git repository" });
|
||||
const plainRow = await screen.findByRole("button", { name: "plain" });
|
||||
|
||||
expect(repoRow.querySelector(".add-project-browser__row-icon")).not.toBeNull();
|
||||
|
|
@ -172,63 +169,20 @@ describe("DirectoryBrowser", () => {
|
|||
await waitFor(() => expect(row?.className).toContain("is-selected"));
|
||||
});
|
||||
|
||||
it("lists recent folders and navigates on click", async () => {
|
||||
const store = new Map<string, string>([["ao:add-project:recent", JSON.stringify(["~/projects/old"])]]);
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => store.set(k, v),
|
||||
removeItem: (k: string) => store.delete(k),
|
||||
clear: () => store.clear(),
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
key: (i: number) => [...store.keys()][i] ?? null,
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ entries: [], roots: [] }) });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<Harness />);
|
||||
|
||||
fireEvent.click(await screen.findByText("~/projects/old"));
|
||||
await waitFor(() =>
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/filesystem/browse?path=~%2Fprojects%2Fold"),
|
||||
);
|
||||
await waitFor(() => expect(screen.getAllByText("~/projects/old")).toHaveLength(2));
|
||||
});
|
||||
|
||||
it("selects the recent folder path when navigating from the sidebar", async () => {
|
||||
const store = new Map<string, string>([["ao:add-project:recent", JSON.stringify(["~/projects/old"])]]);
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => store.set(k, v),
|
||||
removeItem: (k: string) => store.delete(k),
|
||||
clear: () => store.clear(),
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
key: (i: number) => [...store.keys()][i] ?? null,
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
it("renders a selectable, git-aware current-folder row", () => {
|
||||
const browser = {
|
||||
browsePath: "~",
|
||||
selectedBrowsePath: "~",
|
||||
browsePath: "~/workspace/app",
|
||||
selectedBrowsePath: "~/workspace/app/sub",
|
||||
setSelectedBrowsePath: vi.fn(),
|
||||
directoryEntries: [],
|
||||
currentDirectory: null,
|
||||
directoryEntries: [{ name: "sub", isDirectory: true, isGitRepo: false, hasLocalConfig: false }],
|
||||
currentDirectory: { isGitRepo: true, hasLocalConfig: false },
|
||||
roots: [],
|
||||
selectedRootPath: "",
|
||||
locationInput: "~",
|
||||
locationInput: "~/workspace/app",
|
||||
setLocationInput: vi.fn(),
|
||||
loading: false,
|
||||
error: null,
|
||||
parentPath: null,
|
||||
parentPath: "~/workspace",
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
browse: vi.fn(),
|
||||
|
|
@ -241,9 +195,10 @@ describe("DirectoryBrowser", () => {
|
|||
|
||||
render(<DirectoryBrowser browser={browser} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "~/projects/old" }));
|
||||
const currentRow = screen.getByRole("button", { name: "app, current folder, Git repository" });
|
||||
fireEvent.click(currentRow);
|
||||
|
||||
expect(browser.browse).toHaveBeenCalledWith("~/projects/old", { selectedPath: "~/projects/old" });
|
||||
expect(browser.setSelectedBrowsePath).toHaveBeenCalledWith("~/workspace/app");
|
||||
});
|
||||
|
||||
it("does not descend on modified Enter when a folder is selected", async () => {
|
||||
|
|
@ -325,4 +280,35 @@ describe("DirectoryBrowser", () => {
|
|||
|
||||
expect(browser.reset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles keyboard navigation when focus is on an ancestor container", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
entries: [
|
||||
{ name: "alpha", isDirectory: true, isGitRepo: false, hasLocalConfig: false },
|
||||
{ name: "beta", isDirectory: true, isGitRepo: false, hasLocalConfig: false },
|
||||
],
|
||||
roots: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<div data-testid="dialog">
|
||||
<Harness />
|
||||
</div>,
|
||||
);
|
||||
|
||||
const alpha = (await screen.findByText("alpha")).closest("button");
|
||||
expect(alpha).not.toBeNull();
|
||||
|
||||
// The dialog wrapper is an ancestor of the browser panes — the state focus
|
||||
// lands on when the modal opens. Keyboard nav must still respond.
|
||||
fireEvent.keyDown(screen.getByTestId("dialog"), { key: "ArrowDown" });
|
||||
|
||||
await waitFor(() => expect(alpha?.className).toContain("is-selected"));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue