Skip to content
Open
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
36 changes: 36 additions & 0 deletions src/components/EditorDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -455,6 +457,7 @@ export function EditorDisplay(props: EditorDisplayProps) {

updateMarkers();
updatePosition();
revealSchemaJump();
}
};

Expand Down Expand Up @@ -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();

Expand Down
33 changes: 33 additions & 0 deletions src/components/editor-groups/schema-jump.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
30 changes: 30 additions & 0 deletions src/components/editor-groups/schema-jump.ts
Original file line number Diff line number Diff line change
@@ -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<SchemaJumpState>((set) => ({
pendingReveal: undefined,
jumpToSchema: (line, column) => {
useEditorStore.getState().showDocument("schema");
set({ pendingReveal: { line, column } });
},
consumeReveal: () => set({ pendingReveal: undefined }),
}));
56 changes: 55 additions & 1 deletion src/components/relationshipeditor/RelationshipEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import DataEditor, {
GridMouseEventArgs,
GridSelection,
Rectangle,
type CellClickedEventArgs,
type Item,
type Theme,
type Highlight,
} from "@glideapps/glide-data-grid";
Expand All @@ -19,10 +21,12 @@ 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 { useModifierKeyHeld } from "@/hooks/use-modifier-key-held";
import { useResolvedTheme } from "@/hooks/use-resolved-theme";
import {
ParseRelationshipError,
Expand Down Expand Up @@ -67,6 +71,7 @@ import {
RELATION_CELL_KIND,
TYPE_CELL_KIND,
} from "./fieldcell";
import { resolveGridCellTarget } from "./jump-targets";

export type RelationTupleHighlight = {
tupleString: string;
Expand Down Expand Up @@ -333,6 +338,7 @@ export function RelationshipEditor({
};

const resolvedTheme = useResolvedTheme();
const modifierHeld = useModifierKeyHeld();

const dataEditorTheme: Partial<Theme> = useMemo(() => {
const isDark = resolvedTheme === "dark";
Expand Down Expand Up @@ -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:
Expand All @@ -453,6 +470,7 @@ export function RelationshipEditor({
},
allowOverlay: true,
copyData: data[row].columnData[col],
cursor: jumpCursor,
};

case DataKind.RESOURCE_ID:
Expand Down Expand Up @@ -481,6 +499,7 @@ export function RelationshipEditor({
},
allowOverlay: true,
copyData: data[row].columnData[col],
cursor: jumpCursor,
};

case DataKind.CAVEAT_NAME:
Expand All @@ -494,6 +513,7 @@ export function RelationshipEditor({
},
allowOverlay: true,
copyData: data[row].columnData[col],
cursor: jumpCursor,
};

case DataKind.CAVEAT_CONTEXT:
Expand Down Expand Up @@ -531,7 +551,7 @@ export function RelationshipEditor({
};
}
},
[data],
[data, resolver, modifierHeld],
);

const getCellsForSelection = useCallback(
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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"}
Expand Down
3 changes: 3 additions & 0 deletions src/components/relationshipeditor/customcells.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -237,6 +239,7 @@ export function useCustomCells(
},
similarHighlighting: similarHighlighting,
columnsWithWidths: columnsWithWidths,
modifierHeld: modifierHeld,
};

// renderers defines the custom cell types supported by the RelationshipEditor.
Expand Down
29 changes: 29 additions & 0 deletions src/components/relationshipeditor/fieldcell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

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";
Expand Down Expand Up @@ -104,6 +105,7 @@
};
similarHighlighting: boolean;
columnsWithWidths: Column[];
modifierHeld: boolean;
}

type GetAutocompleteOptions<Q extends FieldCellProps> = (
Expand Down Expand Up @@ -150,6 +152,22 @@

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 ||
Expand Down Expand Up @@ -250,6 +268,17 @@
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;
},
Expand Down Expand Up @@ -281,7 +310,7 @@
kind: string;
};

const FieldCellEditor = <T extends CustomCell<Q>, Q extends FieldCellProps>(

Check warning on line 313 in src/components/relationshipeditor/fieldcell.tsx

View workflow job for this annotation

GitHub Actions / Run Linters and Typechecking

react(only-export-components)

Fast refresh only works when a file only exports components. Move your component(s) to a separate file.
props: FieldCellEditorProps<T, Q>,
) => {
const edited = useRef(false);
Expand Down
Loading
Loading