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
94 changes: 88 additions & 6 deletions src/components/EditorDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { flushSync } from "react-dom";
import { useSettings } from "@/components/SettingsProvider";
import { useResolvedTheme } from "@/hooks/use-resolved-theme";

import { assertionStringToCheckWatch, CheckWatch, LiveCheckService } from "../services/check";
import { ScrollLocation, useCookieService } from "../services/cookieservice";
import { DataStore, DataStoreItem, DataStoreItemKind } from "../services/datastore";
import { LocalParseState } from "../services/localparse";
Expand All @@ -22,6 +23,7 @@ import registerDSLanguage, {
import { RelationshipFound } from "../spicedb-common/parsing";
import {
DeveloperError,
DeveloperError_Source,
DeveloperWarning,
} from "../spicedb-common/protodefs/developer/v1/developer_pb";

Expand All @@ -38,6 +40,13 @@ import registerTupleLanguage, { TUPLE_LANGUAGE_NAME } from "./relationshipeditor
let languagesRegistered = false;
const latestLocalParseStateRef: { current: LocalParseState | null } = { current: null };

// Holds the current LiveCheckService for the assertions-quickfix command,
// which is registered once at module scope and therefore can't close over a
// React-owned reference.
const latestLiveCheckServiceRef: { current: LiveCheckService | null } = { current: null };

const ADD_CHECK_WATCH_COMMAND_ID = "playground.addCheckWatchFromAssertion";

export type EditorDisplayProps = {
datastore: DataStore;
services: Services;
Expand Down Expand Up @@ -75,6 +84,12 @@ export function EditorDisplay(props: EditorDisplayProps) {
latestLocalParseStateRef.current = props.services.localParseService.state;
}, [props.services.localParseService.state]);

// Same trick for the assertions quick-fix command.
latestLiveCheckServiceRef.current = props.services.liveCheckService;
useEffect(() => {
latestLiveCheckServiceRef.current = props.services.liveCheckService;
}, [props.services.liveCheckService]);

const location = useLocation();

const datastore = props.datastore;
Expand Down Expand Up @@ -248,10 +263,13 @@ export function EditorDisplay(props: EditorDisplayProps) {
const trimmedContext = rawContext.trim();
const isSingleWordToken = !!trimmedContext && !/\s/.test(trimmedContext);

// The assertions runner emits line numbers that don't reliably point at
// the failing assertion in the YAML source. Ignore the reported line for
// assertion errors and locate the assertion text in the document.
const ignoreReportedLine = isSingleWordToken && de.source === DeveloperError_Source.ASSERTION;

if (isSingleWordToken) {
// If there is no line information, search the entire document for the
// first occurrence of the trimmed context.
if (!line) {
if (!line || ignoreReportedLine) {
const index = contents.indexOf(trimmedContext);
if (index >= 0) {
const found = finder.fromIndex(index);
Expand All @@ -262,9 +280,9 @@ export function EditorDisplay(props: EditorDisplayProps) {
}
}
} else {
// Anchor to the actual occurrence of the trimmed context on (or near)
// the reported line. This is robust against off-by-one / 0-vs-1
// indexed columns coming from different error producers.
// Anchor to the actual occurrence of the trimmed context on the
// reported line. Robust against off-by-one / 0-vs-1 column indexing
// from different error producers.
const lineText = lines[line - 1] ?? "";
const searchFrom = Math.max(0, (column ?? 1) - 1);
let onLineIndex = lineText.indexOf(trimmedContext, searchFrom);
Expand Down Expand Up @@ -397,6 +415,7 @@ export function EditorDisplay(props: EditorDisplayProps) {
if (!languagesRegistered) {
registerDSLanguage(monacoInstance);
registerTupleLanguage(monacoInstance, () => latestLocalParseStateRef.current!);
registerAssertionFixes(monacoInstance);
languagesRegistered = true;
// Themes are defined inside registerDSLanguage. The Editor already rendered
// with the theme prop before defineTheme ran, so Monaco fell back to its
Expand Down Expand Up @@ -639,3 +658,66 @@ export function EditorDisplay(props: EditorDisplayProps) {
</div>
);
}

/**
* registerAssertionFixes wires a CodeLens above each failing-assertion marker
* that materializes the assertion as a Check Watch row. Registered once at
* module scope; reads the latest LiveCheckService via a module-level ref
* since the command handler can't close over a React-owned reference.
*
* A lightbulb CodeAction would be the natural fit here, but Monaco's
* lightbulb widget shifts its icon to an adjacent line when the marker's
* line indent is narrower than the icon (~22px). For 2-space YAML indents
* this puts the bulb on the wrong line, so we use a CodeLens — which renders
* directly above the failing assertion regardless of indent.
*/
function registerAssertionFixes(monacoInstance: typeof monaco) {
monacoInstance.editor.registerCommand(
ADD_CHECK_WATCH_COMMAND_ID,
(_accessor: unknown, watch: CheckWatch) => {
const service = latestLiveCheckServiceRef.current;
if (!service) return;
useDrawerStore.getState().openPanel("watches");
service.addWatch(watch);
},
);

// Refreshed whenever the owning model's markers change so the lens follows
// the marker as the user edits the YAML.
const codeLensProviderRef: { current: monaco.languages.CodeLensProvider | null } = {
current: null,
};
const codeLensEmitter = new monacoInstance.Emitter<monaco.languages.CodeLensProvider>();
monacoInstance.editor.onDidChangeMarkers(() => {
if (codeLensProviderRef.current) codeLensEmitter.fire(codeLensProviderRef.current);
});
const codeLensProvider: monaco.languages.CodeLensProvider = {
onDidChange: codeLensEmitter.event,
provideCodeLenses: (model) => {
const markers = monacoInstance.editor.getModelMarkers({ resource: model.uri });
const lenses: monaco.languages.CodeLens[] = [];
for (const marker of markers) {
if (marker.severity !== monacoInstance.MarkerSeverity.Error) continue;
const code = typeof marker.code === "string" ? marker.code : (marker.code?.value ?? "");
const watch = assertionStringToCheckWatch(code);
if (!watch) continue;
lenses.push({
range: {
startLineNumber: marker.startLineNumber,
startColumn: 1,
endLineNumber: marker.startLineNumber,
endColumn: 1,
},
command: {
id: ADD_CHECK_WATCH_COMMAND_ID,
title: "$(lightbulb) Add check watch for this assertion",
arguments: [watch],
},
});
}
return { lenses, dispose: () => undefined };
},
};
codeLensProviderRef.current = codeLensProvider;
monacoInstance.languages.registerCodeLensProvider("yaml", codeLensProvider);
}
53 changes: 48 additions & 5 deletions src/components/panels/problems.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { ChevronDown, ChevronRight, CircleX, Play, TriangleAlert } from "lucide-react";
import { ChevronDown, ChevronRight, CircleX, Eye, Play, TriangleAlert } from "lucide-react";
import * as React from "react";

import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";

import { assertionStringToCheckWatch, LiveCheckService } from "../../services/check";
import { Services } from "../../services/services";
import {
DeveloperError,
DeveloperError_Source,
DeveloperWarning,
} from "../../spicedb-common/protodefs/developer/v1/developer_pb";
import { DocumentLink } from "../document-link";
import { useDrawerStore } from "../drawer/state";

import { DeveloperSourceDisplay, DeveloperWarningSourceDisplay } from "./errordisplays";

Expand All @@ -22,13 +25,22 @@ export function ProblemsPanel({ services }: ProblemsPanelProps) {
const requestErrors = services.problemService.requestErrors;
const warnings = services.problemService.warnings;
const invalidRels = services.problemService.invalidRelationships;
const validationErrors = services.problemService.validationErrors;
const allValidationErrors = services.problemService.validationErrors;

const schemaErrors = requestErrors.filter((e) => e.source === DeveloperError_Source.SCHEMA);
const relationshipRequestErrors = requestErrors.filter(
(e) => e.source === DeveloperError_Source.RELATIONSHIP,
);
const assertionErrors = requestErrors.filter((e) => e.source === DeveloperError_Source.ASSERTION);
// Assertion-source errors land in `validationErrors` (the validation runner
// is what executes the assertions block), not `requestErrors`. Pull them out
// so they show up under "Assertions" instead of "Validation".
const assertionErrors = [
...requestErrors.filter((e) => e.source === DeveloperError_Source.ASSERTION),
...allValidationErrors.filter((e) => e.source === DeveloperError_Source.ASSERTION),
];
const validationErrors = allValidationErrors.filter(
(e) => e.source !== DeveloperError_Source.ASSERTION,
);

return (
<div className="p-2 space-y-1">
Expand Down Expand Up @@ -63,7 +75,11 @@ export function ProblemsPanel({ services }: ProblemsPanelProps) {

<Group title="Assertions" errorCount={assertionErrors.length}>
{assertionErrors.map((de, i) => (
<ErrorRow key={`a${i}`} error={de} />
<ErrorRow
key={`a${i}`}
error={de}
action={<AddCheckWatchAction error={de} liveCheckService={services.liveCheckService} />}
/>
))}
</Group>

Expand Down Expand Up @@ -170,7 +186,7 @@ function splitMessage(message: string): { summary: string; rest: string } {
return { summary: trimmed, rest: "" };
}

function ErrorRow({ error }: { error: DeveloperError }) {
function ErrorRow({ error, action }: { error: DeveloperError; action?: React.ReactNode }) {
const { summary, rest } = splitMessage(error.message);
return (
<div className="flex items-start gap-3 border-l-2 border-l-destructive bg-card px-3 py-2">
Expand All @@ -194,10 +210,37 @@ function ErrorRow({ error }: { error: DeveloperError }) {
)}
</div>
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
);
}

function AddCheckWatchAction({
error,
liveCheckService,
}: {
error: DeveloperError;
liveCheckService: LiveCheckService;
}) {
const watch = React.useMemo(() => assertionStringToCheckWatch(error.context), [error.context]);
if (!watch) return null;
const onClick = () => {
useDrawerStore.getState().openPanel("watches");
liveCheckService.addWatch(watch);
};
return (
<Tooltip>
<TooltipTrigger asChild>
<Button size="xs" variant="ghost" onClick={onClick}>
<Eye />
Add check watch
</Button>
</TooltipTrigger>
<TooltipContent>Add check watch for this assertion</TooltipContent>
</Tooltip>
);
}

function WarningRow({ warning }: { warning: DeveloperWarning }) {
const { summary, rest } = splitMessage(warning.message);
return (
Expand Down
4 changes: 3 additions & 1 deletion src/components/panels/watches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export function WatchesPanel({ services, datastore }: WatchesPanelProps) {
editorUpdateIndex={editorUpdateIndex}
datastore={datastore}
item={item}
flashHighlight={liveCheckService.recentlyAddedItemId === item.id}
/>
))}
</TableBody>
Expand All @@ -141,6 +142,7 @@ interface LiveCheckRowProps {
editorUpdateIndex?: number;
datastore: DataStore;
localParseService: LocalParseService;
flashHighlight?: boolean;
}

function LiveCheckRow(props: LiveCheckRowProps) {
Expand Down Expand Up @@ -260,7 +262,7 @@ function LiveCheckRow(props: LiveCheckRowProps) {
</TableCell>
</TableRow>
)}
<TableRow>
<TableRow data-flash-highlight={props.flashHighlight ? "true" : undefined}>
<TableCell className="w-8 px-1 align-middle">
{item.debugInformation !== undefined && (
<Button size="icon-xs" variant="ghost" onClick={() => setIsExpanded(!isExpanded)}>
Expand Down
15 changes: 15 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,18 @@ code {
@apply bg-background text-foreground;
}
}

/* Brief flash applied to a newly-added Check Watch row so the user can locate
* it after the drawer auto-switches to the Check Watches panel. */
@keyframes flash-highlight {
from {
background-color: color-mix(in oklch, var(--primary) 35%, transparent);
}
to {
background-color: transparent;
}
}

[data-flash-highlight="true"] > td {
animation: flash-highlight 1s ease-out;
}
51 changes: 51 additions & 0 deletions src/services/check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";

import { assertionStringToCheckWatch } from "./check";

describe("assertionStringToCheckWatch", () => {
it("parses a simple assertion", () => {
expect(assertionStringToCheckWatch("document:firstdoc#view@user:fred")).toEqual({
object: "document:firstdoc",
action: "view",
subject: "user:fred",
context: "",
});
});

it("strips a trailing #... on the subject", () => {
expect(assertionStringToCheckWatch("document:firstdoc#view@user:fred#...")).toEqual({
object: "document:firstdoc",
action: "view",
subject: "user:fred",
context: "",
});
});

it("preserves a non-trivial subject relation", () => {
expect(assertionStringToCheckWatch("document:firstdoc#view@team:eng#member")).toEqual({
object: "document:firstdoc",
action: "view",
subject: "team:eng#member",
context: "",
});
});

it("extracts a caveat context", () => {
expect(
assertionStringToCheckWatch('document:firstdoc#view@user:fred[some_caveat:{"hour":12}]'),
).toEqual({
object: "document:firstdoc",
action: "view",
subject: "user:fred",
context: 'some_caveat:{"hour":12}',
});
});

it("returns undefined for malformed strings", () => {
expect(assertionStringToCheckWatch("")).toBeUndefined();
expect(assertionStringToCheckWatch("not-a-relationship")).toBeUndefined();
expect(assertionStringToCheckWatch("document:firstdoc")).toBeUndefined();
expect(assertionStringToCheckWatch("document:firstdoc#view")).toBeUndefined();
expect(assertionStringToCheckWatch("#view@user:fred")).toBeUndefined();
});
});
Loading
Loading