From e375beb18f0b595be207b94f3b4329c4e73b0425 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 15 May 2026 12:28:05 -0400 Subject: [PATCH 1/2] feat: click-to-jump navigation for relationship editors Add Cmd/Ctrl+click "go to definition" from the text and grid relationship editors to the schema editor, plus a modifier-held hover affordance (underline + pointer cursor) indicating which tokens/cells are jumpable. - schema-jump store coordinates cross-document navigation to the schema tab - jump-targets helpers resolve types/relations/caveats to schema positions - text editor uses a native Monaco DefinitionProvider + editor opener - grid editor uses onCellClicked plus a per-cell cursor and canvas underline - useModifierKeyHeld hook tracks the Ctrl/Cmd modifier state --- src/components/EditorDisplay.tsx | 36 +++ .../editor-groups/schema-jump.test.ts | 33 +++ src/components/editor-groups/schema-jump.ts | 30 +++ .../relationshipeditor/RelationshipEditor.tsx | 56 ++++- .../relationshipeditor/customcells.ts | 3 + .../relationshipeditor/fieldcell.tsx | 29 +++ .../relationshipeditor/jump-targets.test.ts | 188 ++++++++++++++++ .../relationshipeditor/jump-targets.ts | 209 ++++++++++++++++++ .../relationshipeditor/tuplelang.ts | 92 +++++++- src/hooks/use-modifier-key-held.ts | 36 +++ .../browser/use-modifier-key-held.test.tsx | 38 ++++ vite.config.ts | 3 + vitest.config.ts | 5 + 13 files changed, 756 insertions(+), 2 deletions(-) create mode 100644 src/components/editor-groups/schema-jump.test.ts create mode 100644 src/components/editor-groups/schema-jump.ts create mode 100644 src/components/relationshipeditor/jump-targets.test.ts create mode 100644 src/components/relationshipeditor/jump-targets.ts create mode 100644 src/hooks/use-modifier-key-held.ts create mode 100644 src/tests/browser/use-modifier-key-held.test.tsx diff --git a/src/components/EditorDisplay.tsx b/src/components/EditorDisplay.tsx index 2473fe27..f4a3298b 100644 --- a/src/components/EditorDisplay.tsx +++ b/src/components/EditorDisplay.tsx @@ -28,6 +28,7 @@ import { } from "../spicedb-common/protodefs/developer/v1/developer_pb"; import { useDrawerStore } from "./drawer/state"; +import { useSchemaJumpStore } from "./editor-groups/schema-jump"; import { ERROR_SOURCE_TO_ITEM } from "./panels/errordisplays"; import registerTupleLanguage, { TUPLE_LANGUAGE_NAME } from "./relationshipeditor/tuplelang"; @@ -91,6 +92,7 @@ export function EditorDisplay(props: EditorDisplayProps) { }, [props.services.liveCheckService]); const location = useLocation(); + const pendingSchemaReveal = useSchemaJumpStore((s) => s.pendingReveal); const datastore = props.datastore; const currentItem = props.currentItem; @@ -455,6 +457,7 @@ export function EditorDisplay(props: EditorDisplayProps) { updateMarkers(); updatePosition(); + revealSchemaJump(); } }; @@ -559,6 +562,39 @@ export function EditorDisplay(props: EditorDisplayProps) { } }; + // Reveal a location requested via the schema-jump store. Called both from the + // store subscription effect (schema editor already mounted) and from + // handleEditorMounted (schema tab was just activated, editor mounts fresh). + const revealSchemaJump = () => { + if (currentItem?.kind !== DataStoreItemKind.SCHEMA) { + return; + } + const pending = useSchemaJumpStore.getState().pendingReveal; + if (!pending) { + return; + } + const editors = editorRefs.current; + if (currentItem.id === undefined || !(currentItem.id in editors)) { + return; + } + const editor = editors[currentItem.id]; + editor.revealRangeInCenter({ + startLineNumber: pending.line, + startColumn: pending.column, + endLineNumber: pending.line, + endColumn: pending.column, + }); + editor.setPosition({ lineNumber: pending.line, column: pending.column }); + editor.focus(); + useSchemaJumpStore.getState().consumeReveal(); + }; + + useEffect(() => { + revealSchemaJump(); + // NOTE: only re-run when a new reveal is requested. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pendingSchemaReveal]); + useEffect(() => { updatePosition(); diff --git a/src/components/editor-groups/schema-jump.test.ts b/src/components/editor-groups/schema-jump.test.ts new file mode 100644 index 00000000..a1d6b1f3 --- /dev/null +++ b/src/components/editor-groups/schema-jump.test.ts @@ -0,0 +1,33 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it } from "vitest"; + +import { useSchemaJumpStore } from "./schema-jump"; +import { useEditorStore } from "./state"; + +describe("schema jump store", () => { + beforeEach(() => { + useEditorStore.getState().reset(); + localStorage.removeItem("playground-editor-state"); + useSchemaJumpStore.getState().consumeReveal(); + }); + + it("starts with no pending reveal", () => { + expect(useSchemaJumpStore.getState().pendingReveal).toBeUndefined(); + }); + + it("jumpToSchema records the location and activates the schema document", () => { + // Move schema out of the active slot first so we can prove it gets activated. + useEditorStore.getState().setActiveTab("g1", "relationships"); + useSchemaJumpStore.getState().jumpToSchema(7, 3); + + expect(useSchemaJumpStore.getState().pendingReveal).toEqual({ line: 7, column: 3 }); + const layout = useEditorStore.getState().layout; + expect(layout.kind === "single" && layout.group.activeTab).toBe("schema"); + }); + + it("consumeReveal clears the pending reveal", () => { + useSchemaJumpStore.getState().jumpToSchema(1, 1); + useSchemaJumpStore.getState().consumeReveal(); + expect(useSchemaJumpStore.getState().pendingReveal).toBeUndefined(); + }); +}); diff --git a/src/components/editor-groups/schema-jump.ts b/src/components/editor-groups/schema-jump.ts new file mode 100644 index 00000000..97c989ea --- /dev/null +++ b/src/components/editor-groups/schema-jump.ts @@ -0,0 +1,30 @@ +import { create } from "zustand"; + +import { useEditorStore } from "./state"; + +/** A 1-indexed line/column position in the schema editor (matches Monaco + parser ranges). */ +export type SchemaRevealLocation = { line: number; column: number }; + +type SchemaJumpState = { + /** A location the schema editor should reveal, or undefined if none is pending. */ + pendingReveal: SchemaRevealLocation | undefined; + /** Activate the schema document and request a reveal at the given position. */ + jumpToSchema: (line: number, column: number) => void; + /** Clear the pending reveal once it has been applied. */ + consumeReveal: () => void; +}; + +/** + * Module-level store coordinating "jump to schema" navigation from the + * relationship editors. Kept separate from the editor-groups store so the + * relationship editors can trigger jumps without threading callbacks through + * the component tree. + */ +export const useSchemaJumpStore = create((set) => ({ + pendingReveal: undefined, + jumpToSchema: (line, column) => { + useEditorStore.getState().showDocument("schema"); + set({ pendingReveal: { line, column } }); + }, + consumeReveal: () => set({ pendingReveal: undefined }), +})); diff --git a/src/components/relationshipeditor/RelationshipEditor.tsx b/src/components/relationshipeditor/RelationshipEditor.tsx index 68167f87..f9b6c187 100644 --- a/src/components/relationshipeditor/RelationshipEditor.tsx +++ b/src/components/relationshipeditor/RelationshipEditor.tsx @@ -8,6 +8,8 @@ import DataEditor, { GridMouseEventArgs, GridSelection, Rectangle, + type CellClickedEventArgs, + type Item, type Theme, type Highlight, } from "@glideapps/glide-data-grid"; @@ -19,11 +21,13 @@ import { useCookies } from "react-cookie"; import { toast } from "sonner"; import { useDeepCompareEffect, useDeepCompareMemo } from "use-deep-compare"; +import { useSchemaJumpStore } from "@/components/editor-groups/schema-jump"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useResolvedTheme } from "@/hooks/use-resolved-theme"; +import { useModifierKeyHeld } from "@/hooks/use-modifier-key-held"; import { ParseRelationshipError, parseRelationshipsWithComments, @@ -41,6 +45,7 @@ import { MIN_COLUMN_WIDTH, validate, } from "./columns"; +import { resolveGridCellTarget } from "./jump-targets"; import { COMMENT_CELL_KIND, copyDataForCommentCell } from "./commentcell"; import { RelEditorCustomCell, useCustomCells } from "./customcells"; import { @@ -333,6 +338,7 @@ export function RelationshipEditor({ }; const resolvedTheme = useResolvedTheme(); + const modifierHeld = useModifierKeyHeld(); const dataEditorTheme: Partial = useMemo(() => { const isDark = resolvedTheme === "dark"; @@ -440,6 +446,17 @@ export function RelationshipEditor({ }; } + // While a modifier key is held, jumpable cells that resolve to a schema + // location get a pointer cursor. glide applies a cell's `cursor` only + // while the pointer is over it, so this is naturally hovered-cell-only. + const jumpCursor = + modifierHeld && + resolver !== undefined && + resolveGridCellTarget(resolver, COLUMNS[col].dataKind, data[row].columnData, col) !== + undefined + ? "pointer" + : undefined; + switch (COLUMNS[col].dataKind) { case DataKind.RESOURCE_TYPE: case DataKind.SUBJECT_TYPE: @@ -453,6 +470,7 @@ export function RelationshipEditor({ }, allowOverlay: true, copyData: data[row].columnData[col], + cursor: jumpCursor, }; case DataKind.RESOURCE_ID: @@ -481,6 +499,7 @@ export function RelationshipEditor({ }, allowOverlay: true, copyData: data[row].columnData[col], + cursor: jumpCursor, }; case DataKind.CAVEAT_NAME: @@ -494,6 +513,7 @@ export function RelationshipEditor({ }, allowOverlay: true, copyData: data[row].columnData[col], + cursor: jumpCursor, }; case DataKind.CAVEAT_CONTEXT: @@ -531,7 +551,7 @@ export function RelationshipEditor({ }; } }, - [data], + [data, resolver, modifierHeld], ); const getCellsForSelection = useCallback( @@ -857,6 +877,39 @@ export function RelationshipEditor({ resolver, similarHighlighting, columnsWithWidths, + modifierHeld, + ); + + // Cmd/Ctrl+click a type / relation / caveat-name cell jumps to the schema. + // Plain clicks fall through to glide's normal selection/edit behavior. + const handleCellClicked = useCallback( + (cell: Item, event: CellClickedEventArgs) => { + if (!event.ctrlKey && !event.metaKey) { + return; + } + if (!resolver) { + return; + } + const [col, row] = cell; + if (row >= data.length || col >= COLUMNS.length) { + return; + } + const rowData = data[row]; + if ("comment" in rowData.datum) { + return; + } + const target = resolveGridCellTarget( + resolver, + COLUMNS[col].dataKind, + rowData.columnData, + col, + ); + if (!target) { + return; + } + useSchemaJumpStore.getState().jumpToSchema(target.line, target.column); + }, + [resolver, data], ); return ( @@ -931,6 +984,7 @@ export function RelationshipEditor({ onDelete={isReadOnly ? undefined : handleDelete} onRowMoved={isReadOnly ? undefined : handleRowMoved} onItemHovered={handleItemHovered} + onCellClicked={handleCellClicked} isDraggable={false} trailingRowOptions={{ tint: !isReadOnly }} rowMarkers={isReadOnly ? "number" : "both"} diff --git a/src/components/relationshipeditor/customcells.ts b/src/components/relationshipeditor/customcells.ts index 2d5e3e17..49d6f86b 100644 --- a/src/components/relationshipeditor/customcells.ts +++ b/src/components/relationshipeditor/customcells.ts @@ -52,6 +52,7 @@ export function useCustomCells( resolver: Resolver | undefined, similarHighlighting: boolean, columnsWithWidths: Column[], + modifierHeld: boolean, ): { // The type we're providing here is about as narrow as we can // get without fully figuring out a discriminated union here. @@ -218,6 +219,7 @@ export function useCustomCells( }, similarHighlighting: similarHighlighting, columnsWithWidths: columnsWithWidths, + modifierHeld: modifierHeld, }); // NOTE: we always set the current value on the props to ensure that the renderers @@ -237,6 +239,7 @@ export function useCustomCells( }, similarHighlighting: similarHighlighting, columnsWithWidths: columnsWithWidths, + modifierHeld: modifierHeld, }; // renderers defines the custom cell types supported by the RelationshipEditor. diff --git a/src/components/relationshipeditor/fieldcell.tsx b/src/components/relationshipeditor/fieldcell.tsx index 827923ea..105acf75 100644 --- a/src/components/relationshipeditor/fieldcell.tsx +++ b/src/components/relationshipeditor/fieldcell.tsx @@ -20,6 +20,7 @@ import { RelationshipsService } from "@/spicedb-common/services/relationshipsser import { COLUMNS, Column, DataKind, DataTitle, RelationshipSection } from "./columns"; import { AnnotatedData } from "./data"; +import { resolveGridCellTarget } from "./jump-targets"; export const TYPE_CELL_KIND = "type-field-cell"; export const OBJECTID_CELL_KIND = "objectid-field-cell"; @@ -104,6 +105,7 @@ export interface FieldCellRendererProps { }; similarHighlighting: boolean; columnsWithWidths: Column[]; + modifierHeld: boolean; } type GetAutocompleteOptions = ( @@ -150,6 +152,22 @@ function fieldCellRenderer, Q extends FieldCellProps>( const props = propsRefs.current; + // While a modifier key is held, the cell under the cursor that resolves + // to a schema location gets an underline. `args.hoverX` is defined only + // for the cell currently under the pointer — glide re-invokes draw for + // a cell whenever its hover state changes, so no separate hover state + // is needed. + const shouldUnderlineJump = + args.hoverX !== undefined && + props?.modifierHeld === true && + props.resolver !== undefined && + resolveGridCellTarget( + props.resolver, + dataKind, + props.annotatedData[row].columnData, + zeroIndexedCol, + ) !== undefined; + const selectedType: SelectedType | undefined = props?.selected.selectedType || props?.selected.selectedObject || @@ -250,6 +268,17 @@ function fieldCellRenderer, Q extends FieldCellProps>( ctx.font = "12px Roboto Mono, Monospace"; ctx.fillText(dataValue, rect.x + 10, rect.y + rect.height / 2 + 1, rect.width - 20); + if (shouldUnderlineJump) { + const underlineWidth = Math.min(ctx.measureText(dataValue).width, rect.width - 20); + const underlineY = rect.y + rect.height / 2 + 7; + ctx.strokeStyle = theme.textDark; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(rect.x + 10, underlineY); + ctx.lineTo(rect.x + 10 + underlineWidth, underlineY); + ctx.stroke(); + } + ctx.restore(); return true; }, diff --git a/src/components/relationshipeditor/jump-targets.test.ts b/src/components/relationshipeditor/jump-targets.test.ts new file mode 100644 index 00000000..60897a55 --- /dev/null +++ b/src/components/relationshipeditor/jump-targets.test.ts @@ -0,0 +1,188 @@ +import { parse, Resolver } from "@authzed/spicedb-parser-js"; +import { describe, expect, it } from "vitest"; + +import { DataKind } from "./columns"; +import { + caveatTarget, + relationTarget, + resolveGridCellTarget, + tokenAtPosition, + typeTarget, +} from "./jump-targets"; + +// Schema written without indentation so every line-start token is at column 1. +const SCHEMA = [ + "definition user {}", // line 1 + "", // line 2 + "definition document {", // line 3 + "relation viewer: user", // line 4 + "permission view = viewer", // line 5 + "}", // line 6 + "", // line 7 + "caveat ip_match(ip string) {", // line 8 + 'ip == "1.2.3.4"', // line 9 + "}", // line 10 +].join("\n"); + +function buildResolver(): Resolver { + const result = parse(SCHEMA); + if (result.error || !result.schema) { + throw new Error(`test schema failed to parse: ${result.error?.message}`); + } + return new Resolver(result.schema); +} + +describe("jump-targets resolution helpers", () => { + const resolver = buildResolver(); + + it("typeTarget resolves a definition to its start line", () => { + expect(typeTarget(resolver, "user")).toEqual({ line: 1, column: 1 }); + expect(typeTarget(resolver, "document")).toEqual({ line: 3, column: 1 }); + }); + + it("typeTarget returns undefined for an unknown type", () => { + expect(typeTarget(resolver, "nonexistent")).toBeUndefined(); + }); + + it("relationTarget resolves a relation and a permission", () => { + expect(relationTarget(resolver, "document", "viewer")).toEqual({ line: 4, column: 1 }); + expect(relationTarget(resolver, "document", "view")).toEqual({ line: 5, column: 1 }); + }); + + it("relationTarget returns undefined for unknown type or relation", () => { + expect(relationTarget(resolver, "document", "missing")).toBeUndefined(); + expect(relationTarget(resolver, "missing", "viewer")).toBeUndefined(); + }); + + it("caveatTarget resolves a caveat definition", () => { + expect(caveatTarget(resolver, "ip_match")).toEqual({ line: 8, column: 1 }); + }); + + it("caveatTarget returns undefined for an unknown caveat", () => { + expect(caveatTarget(resolver, "missing")).toBeUndefined(); + }); +}); + +describe("tokenAtPosition", () => { + // Column is 1-indexed (Monaco convention). Reference line: + // "document:firstdoc#viewer@user:tom" + // 1 9 18 25 30 + const SIMPLE = "document:firstdoc#viewer@user:tom"; + + it("classifies the resource type", () => { + expect(tokenAtPosition(SIMPLE, 3)).toEqual({ kind: "type", name: "document" }); + }); + + it("classifies the resource relation with its owning type", () => { + expect(tokenAtPosition(SIMPLE, 20)).toEqual({ + kind: "relation", + name: "viewer", + resourceType: "document", + }); + }); + + it("classifies the subject type", () => { + expect(tokenAtPosition(SIMPLE, 27)).toEqual({ kind: "type", name: "user" }); + }); + + it("returns undefined for object ids", () => { + expect(tokenAtPosition(SIMPLE, 12)).toBeUndefined(); // firstdoc + expect(tokenAtPosition(SIMPLE, 31)).toBeUndefined(); // tom + }); + + it("classifies a subject relation against the subject type", () => { + const line = "document:doc#viewer@group:eng#member"; + expect(tokenAtPosition(line, 33)).toEqual({ + kind: "relation", + name: "member", + resourceType: "group", + }); + expect(tokenAtPosition(line, 22)).toEqual({ kind: "type", name: "group" }); + }); + + it("ignores the '...' subject relation", () => { + const line = "document:doc#viewer@user:tom#..."; + expect(tokenAtPosition(line, 30)).toBeUndefined(); + }); + + it("classifies a caveat name", () => { + const line = 'document:doc#viewer@user:tom[somecaveat:{"ip":"1.2.3.4"}]'; + expect(tokenAtPosition(line, 32)).toEqual({ kind: "caveat", name: "somecaveat" }); + }); + + it("does not treat an expiration block as a caveat", () => { + const line = "document:doc#viewer@user:tom[expiration:2025-01-01T00:00:00Z]"; + expect(tokenAtPosition(line, 32)).toBeUndefined(); + }); + + it("returns undefined for comments, blank lines, and out-of-range columns", () => { + expect(tokenAtPosition("// a comment", 5)).toBeUndefined(); + expect(tokenAtPosition(" ", 2)).toBeUndefined(); + expect(tokenAtPosition(SIMPLE, 999)).toBeUndefined(); + }); + + it("returns undefined for a malformed relationship", () => { + expect(tokenAtPosition("not a relationship", 3)).toBeUndefined(); + }); +}); + +describe("resolveGridCellTarget", () => { + const resolver = buildResolver(); + // Grid column order: RESOURCE_TYPE(0), RESOURCE_ID(1), RELATION(2), + // SUBJECT_TYPE(3), SUBJECT_ID(4), SUBJECT_RELATION(5), CAVEAT_NAME(6). + const row = ["document", "doc1", "viewer", "document", "doc2", "viewer", "ip_match"]; + + it("resolves a resource type cell", () => { + expect(resolveGridCellTarget(resolver, DataKind.RESOURCE_TYPE, row, 0)).toEqual({ + line: 3, + column: 1, + }); + }); + + it("resolves a subject type cell", () => { + expect(resolveGridCellTarget(resolver, DataKind.SUBJECT_TYPE, row, 3)).toEqual({ + line: 3, + column: 1, + }); + }); + + it("resolves a relation cell against the resource type two columns left", () => { + expect(resolveGridCellTarget(resolver, DataKind.RELATION, row, 2)).toEqual({ + line: 4, + column: 1, + }); + }); + + it("resolves a subject relation cell against the subject type two columns left", () => { + expect(resolveGridCellTarget(resolver, DataKind.SUBJECT_RELATION, row, 5)).toEqual({ + line: 4, + column: 1, + }); + }); + + it("resolves a caveat name cell", () => { + expect(resolveGridCellTarget(resolver, DataKind.CAVEAT_NAME, row, 6)).toEqual({ + line: 8, + column: 1, + }); + }); + + it("returns undefined for a non-jumpable column", () => { + expect(resolveGridCellTarget(resolver, DataKind.RESOURCE_ID, row, 1)).toBeUndefined(); + }); + + it("returns undefined for an empty cell value", () => { + const emptyRow = ["", "", "", "", "", "", ""]; + expect(resolveGridCellTarget(resolver, DataKind.RESOURCE_TYPE, emptyRow, 0)).toBeUndefined(); + }); + + it("returns undefined when the relation's owner type is empty", () => { + const noOwner = ["", "doc1", "viewer", "", "", "", ""]; + expect(resolveGridCellTarget(resolver, DataKind.RELATION, noOwner, 2)).toBeUndefined(); + }); + + it("returns undefined for an unresolved type", () => { + const unknown = ["nonexistent", "x", "", "", "", "", ""]; + expect(resolveGridCellTarget(resolver, DataKind.RESOURCE_TYPE, unknown, 0)).toBeUndefined(); + }); +}); diff --git a/src/components/relationshipeditor/jump-targets.ts b/src/components/relationshipeditor/jump-targets.ts new file mode 100644 index 00000000..88738eb5 --- /dev/null +++ b/src/components/relationshipeditor/jump-targets.ts @@ -0,0 +1,209 @@ +import { Resolver } from "@authzed/spicedb-parser-js"; + +import { SchemaRevealLocation } from "@/components/editor-groups/schema-jump"; + +import { DataKind } from "./columns"; + +/** + * typeTarget resolves an object/definition name to the start of its definition + * in the schema, or undefined if the schema does not define it. + */ +export function typeTarget( + resolver: Resolver, + typeName: string, +): SchemaRevealLocation | undefined { + const def = resolver.lookupDefinition(typeName); + if (!def) { + return undefined; + } + const start = def.definition.range.startIndex; + return { line: start.line, column: start.column }; +} + +/** + * relationTarget resolves a relation or permission on the given type to the + * start of its declaration in the schema, or undefined if either the type or + * the relation/permission is not defined. + */ +export function relationTarget( + resolver: Resolver, + typeName: string, + relationName: string, +): SchemaRevealLocation | undefined { + const def = resolver.lookupDefinition(typeName); + if (!def) { + return undefined; + } + const relationOrPermission = def.lookupRelationOrPermission(relationName); + if (!relationOrPermission) { + return undefined; + } + const start = relationOrPermission.range.startIndex; + return { line: start.line, column: start.column }; +} + +/** + * caveatTarget resolves a caveat name to the start of its definition in the + * schema, or undefined if the schema does not define it. ResolvedCaveatDefinition + * (from listCaveats) has a zeroed-out range, so this scans the raw schema + * definitions for the caveatDef node directly. + */ +export function caveatTarget( + resolver: Resolver, + caveatName: string, +): SchemaRevealLocation | undefined { + const caveat = resolver.schema.definitions.find( + (d) => d.kind === "caveatDef" && d.name === caveatName, + ); + if (!caveat) { + return undefined; + } + const start = caveat.range.startIndex; + return { line: start.line, column: start.column }; +} + +/** + * ClickedToken describes a jumpable token identified inside a relationship line. + * `relation` carries the owning type so the relation can be resolved against it. + */ +export type ClickedToken = + | { kind: "type"; name: string } + | { kind: "relation"; name: string; resourceType: string } + | { kind: "caveat"; name: string }; + +/** + * tokenAtPosition classifies the token under a 1-indexed column in a single + * relationship line. It segments the line on the unambiguous delimiters + * `:` `#` `@` `[` — none of which can appear inside a type, id, or relation + * token — and returns the jumpable token there, or undefined when the column + * is on an object id / caveat context / expiration, or the line is not a + * parseable relationship. + */ +export function tokenAtPosition(lineText: string, column: number): ClickedToken | undefined { + const idx = column - 1; // 0-indexed offset into lineText + if (idx < 0 || idx >= lineText.length) { + return undefined; + } + + const trimmedStart = lineText.search(/\S/); + if (trimmedStart < 0 || lineText.slice(trimmedStart).startsWith("//")) { + return undefined; + } + + // Resource part: everything before the first '@'. + const atIndex = lineText.indexOf("@"); + if (atIndex < 0) { + return undefined; + } + const resourceColon = lineText.indexOf(":"); + const resourceHash = lineText.indexOf("#"); + if ( + resourceColon < 0 || + resourceHash < 0 || + resourceColon > atIndex || + resourceHash > atIndex || + resourceHash < resourceColon + ) { + return undefined; + } + + const resourceType = lineText.slice(trimmedStart, resourceColon).trim(); + const resourceRelStart = resourceHash + 1; + + // resourceType span: [trimmedStart, resourceColon) + if (idx >= trimmedStart && idx < resourceColon) { + return { kind: "type", name: resourceType }; + } + // resourceRel span: [resourceHash + 1, atIndex) + if (idx >= resourceRelStart && idx < atIndex) { + const name = lineText.slice(resourceRelStart, atIndex).trim(); + return { kind: "relation", name, resourceType }; + } + + // Subject part: everything after the first '@'. + const subjBase = atIndex + 1; + const subjectPart = lineText.slice(subjBase); + const subjColonRel = subjectPart.indexOf(":"); + if (subjColonRel < 0) { + return undefined; + } + const subjectType = subjectPart.slice(0, subjColonRel).trim(); + + // subjectType span: [subjBase, subjBase + subjColonRel) + if (idx >= subjBase && idx < subjBase + subjColonRel) { + return { kind: "type", name: subjectType }; + } + + const subjBracket = subjectPart.indexOf("["); + + // Optional subject relation: '#' after the subject id. + const subjHash = subjectPart.indexOf("#", subjColonRel + 1); + if (subjHash >= 0) { + const relStart = subjHash + 1; + const relEnd = subjBracket > subjHash ? subjBracket : subjectPart.length; + if (idx >= subjBase + relStart && idx < subjBase + relEnd) { + const name = subjectPart.slice(relStart, relEnd).trim(); + if (name === "...") { + return undefined; + } + return { kind: "relation", name, resourceType: subjectType }; + } + } + + // Optional caveat: the first '[' block, unless it is an expiration block. + // Caveat context is JSON and may contain ']'/'['/':' — but the caveat NAME + // always sits immediately after '[' and ends at the first ':' or ']'. + if (subjBracket >= 0) { + const afterBracket = subjectPart.slice(subjBracket + 1); + const stop = afterBracket.search(/[:\]]/); + const namePart = stop >= 0 ? afterBracket.slice(0, stop) : afterBracket; + if (namePart !== "expiration") { + const nameStart = subjBase + subjBracket + 1; + const nameEnd = nameStart + namePart.length; + if (idx >= nameStart && idx < nameEnd) { + return { kind: "caveat", name: namePart.trim() }; + } + } + } + + return undefined; +} + +/** + * resolveGridCellTarget resolves the jump target for a relationship-grid cell, + * given the cell's column `dataKind`, the row's `columnData`, and the cell's + * (marker-excluded) column index. Returns undefined for non-jumpable columns, + * empty cell values, or tokens not present in the schema. + * + * The owning type for a relation cell sits two columns to the left of the + * relation column (resource type for RELATION, subject type for + * SUBJECT_RELATION). + */ +export function resolveGridCellTarget( + resolver: Resolver, + dataKind: DataKind, + columnData: readonly string[], + col: number, +): SchemaRevealLocation | undefined { + const value = columnData[col]; + if (!value) { + return undefined; + } + switch (dataKind) { + case DataKind.RESOURCE_TYPE: + case DataKind.SUBJECT_TYPE: + return typeTarget(resolver, value); + case DataKind.RELATION: + case DataKind.SUBJECT_RELATION: { + const typeName = columnData[col - 2]; + if (!typeName) { + return undefined; + } + return relationTarget(resolver, typeName, value); + } + case DataKind.CAVEAT_NAME: + return caveatTarget(resolver, value); + default: + return undefined; + } +} diff --git a/src/components/relationshipeditor/tuplelang.ts b/src/components/relationshipeditor/tuplelang.ts index 0bf018d8..3962e2a5 100644 --- a/src/components/relationshipeditor/tuplelang.ts +++ b/src/components/relationshipeditor/tuplelang.ts @@ -1,6 +1,7 @@ -import { editor, Position } from "monaco-editor"; +import { editor, languages, Position } from "monaco-editor"; import * as monacoEditor from "monaco-editor"; +import { SchemaRevealLocation, useSchemaJumpStore } from "@/components/editor-groups/schema-jump"; import { LocalParseState } from "@/services/localparse"; import { getCaveatDefinitions, @@ -11,6 +12,8 @@ import { SubjectDefinition, } from "@/services/semantics"; +import { caveatTarget, relationTarget, tokenAtPosition, typeTarget } from "./jump-targets"; + export const TUPLE_LANGUAGE_NAME = "tuple"; export const TUPLE_THEME_NAME = "tuple-theme"; export const TUPLE_DARK_THEME_NAME = "tuple-theme-dark"; @@ -196,6 +199,93 @@ export default function registerTupleLanguage( }, }); + // Cmd/Ctrl+hover/click in the tuple editor uses Monaco's native "go to + // definition" affordance (underline + pointer cursor). The definition + // provider resolves the token to a position in the schema and returns it at + // a sentinel URI; the editor-opener below intercepts that URI and performs + // the cross-model jump via the schema-jump store. (A definition provider + // cannot itself navigate across Monaco models, hence the separate opener.) + const schemaJumpUri = monaco.Uri.parse("playground://schema-jump"); + + // Monaco only renders the hover affordance for a single definition result + // once it can resolve that result's URI to a real text model (see + // GotoDefinitionAtPositionEditorContribution.startFindDefinition — it bails + // before adding the underline decoration if createModelReference fails). So + // the sentinel URI must back a real model; its content is kept mirrored to + // the schema text in provideDefinition so the line count covers the target + // position and the hover preview is accurate. + const schemaJumpModel = + monaco.editor.getModel(schemaJumpUri) ?? + monaco.editor.createModel("", undefined, schemaJumpUri); + + monaco.languages.registerDefinitionProvider(TUPLE_LANGUAGE_NAME, { + provideDefinition: ( + model: editor.ITextModel, + position: Position, + ): languages.ProviderResult => { + const token = tokenAtPosition( + model.getLineContent(position.lineNumber), + position.column, + ); + if (!token) { + return undefined; + } + const resolver = localParseState().resolver; + if (!resolver) { + return undefined; + } + let target: SchemaRevealLocation | undefined; + if (token.kind === "type") { + target = typeTarget(resolver, token.name); + } else if (token.kind === "relation") { + target = relationTarget(resolver, token.resourceType, token.name); + } else { + target = caveatTarget(resolver, token.name); + } + if (!target) { + return undefined; + } + // Mirror the current schema text into the sentinel model so Monaco can + // resolve it (which is what makes the hover affordance render) and show + // an accurate preview. + const schemaText = localParseState().schemaText; + if (schemaJumpModel.getValue() !== schemaText) { + schemaJumpModel.setValue(schemaText); + } + return { + uri: schemaJumpUri, + range: { + startLineNumber: target.line, + startColumn: target.column, + endLineNumber: target.line, + endColumn: target.column, + }, + }; + }, + }); + + monaco.editor.registerEditorOpener({ + openCodeEditor: (_source, resource, selectionOrPosition) => { + if (resource.toString() !== schemaJumpUri.toString()) { + return false; + } + let line: number | undefined; + let column: number | undefined; + if (selectionOrPosition && "startLineNumber" in selectionOrPosition) { + line = selectionOrPosition.startLineNumber; + column = selectionOrPosition.startColumn; + } else if (selectionOrPosition && "lineNumber" in selectionOrPosition) { + line = selectionOrPosition.lineNumber; + column = selectionOrPosition.column; + } + if (line === undefined || column === undefined) { + return false; + } + useSchemaJumpStore.getState().jumpToSchema(line, column); + return true; + }, + }); + monaco.editor.defineTheme(TUPLE_THEME_NAME, { base: "vs", inherit: true, diff --git a/src/hooks/use-modifier-key-held.ts b/src/hooks/use-modifier-key-held.ts new file mode 100644 index 00000000..62544b22 --- /dev/null +++ b/src/hooks/use-modifier-key-held.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; + +/** + * useModifierKeyHeld returns whether the Ctrl or Cmd (meta) key is currently + * held down. Used to show the "click to jump" affordance in the relationship + * editors. Resets on window blur so a key still held while switching apps does + * not leave the affordance stuck on. + */ +export function useModifierKeyHeld(): boolean { + const [held, setHeld] = useState(false); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.ctrlKey || e.metaKey) { + setHeld(true); + } + }; + const onKeyUp = (e: KeyboardEvent) => { + if (!e.ctrlKey && !e.metaKey) { + setHeld(false); + } + }; + const onBlur = () => setHeld(false); + + document.addEventListener("keydown", onKeyDown); + document.addEventListener("keyup", onKeyUp); + window.addEventListener("blur", onBlur); + return () => { + document.removeEventListener("keydown", onKeyDown); + document.removeEventListener("keyup", onKeyUp); + window.removeEventListener("blur", onBlur); + }; + }, []); + + return held; +} diff --git a/src/tests/browser/use-modifier-key-held.test.tsx b/src/tests/browser/use-modifier-key-held.test.tsx new file mode 100644 index 00000000..9d91e7af --- /dev/null +++ b/src/tests/browser/use-modifier-key-held.test.tsx @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { renderHook } from "vitest-browser-react"; + +import { useModifierKeyHeld } from "@/hooks/use-modifier-key-held"; + +describe("useModifierKeyHeld", () => { + it("starts false", async () => { + const { result } = await renderHook(() => useModifierKeyHeld()); + expect(result.current).toBe(false); + }); + + it("becomes true on a meta keydown and false on release", async () => { + const { result } = await renderHook(() => useModifierKeyHeld()); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Meta", metaKey: true })); + await expect.poll(() => result.current).toBe(true); + + document.dispatchEvent( + new KeyboardEvent("keyup", { key: "Meta", metaKey: false, ctrlKey: false }), + ); + await expect.poll(() => result.current).toBe(false); + }); + + it("becomes true on a ctrl keydown", async () => { + const { result } = await renderHook(() => useModifierKeyHeld()); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Control", ctrlKey: true })); + await expect.poll(() => result.current).toBe(true); + }); + + it("resets to false on window blur", async () => { + const { result } = await renderHook(() => useModifierKeyHeld()); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Control", ctrlKey: true })); + await expect.poll(() => result.current).toBe(true); + + window.dispatchEvent(new Event("blur")); + await expect.poll(() => result.current).toBe(false); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 7bc581ce..347f81b1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -20,4 +20,7 @@ export default defineConfig({ "@": path.resolve(__dirname, "./src"), }, }, + optimizeDeps: { + include: ["parsimmon"], + }, }); diff --git a/vitest.config.ts b/vitest.config.ts index b053afd6..c9c4f9f3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,6 +14,11 @@ export default mergeConfig( name: "unit", include: ["src/**/*.test.ts", "src/**/*.test.tsx"], exclude: ["src/tests/browser/**"], + server: { + deps: { + inline: ["@authzed/spicedb-parser-js", "parsimmon"], + }, + }, }, }, { From 1a7d0ab8a6ed1a14375733afa294f57d7f85701a Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Tue, 19 May 2026 12:21:12 -0400 Subject: [PATCH 2/2] chore: apply oxfmt formatting --- src/components/relationshipeditor/RelationshipEditor.tsx | 4 ++-- src/components/relationshipeditor/jump-targets.ts | 5 +---- src/components/relationshipeditor/tuplelang.ts | 5 +---- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/components/relationshipeditor/RelationshipEditor.tsx b/src/components/relationshipeditor/RelationshipEditor.tsx index f9b6c187..fad9effa 100644 --- a/src/components/relationshipeditor/RelationshipEditor.tsx +++ b/src/components/relationshipeditor/RelationshipEditor.tsx @@ -26,8 +26,8 @@ import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { useResolvedTheme } from "@/hooks/use-resolved-theme"; import { useModifierKeyHeld } from "@/hooks/use-modifier-key-held"; +import { useResolvedTheme } from "@/hooks/use-resolved-theme"; import { ParseRelationshipError, parseRelationshipsWithComments, @@ -45,7 +45,6 @@ import { MIN_COLUMN_WIDTH, validate, } from "./columns"; -import { resolveGridCellTarget } from "./jump-targets"; import { COMMENT_CELL_KIND, copyDataForCommentCell } from "./commentcell"; import { RelEditorCustomCell, useCustomCells } from "./customcells"; import { @@ -72,6 +71,7 @@ import { RELATION_CELL_KIND, TYPE_CELL_KIND, } from "./fieldcell"; +import { resolveGridCellTarget } from "./jump-targets"; export type RelationTupleHighlight = { tupleString: string; diff --git a/src/components/relationshipeditor/jump-targets.ts b/src/components/relationshipeditor/jump-targets.ts index 88738eb5..7ce7e81f 100644 --- a/src/components/relationshipeditor/jump-targets.ts +++ b/src/components/relationshipeditor/jump-targets.ts @@ -8,10 +8,7 @@ import { DataKind } from "./columns"; * typeTarget resolves an object/definition name to the start of its definition * in the schema, or undefined if the schema does not define it. */ -export function typeTarget( - resolver: Resolver, - typeName: string, -): SchemaRevealLocation | undefined { +export function typeTarget(resolver: Resolver, typeName: string): SchemaRevealLocation | undefined { const def = resolver.lookupDefinition(typeName); if (!def) { return undefined; diff --git a/src/components/relationshipeditor/tuplelang.ts b/src/components/relationshipeditor/tuplelang.ts index 3962e2a5..047a712f 100644 --- a/src/components/relationshipeditor/tuplelang.ts +++ b/src/components/relationshipeditor/tuplelang.ts @@ -223,10 +223,7 @@ export default function registerTupleLanguage( model: editor.ITextModel, position: Position, ): languages.ProviderResult => { - const token = tokenAtPosition( - model.getLineContent(position.lineNumber), - position.column, - ); + const token = tokenAtPosition(model.getLineContent(position.lineNumber), position.column); if (!token) { return undefined; }