From debbc0d56fabac52fcef204ec60127308ff1f38a Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Tue, 19 May 2026 13:19:10 -0400 Subject: [PATCH] feat: add check watch from failing assertion Surface failing assertions in two places that let the user lift them into a Check Watch row with one click: a CodeLens above the assertion line in the YAML editor, and an "Add check watch" button on the assertion's row in the Problems panel. Clicking either opens the Check Watches drawer, runs the watch immediately, and flashes the new row so it's locatable. Assertion-source errors are also re-routed from the Validation group to the Assertions group in the Problems panel, where they belong. --- src/components/EditorDisplay.tsx | 94 ++++++++++++++++++++++++++++-- src/components/panels/problems.tsx | 53 +++++++++++++++-- src/components/panels/watches.tsx | 4 +- src/index.css | 15 +++++ src/services/check.test.ts | 51 ++++++++++++++++ src/services/check.ts | 88 +++++++++++++++++++++++++++- 6 files changed, 292 insertions(+), 13 deletions(-) create mode 100644 src/services/check.test.ts diff --git a/src/components/EditorDisplay.tsx b/src/components/EditorDisplay.tsx index 47e61d1..2473fe2 100644 --- a/src/components/EditorDisplay.tsx +++ b/src/components/EditorDisplay.tsx @@ -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"; @@ -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"; @@ -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; @@ -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; @@ -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); @@ -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); @@ -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 @@ -639,3 +658,66 @@ export function EditorDisplay(props: EditorDisplayProps) { ); } + +/** + * 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(); + 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); +} diff --git a/src/components/panels/problems.tsx b/src/components/panels/problems.tsx index 08adfe7..aa938a8 100644 --- a/src/components/panels/problems.tsx +++ b/src/components/panels/problems.tsx @@ -1,9 +1,11 @@ -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, @@ -11,6 +13,7 @@ import { DeveloperWarning, } from "../../spicedb-common/protodefs/developer/v1/developer_pb"; import { DocumentLink } from "../document-link"; +import { useDrawerStore } from "../drawer/state"; import { DeveloperSourceDisplay, DeveloperWarningSourceDisplay } from "./errordisplays"; @@ -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 (
@@ -63,7 +75,11 @@ export function ProblemsPanel({ services }: ProblemsPanelProps) { {assertionErrors.map((de, i) => ( - + } + /> ))} @@ -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 (
@@ -194,10 +210,37 @@ function ErrorRow({ error }: { error: DeveloperError }) { )}
+ {action &&
{action}
} ); } +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 ( + + + + + Add check watch for this assertion + + ); +} + function WarningRow({ warning }: { warning: DeveloperWarning }) { const { summary, rest } = splitMessage(warning.message); return ( diff --git a/src/components/panels/watches.tsx b/src/components/panels/watches.tsx index 7d0d8f3..a2717cc 100644 --- a/src/components/panels/watches.tsx +++ b/src/components/panels/watches.tsx @@ -118,6 +118,7 @@ export function WatchesPanel({ services, datastore }: WatchesPanelProps) { editorUpdateIndex={editorUpdateIndex} datastore={datastore} item={item} + flashHighlight={liveCheckService.recentlyAddedItemId === item.id} /> ))} @@ -141,6 +142,7 @@ interface LiveCheckRowProps { editorUpdateIndex?: number; datastore: DataStore; localParseService: LocalParseService; + flashHighlight?: boolean; } function LiveCheckRow(props: LiveCheckRowProps) { @@ -260,7 +262,7 @@ function LiveCheckRow(props: LiveCheckRowProps) { )} - + {item.debugInformation !== undefined && (