Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions packages/app-web/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,13 @@ Text that is not UTF-8, or a binary file of a text type, gets the note.

| Column | Shows |
|--------|-------|
| Left | The sidebar, in three sections that fold: File Explorer, Source Explorer, and Record Explorer, which lists two collections: the one picked (`?list=`) and the collection it is listed in. |
| Left | The sidebar, in three sections that fold: File Explorer, Source Explorer, and Record Explorer, which lists two collections: the one picked (`?list=`) and the collection it is listed in. Collections takes at most 40% of Record Explorer and scrolls beyond that; Items has the rest and a filter box that narrows the listed items. |
| Centre | The graph, a card that folds: the resources opened in the workspace since its last reset and what they relate to, on a 3D canvas (`GraphView`). Clicking a node opens its resource. Below it, the open resource. Tabs: Overview (properties, then a card for each related resource, outbound then inbound), Debug, drawn as a wrench (the response; the events of the record, or of the acts that ran against the commit, each a line with chips for the records it names; and the commits those acts ran against). `?tab=` names the tab. The tabs and Reload sit at the right of the line with the resource's name. |

The button at the left of the top bar shows or hides the sidebar:
The top bar starts with the LabKit mark and name, which are one button that opens the list of
workspaces. The button after it shows or hides the sidebar. The theme button at the right has no
border or background. The browser's own Back and Forward buttons step through the pages opened. The
sidebar toggle works like this:

- At full width, the sidebar is shown or hidden in place, and the browser keeps the choice for the
next visit.
Expand Down
56 changes: 46 additions & 10 deletions packages/app-web/e2e/app.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,53 @@ test.describe("getting around", () => {
await expect(item(page, "Q_1")).toHaveClass(/active/);
});

test("the home button leads back to the workspaces", async ({ page }) => {
test("the LabKit name in the top bar leads back to the workspaces", async ({ page }) => {
await page.goto("/app/workspace/alpha/Q_1");
await page.getByRole("button", { name: "Entry point" }).click();
await page.getByRole("button", { name: "LabKit" }).click();
await expect(page).toHaveURL(/\/app\/$/);
await expect(page.getByText("Pick a type on the left")).toBeVisible();
});

test("the top bar has the LabKit name first, then the sidebar toggle, and no history or filter controls", async ({
page,
}) => {
await page.goto("/app/workspace/alpha/Q_1");
const bar = page.locator(".topbar");
const brand = bar.getByRole("button", { name: "LabKit" });
const toggle = bar.getByRole("button", { name: "Show or hide the sidebar" });
const x = async (locator: import("@playwright/test").Locator) =>
(await locator.boundingBox())?.x ?? -1;
expect(await x(brand)).toBeLessThan(await x(toggle));
await expect(bar.getByRole("button", { name: "Back" })).toHaveCount(0);
await expect(bar.getByRole("button", { name: "Forward" })).toHaveCount(0);
await expect(bar.getByRole("button", { name: "Entry point" })).toHaveCount(0);
await expect(bar.getByRole("textbox")).toHaveCount(0);
await expect(bar).not.toContainText("Trace Console");
});

test("the Items filter narrows the listed items", async ({ page }) => {
await page.goto(`/app/workspace/alpha/Q_1?${list("/workspace/alpha/question")}`);
await expect(item(page, "Q_2")).toBeVisible();
await page.getByRole("textbox", { name: "Filter the items" }).fill("Q_2");
await expect(item(page, "Q_2")).toBeVisible();
await expect(item(page, "Q_1")).toHaveCount(0);
});

test("in a short window, Collections takes at most 40% of Record Explorer and Items keeps the rest", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 520 });
await page.goto(`/app/workspace/alpha/Q_1?${list("/workspace/alpha/question")}`);
const panes = page.locator("aside.sidebar .explorer-body .sidebar-section");
await expect(panes).toHaveCount(2);
const body = (await page.locator("aside.sidebar .explorer-body").boundingBox())?.height ?? 0;
const collections = (await panes.nth(0).boundingBox())?.height ?? 0;
const items = (await panes.nth(1).boundingBox())?.height ?? 0;
expect(collections).toBeLessThanOrEqual(0.4 * body + 2);
expect(items).toBeGreaterThanOrEqual(120);
expect(items).toBeGreaterThan(collections);
});

