Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/notebook-navigation-explorations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Notebook view navigation prototypes: a ⌘K command palette, back/forward jump history, Vimium-style hint-jump (`f`), peek previews on references, and a zoom/pan camera with a minimap for the net diagram.
8 changes: 8 additions & 0 deletions libs/@hashintel/petrinaut/docs/notebook-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ Two badges can appear after a cell's name:
- **`initial`** (blue) β€” this place must hold tokens in the initial state. Nothing in the net can produce its first token: either nothing feeds it at all, or it belongs to a pool that only circulates what it starts with (a resource pool such as a set of machines). If a scenario leaves every place of such a group empty, the transitions that need it can never fire.
- **`↻N`** (tinted) β€” this node is part of cycle _N_. Hover the badge to light up the whole cycle in the list and the graph.

## Getting around

- **Command palette** β€” press **⌘K** (Ctrl+K) to open a palette that fuzzy-matches every cell name and the view's commands (cell order, kind filters, expand/collapse all, graph focus). **Enter** jumps to a cell or runs a command; **Escape** closes.
- **Jump history** β€” following a reference (an arc's place, an explorer row, a palette result) is a jump, and jumps are remembered: the **β€Ή β€Ί** buttons in the toolbar, or **βŒ₯←** / **βŒ₯β†’**, go back and forward through them, like a browser.
- **Hint jump** β€” press **f** while in the list to label every visible row with a letter chip; type a chip's letters to jump straight to that row. **Escape** (or any other key) leaves the mode.
- **Peek** β€” hover a reference (an arc's place name, an explorer row, a graph node) or move keyboard focus onto it, and a card previews the target cell β€” its kind, summary, and dependent counts β€” without navigating.

## Toolbar

- **Search** β€” type in the search box (or press **/**) to fuzzy-match cell names; matching characters are highlighted and other cells fade. **↑** / **↓** step through matches, **Enter** jumps to the first one, **Escape** clears.
Expand All @@ -35,6 +42,7 @@ The right-hand panel draws the whole net as a top-to-bottom flow graph of places

- Selecting a place or transition highlights it in the graph: dependencies in blue, dependents in orange, a node connected in both directions in purple. Clicking a node in the graph selects its cell.
- The **target button** re-organizes the graph around the selected node: what it depends on stacks above it, what depends on it below, everything else settles underneath. The re-layout is animated. Toggle it off to return to the default flow layout.
- The diagram is a camera: **scroll** to zoom toward the cursor, **drag** the background to pan, and **double-click** the background to fit the whole net again. Zoomed far out, node labels give way to shapes alone. The minimap in the corner shows where you are β€” press or drag it to move the camera.
- Below the graph, the selected cell's dependencies and dependents are listed in full β€” including types, equations, and parameters, which the diagram itself leaves out.
- Drag the panel's left edge to resize it, and the divider above the lists to trade space between the graph and the lists.

Expand Down
255 changes: 255 additions & 0 deletions libs/@hashintel/petrinaut/src/ui/views/Notebook/command-palette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
import { useState } from "react";

import { css, cva } from "@hashintel/ds-helpers/css";

import { CELL_KIND_ICONS, CELL_KIND_LABELS } from "./cell-kinds";
import { HighlightedName } from "./notebook-cell";
import { cellName, fuzzyMatchName } from "./notebook-model";

import type { NotebookCell as NotebookCellModel } from "./notebook-model";

/** A view command the palette can run, beside the jumpable cells. */
export type PaletteAction = {
id: string;
label: string;
/** Right-aligned state or shortcut hint. */
hint?: string;
run: () => void;
};

const backdropStyle = css({
position: "fixed",
inset: "[0]",
zIndex: "modal",
backgroundColor: "[rgba(15, 18, 24, 0.25)]",
});

const panelStyle = css({
position: "fixed",
top: "[14%]",
left: "[50%]",
transform: "translateX(-50%)",
width: "[min(600px, calc(100vw - 48px))]",
zIndex: "modal",
display: "flex",
flexDirection: "column",
backgroundColor: "neutral.s00",
borderRadius: "lg",
borderWidth: "[1px]",
borderStyle: "solid",
borderColor: "neutral.s40",
boxShadow:
"[0px 12px 32px -8px rgba(0,0,0,0.25), 0px 2px 8px rgba(0,0,0,0.1)]",
overflow: "hidden",
});

const inputStyle = css({
width: "full",
paddingX: "3",
paddingY: "2.5",
fontSize: "sm",
color: "neutral.s115",
backgroundColor: "[transparent]",
borderWidth: "[0]",
borderBottomWidth: "[1px]",
borderBottomStyle: "solid",
borderBottomColor: "neutral.s30",
outline: "[none]",
});

const listStyle = css({
maxHeight: "[340px]",
overflowY: "auto",
paddingY: "1",
});

const entryStyle = cva({
base: {
display: "flex",
alignItems: "center",
gap: "2",
width: "full",
paddingX: "3",
paddingY: "1.5",
fontSize: "sm",
textAlign: "left",
backgroundColor: "[transparent]",
borderWidth: "[0]",
cursor: "pointer",
color: "neutral.s115",
},
variants: {
isSelected: {
true: { backgroundColor: "blue.s20" },
false: {},
},
},
});

const entryKindStyle = css({
flexShrink: 0,
fontSize: "xs",
fontFamily: "mono",
color: "purple.s100",
width: "[76px]",
});

const entryHintStyle = css({
marginLeft: "auto",
flexShrink: 0,
fontSize: "xs",
color: "neutral.fg.subtle",
});

const emptyStyle = css({
paddingX: "3",
paddingY: "3",
fontSize: "sm",
color: "neutral.fg.subtle",
});

type PaletteEntry =
| { kind: "action"; action: PaletteAction; indices: number[] }
| { kind: "cell"; cell: NotebookCellModel; indices: number[] };

const MAX_LISTED_CELLS = 40;

export interface CommandPaletteProps {
/** Every cell, unfiltered β€” jumping reveals a hidden kind. */
cells: NotebookCellModel[];
actions: PaletteAction[];
onJumpToCell: (cellId: string) => void;
onClose: () => void;
}

/**
* The ⌘K palette: one keyboard-first surface that fuzzy-matches the view's
* commands and every cell name. Actions list first, cells after; Enter runs
* the selection, Escape closes. Modelled on the Linear/VS Code palettes.
*/
export const CommandPalette: React.FC<CommandPaletteProps> = ({
cells,
actions,
onJumpToCell,
onClose,
}) => {
const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);

const trimmed = query.trim();
const entries: PaletteEntry[] = [
...actions.flatMap((action): PaletteEntry[] => {
const indices =
trimmed === "" ? [] : fuzzyMatchName(trimmed, action.label);
return indices === null ? [] : [{ kind: "action", action, indices }];
}),
...cells.flatMap((cell): PaletteEntry[] => {
const indices =
trimmed === "" ? [] : fuzzyMatchName(trimmed, cellName(cell));
return indices === null ? [] : [{ kind: "cell", cell, indices }];
}),
].slice(0, actions.length + MAX_LISTED_CELLS);

const clampedIndex = Math.min(selectedIndex, entries.length - 1);

const runEntry = (entry: PaletteEntry) => {
if (entry.kind === "action") {
entry.action.run();
} else {
onJumpToCell(entry.cell.id);
}
onClose();
};

return (
<>
<div className={backdropStyle} onClick={onClose} aria-hidden />
<div className={panelStyle} role="dialog" aria-label="Command palette">
<input
// The palette exists for exactly this: focus lands in it on open.
ref={(element) => element?.focus()}
className={inputStyle}
placeholder="Jump to a cell or run a command…"
value={query}
onChange={(event) => {
setQuery(event.target.value);
setSelectedIndex(0);
}}
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setSelectedIndex(Math.min(clampedIndex + 1, entries.length - 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setSelectedIndex(Math.max(clampedIndex - 1, 0));
} else if (event.key === "Enter") {
event.preventDefault();
const entry = entries[clampedIndex];
if (entry) {
runEntry(entry);
}
} else if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onClose();
}
}}
/>
<div className={listStyle} role="listbox" aria-label="Palette results">
{entries.length === 0 ? (
<div className={emptyStyle}>Nothing matches "{trimmed}".</div>
) : (
entries.map((entry, at) => {
const isSelected = at === clampedIndex;
if (entry.kind === "action") {
return (
<button
key={`action-${entry.action.id}`}
type="button"
role="option"
aria-selected={isSelected}
className={entryStyle({ isSelected })}
onMouseEnter={() => setSelectedIndex(at)}
onClick={() => runEntry(entry)}
>
<span className={entryKindStyle}>command</span>
<HighlightedName
name={entry.action.label}
matchIndices={entry.indices}
/>
{entry.action.hint !== undefined && (
<span className={entryHintStyle}>
{entry.action.hint}
</span>
)}
</button>
);
}
const KindIcon = CELL_KIND_ICONS[entry.cell.kind];
return (
<button
key={`cell-${entry.cell.id}`}
type="button"
role="option"
aria-selected={isSelected}
className={entryStyle({ isSelected })}
onMouseEnter={() => setSelectedIndex(at)}
onClick={() => runEntry(entry)}
>
<span className={entryKindStyle}>
{CELL_KIND_LABELS[entry.cell.kind]}
</span>
<KindIcon size={11} />
<HighlightedName
name={cellName(entry.cell)}
matchIndices={entry.indices}
/>
</button>
);
})
)}
</div>
</div>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ const NodeRow: React.FC<{
ref={grid.register(index, 0)}
tabIndex={grid.tabIndexFor(index, 0)}
className={nodeRowStyle}
data-peek-cell={node.id}
onClick={() => onNavigate(node)}
onKeyDown={grid.onKeyDown(index, 0)}
onFocus={() => grid.onFocusCell(index, 0)}
Expand Down
45 changes: 45 additions & 0 deletions libs/@hashintel/petrinaut/src/ui/views/Notebook/hint-jump.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";

import { hintLabels, matchHint } from "./hint-jump";

describe("hintLabels", () => {
it("uses single letters while they suffice", () => {
expect(hintLabels(3)).toEqual(["a", "s", "d"]);
expect(hintLabels(9)).toHaveLength(9);
expect(new Set(hintLabels(9)).size).toBe(9);
});

it("switches every label to pairs when singles run out", () => {
const labels = hintLabels(12);
expect(labels).toHaveLength(12);
expect(labels.every((label) => label.length === 2)).toBe(true);
expect(new Set(labels).size).toBe(12);
});

it("never makes one label a prefix of another", () => {
for (const count of [5, 9, 10, 40, 81]) {
const labels = hintLabels(count);
for (const label of labels) {
expect(labels.filter((other) => other.startsWith(label))).toHaveLength(
1,
);
}
}
});
});

describe("matchHint", () => {
it("matches a complete single-letter label", () => {
expect(matchHint("s", 3)).toEqual({ kind: "match", index: 1 });
});

it("stays pending on a valid pair prefix", () => {
expect(matchHint("a", 12)).toEqual({ kind: "pending" });
expect(matchHint("as", 12)).toEqual({ kind: "match", index: 1 });
});

it("reports a dead end for characters no label starts with", () => {
expect(matchHint("z", 12)).toEqual({ kind: "none" });
expect(matchHint("az", 12)).toEqual({ kind: "none" });
});
});
45 changes: 45 additions & 0 deletions libs/@hashintel/petrinaut/src/ui/views/Notebook/hint-jump.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Hint-jump labelling: Vimium/avy-style two-keystroke navigation. Pressing
* the trigger key labels every visible row with a short letter sequence;
* typing a label jumps to its row.
*/

/** Home-row-first alphabet, so the common labels stay under the fingers. */
const HINT_LETTERS = "asdfghjkl".split("");

/**
* A label per target, all the same length so no label is a prefix of
* another: single letters while they suffice, letter pairs beyond that.
*/
export function hintLabels(count: number): string[] {
if (count <= HINT_LETTERS.length) {
return HINT_LETTERS.slice(0, count);
}
const labels: string[] = [];
for (const first of HINT_LETTERS) {
for (const second of HINT_LETTERS) {
labels.push(`${first}${second}`);
if (labels.length === count) {
return labels;
}
}
}
return labels;
}

export type HintMatch =
| { kind: "pending" }
| { kind: "match"; index: number }
| { kind: "none" };

/** How typed characters resolve against the labels of `hintLabels(count)`. */
export function matchHint(typed: string, count: number): HintMatch {
const labels = hintLabels(count);
const index = labels.indexOf(typed);
if (index !== -1) {
return { kind: "match", index };
}
return labels.some((label) => label.startsWith(typed))
? { kind: "pending" }
: { kind: "none" };
}
Loading
Loading