177 lines
4.9 KiB
TypeScript
177 lines
4.9 KiB
TypeScript
import { type RefObject, useEffect, useMemo, useRef, useState } from "react";
|
|
import type { DirectorySession } from "../workspace/types";
|
|
|
|
const focusableSelector =
|
|
'button:not(:disabled), input:not(:disabled), [href], [tabindex]:not([tabindex="-1"])';
|
|
|
|
function sessionTitle(session: DirectorySession) {
|
|
return session.name ?? session.firstMessage ?? "Untitled session";
|
|
}
|
|
|
|
function messageCountLabel(count: number) {
|
|
return `${count} ${count === 1 ? "message" : "messages"}`;
|
|
}
|
|
|
|
function matchesQuery(session: DirectorySession, query: string) {
|
|
return [session.name, session.firstMessage, session.id].some((value) =>
|
|
value?.toLocaleLowerCase().includes(query),
|
|
);
|
|
}
|
|
|
|
function SessionRow({
|
|
session,
|
|
onOpen,
|
|
}: {
|
|
session: DirectorySession;
|
|
onOpen: (session: DirectorySession) => void;
|
|
}) {
|
|
const showPreview = Boolean(
|
|
session.name &&
|
|
session.firstMessage &&
|
|
session.firstMessage !== session.name,
|
|
);
|
|
return (
|
|
<li>
|
|
<button className="session-row" onClick={() => onOpen(session)}>
|
|
<span className="session-row-content">
|
|
<strong>{sessionTitle(session)}</strong>
|
|
{showPreview ? (
|
|
<span className="session-preview">{session.firstMessage}</span>
|
|
) : null}
|
|
<small className="session-metadata">
|
|
<span>{messageCountLabel(session.messageCount)}</span>
|
|
<time dateTime={session.modified}>
|
|
Modified {new Date(session.modified).toLocaleDateString()}
|
|
</time>
|
|
</small>
|
|
</span>
|
|
{session.runtimeId ? <span className="session-open">Open</span> : null}
|
|
</button>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function SessionResults({
|
|
sessions,
|
|
query,
|
|
onOpen,
|
|
}: {
|
|
sessions: DirectorySession[];
|
|
query: string;
|
|
onOpen: (session: DirectorySession) => void;
|
|
}) {
|
|
if (sessions.length === 0)
|
|
return <p className="muted">No saved sessions match “{query}”.</p>;
|
|
return (
|
|
<ul id="session-results" className="session-list">
|
|
{sessions.map((session) => (
|
|
<SessionRow key={session.path} session={session} onOpen={onOpen} />
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
function useDialogFocus(
|
|
dialogRef: RefObject<HTMLElement | null>,
|
|
searchRef: RefObject<HTMLInputElement | null>,
|
|
onClose: () => void,
|
|
) {
|
|
const openerRef = useRef<HTMLElement | null>(
|
|
document.activeElement as HTMLElement | null,
|
|
);
|
|
useEffect(() => {
|
|
const dialog = dialogRef.current;
|
|
const controls = () => [
|
|
...(dialog?.querySelectorAll<HTMLElement>(focusableSelector) ?? []),
|
|
];
|
|
searchRef.current?.focus();
|
|
const keydown = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
onClose();
|
|
return;
|
|
}
|
|
if (event.key !== "Tab") return;
|
|
const focusable = controls();
|
|
const first = focusable[0];
|
|
const last = focusable[focusable.length - 1];
|
|
if (!first || !last) return;
|
|
if (event.shiftKey && document.activeElement === first) {
|
|
event.preventDefault();
|
|
last.focus();
|
|
} else if (!event.shiftKey && document.activeElement === last) {
|
|
event.preventDefault();
|
|
first.focus();
|
|
}
|
|
};
|
|
window.addEventListener("keydown", keydown);
|
|
return () => {
|
|
window.removeEventListener("keydown", keydown);
|
|
openerRef.current?.focus();
|
|
};
|
|
}, [dialogRef, onClose, searchRef]);
|
|
}
|
|
|
|
export function SessionPicker({
|
|
sessions,
|
|
onOpen,
|
|
onClose,
|
|
}: {
|
|
sessions: DirectorySession[];
|
|
onOpen: (session: DirectorySession) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const [query, setQuery] = useState("");
|
|
const dialogRef = useRef<HTMLElement>(null);
|
|
const searchRef = useRef<HTMLInputElement>(null);
|
|
const normalizedQuery = query.trim().toLocaleLowerCase();
|
|
const filteredSessions = useMemo(
|
|
() => sessions.filter((session) => matchesQuery(session, normalizedQuery)),
|
|
[normalizedQuery, sessions],
|
|
);
|
|
useDialogFocus(dialogRef, searchRef, onClose);
|
|
const resultLabel = `${filteredSessions.length} saved session${filteredSessions.length === 1 ? "" : "s"}`;
|
|
return (
|
|
<div className="modal-backdrop">
|
|
<section
|
|
ref={dialogRef}
|
|
className="session-picker"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="session-picker-title"
|
|
>
|
|
<div className="picker-heading">
|
|
<h2 id="session-picker-title">Saved sessions</h2>
|
|
<button onClick={onClose}>Close</button>
|
|
</div>
|
|
<div className="session-search">
|
|
<label className="sr-only" htmlFor="session-search">
|
|
Search saved sessions
|
|
</label>
|
|
<input
|
|
ref={searchRef}
|
|
id="session-search"
|
|
type="search"
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="Search by name, message, or ID"
|
|
aria-controls="session-results"
|
|
/>
|
|
</div>
|
|
<p className="session-result-count" role="status" aria-live="polite">
|
|
{sessions.length ? resultLabel : "No saved sessions"}
|
|
</p>
|
|
{sessions.length === 0 ? (
|
|
<p className="muted">No saved sessions for this directory.</p>
|
|
) : (
|
|
<SessionResults
|
|
sessions={filteredSessions}
|
|
query={query}
|
|
onOpen={onOpen}
|
|
/>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|