test("a deep link renders directly, and survives a reload", async ({ page }) => {
await page.goto("/app/workspace/alpha/LOE_1");
await expect(header(page)).toHaveText("LOE_1");
Expand Down Expand Up @@ -637,28 +677,24 @@ test.describe("an open resource", () => {
expect(await page.evaluate(() => (window as unknown as { kept?: boolean }).kept)).toBe(true);
});

test("back and forward, the browser's or the page's, step through what was opened", async ({
page,
}) => {
test("the browser's back and forward step through what was opened", async ({ page }) => {
await page.goto("/app/workspace/alpha/Q_1");
await page.locator(".card-head", { hasText: "LOE_1" }).click();
await expect(header(page)).toHaveText("LOE_1");

await page.goBack();
await expect(header(page)).toHaveText("Q_1");
await page.getByRole("button", { name: "Forward" }).click();
await page.goForward();
await expect(header(page)).toHaveText("LOE_1");
await expect(page.getByRole("button", { name: "Forward" })).toBeDisabled();
await page.getByRole("button", { name: "Back" }).click();
await expect(header(page)).toHaveText("Q_1");
});

test("opening the resource that is open adds no history entry", async ({ page }) => {
await page.goto(`/app/workspace/alpha/Q_1?${list("/workspace/alpha/question")}`);
await expect(header(page)).toHaveText("Q_1");
const entries = await page.evaluate(() => history.length);
await item(page, "Q_1").click();
await expect(header(page)).toHaveText("Q_1");
await expect(page.getByRole("button", { name: "Back" })).toBeDisabled();
expect(await page.evaluate(() => history.length)).toBe(entries);
});

test("while its response is on its way, the page keeps its layout with placeholder rows", async ({
Expand Down
112 changes: 21 additions & 91 deletions packages/app-web/src/ui/trace/TraceConsole.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,8 @@ import {
FlagCheckeredIcon,
FolderIcon,
GitCommitIcon,
HouseIcon,
type Icon,
IconContext,
MagnifyingGlassIcon,
PauseIcon,
PlayIcon,
SealCheckIcon,
Expand All @@ -36,7 +34,7 @@ import {
} from "@phosphor-icons/react";
import markUrl from "@labkit/design/mark.svg";
import "@labkit/design/tokens.css";
import { Link, useNavigate, useRouter, useRouterState } from "@tanstack/react-router";
import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
import {
type CSSProperties,
type ReactNode,
Expand Down Expand Up @@ -107,34 +105,6 @@ function Spinner({ children }: { children: ReactNode }) {
);
}

/**
* The history entries this page has seen, by their position in the browser's history, which enable
* or disable the Back and Forward buttons. The browser does not let a page read its history, so
* the page keeps the path it showed at each position: an entry is replaced when the page arrives at
* its position again by a new navigation, and the entries after it are dropped, as the browser
* drops them. The position and the path are read from one location, so a render between two
* navigations never pairs one's position with the other's path.
*/
function useTrail(): { trail: string[]; at: number } {
const here = useRouterState({
select: (s) =>
`${(s.location.state as { __TSR_index?: number }).__TSR_index ?? 0} ${s.location.pathname}`,
});
const space = here.indexOf(" ");
const at = Number(here.slice(0, space));
const path = here.slice(space + 1);
const [trail, setTrail] = useState<string[]>([]);
useEffect(() => {
setTrail((t) => {
if (t[at] === path) return t;
const next = t.slice(0, at);
next[at] = path;
return next;
});
}, [at, path]);
return { trail, at };
}

/**
* The resources the reader has opened since the graph was last reset, in the order they were first
* opened. The graph draws these and what they relate to. `generation` counts the resets, so the
Expand Down Expand Up @@ -186,9 +156,7 @@ export interface TraceConsoleProps {

export function TraceConsole({ path, list, tab }: TraceConsoleProps) {
const navigate = useNavigate();
const router = useRouter();
const [theme, setTheme] = useSavedTheme();
const [term, setTerm] = useState("");
const [drawer, setDrawer] = useState<"items" | undefined>();
// At full width the sidebar is shown or hidden, as this browser last left it; in a narrow
// window it is a drawer over the page, closed until opened.
Expand Down Expand Up @@ -244,17 +212,17 @@ export function TraceConsole({ path, list, tab }: TraceConsoleProps) {
void navigate({ to: "/$", params: { _splat: "" }, search: {} });
};

const { trail, at } = useTrail();
const canBack = trail.slice(0, at).some((e) => e !== undefined);
const canForward = trail.slice(at + 1).some((e) => e !== undefined);

const listKey = openIsCollection ? path : (list ?? ENTRY);
const prose = proseFor(held, path, open);

return (
<IconContext.Provider value={ICONS}>
<div className="lk-root trace" data-theme={theme === "system" ? undefined : theme}>
<header className="topbar">
<button type="button" className="brand" title="Back to the workspaces" onClick={home}>
<img className="brand-mark" src={markUrl} alt="" />
<span className="brand-name">LabKit</span>
</button>
<button
type="button"
className="hamburger"
Expand All @@ -268,53 +236,8 @@ export function TraceConsole({ path, list, tab }: TraceConsoleProps) {
>
<SidebarSimpleIcon aria-hidden="true" />
</button>
<div className="brand">
<img className="brand-mark" src={markUrl} alt="" />
<span className="brand-name">Trace Console</span>
<span className="brand-sub">live · {window.location.host}</span>
</div>
<div className="nav-controls">
<button
type="button"
className="nav-btn"
aria-label="Entry point"
title="Back to the workspaces"
onClick={home}
>
<HouseIcon aria-hidden="true" />
</button>
<button
type="button"
className="nav-btn"
aria-label="Back"
disabled={!canBack}
onClick={() => router.history.back()}
>
<ArrowLeftIcon aria-hidden="true" />
</button>
<button
type="button"
className="nav-btn"
aria-label="Forward"
disabled={!canForward}
onClick={() => router.history.forward()}
>
<ArrowRightIcon aria-hidden="true" />
</button>
</div>
<span className="topbar-gap" />
{import.meta.env.DEV ? <DevLinks /> : null}
<div className="search-wrap">
<MagnifyingGlassIcon className="icon" aria-hidden="true" />
<input
type="text"
placeholder="Filter…"
autoComplete="off"
aria-label="Filter the lists"
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
</div>
<ThemeToggle theme={theme} onChange={setTheme} className="theme-btn" />
</header>

Expand All @@ -338,7 +261,6 @@ export function TraceConsole({ path, list, tab }: TraceConsoleProps) {
root={listKey === ENTRY}
openKey={path}
held={held}
term={term}
onOpen={open}
onList={pickList}
/>
Expand Down Expand Up @@ -480,7 +402,6 @@ function Lists({
root,
openKey,
held,
term,
onOpen,
onList,
}: {
Expand All @@ -491,10 +412,11 @@ function Lists({
root: boolean;
openKey: string;
held: HeldIndex;
term: string;
onOpen: (key: string) => void;
onList: (key: string) => void;
}) {
// Narrows the Items pane to the items whose text holds it.
const [term, setTerm] = useState("");
const listEntry = useDocument(requestPath(listKey));
// Until the picked collection's first page arrives, the collection it was picked from stays the
// upper one, so the upper pane does not show the picked collection and then swap back.
Expand Down Expand Up @@ -652,16 +574,13 @@ function Lists({
<div className="faint mono pane-path">{upperKey}</div>
<div className="type-list" ref={upperList}>
{lowerRow === undefined ? null : (
<ActiveBox
list={upperList}
activeKey={lowerRow}
rows={`${upper.items.length} ${term}`}
/>
<ActiveBox list={upperList} activeKey={lowerRow} rows={`${upper.items.length}`} />
)}
<Pane
pages={upper}
empty={`Loading ${ENTRY}…`}
{...{ held, term, openKey, onOpen, onList }}
{...{ held, openKey, onOpen, onList }}
term=""
lowerKey={lowerRow}
pageable={false}
hidden={IN_OTHER_EXPLORERS}
Expand All @@ -675,6 +594,17 @@ function Lists({
{lower.items.length > 0 ? `${lower.items.length}${lower.next ? "+" : ""}` : ""}
</span>
</div>
{lowerKey === undefined ? null : (
<input
type="text"
className="items-filter"
placeholder="Filter items…"
autoComplete="off"
aria-label="Filter the items"
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
)}
<div className="faint mono pane-path">{lowerKey ?? ""}</div>
<div className="res-list" ref={lowerList}>
{lowerKey === undefined ? (
Expand Down
Loading
Loading