Skip to content
Prev Previous commit
Next Next commit
feat: render top level and parameter errors
  • Loading branch information
brettkolodny committed May 31, 2025
commit e20e26a851b73b32de13b726b658653007760121
2 changes: 2 additions & 0 deletions preview/apitypes/apitypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ type PreviewOutput struct {
}

type NullHCLString = types.NullHCLString

type FriendlyDiagnostic = types.FriendlyDiagnostic
5 changes: 4 additions & 1 deletion preview/scripts/types/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,12 @@ func typeMappings(gen *guts.GoParser) error {
gen.IncludeCustomDeclaration(map[string]guts.TypeOverride{
// opt.Bool can return 'null' if unset
"tailscale.com/types/opt.Bool": config.OverrideNullable(config.OverrideLiteral(bindings.KeywordBoolean)),
// Replace the hcl packag's Diagnostic with preview's FriendlyDiagnostic.
// This is needed because when the preview package's re-exported Diagnostic
// type is marshalled it's converted into FriendlyDiagnostic.
"github.com/hashicorp/hcl/v2.Diagnostic": func() bindings.ExpressionType {
return bindings.Reference(bindings.Identifier{
Name: "unknown",
Name: "FriendlyDiagnostic",
Package: nil,
Prefix: "",
})
Expand Down
Binary file modified public/build/preview.wasm
Binary file not shown.
72 changes: 59 additions & 13 deletions src/Preview.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { Button } from "@/components/Button";
import { ResizablePanel } from "@/components/Resizable";
import {
type Diagnostic,
type InternalDiagnostic,
outputToDiagnostics,
} from "@/diagnostics";
import type { PreviewOutput } from "@/gen/types";
import { useDebouncedValue } from "@/hooks/debounce";
import { useStore } from "@/store";
import { cn } from "@/utils/cn";
import { ActivityIcon, ExternalLinkIcon, LoaderIcon } from "lucide-react";
import { type FC, useEffect, useState } from "react";
import type { PreviewOutput } from "@/gen/types";

export const Preview: FC = () => {
const $wasmState = useStore((state) => state.wasmState);
Expand All @@ -32,13 +37,27 @@ export const Preview: FC = () => {
} else {
const output = JSON.parse(rawOutput) as PreviewOutput;
setOutput(() => output);
$setError(outputToDiagnostics(output));
}
} catch (e) {
console.error(e);
if (e instanceof Error) {
$setError(`${e.name}: ${e.message}`);
const diagnostic: InternalDiagnostic = {
severity: "error",
summary: e.name,
detail: e.message,
kind: "internal",
};
$setError([diagnostic]);
} else {
$setError("Something went wrong");
const diagnostic: InternalDiagnostic = {
severity: "error",
summary: "Error",
detail: "Something went wrong",
kind: "internal",
};

$setError([diagnostic]);
}
}
};
Expand Down Expand Up @@ -130,10 +149,10 @@ const PreviewEmptyState = () => {
};

const ErrorPane = () => {
const $error = useStore((state) => state.error);
const $errors = useStore((state) => state.errors);
const $toggleShowError = useStore((state) => state.toggleShowError);

if (!$error) {
if ($errors.diagnostics.length === 0) {
return null;
}

Expand All @@ -143,7 +162,7 @@ const ErrorPane = () => {
aria-hidden={true}
className={cn(
"pointer-events-none absolute top-0 left-0 h-full w-full transition-all",
$error.show && "bg-black/50",
$errors.show && "bg-black/20 dark:bg-black/50",
)}
>
{/* OVERLAY */}
Expand All @@ -152,35 +171,62 @@ const ErrorPane = () => {
<div
className={cn(
"absolute bottom-0 left-0 w-full",
$error.show && "h-auto",
$errors.show && "h-auto",
)}
>
<button
className="flex h-4 min-h-4 w-full items-center justify-center rounded-t-xl bg-[#AA5253]"
className="flex h-4 min-h-4 w-full items-center justify-center rounded-t-xl bg-border-destructive"
onClick={$toggleShowError}
>
<div className="h-0.5 w-2/3 max-w-32 rounded-full bg-white/40"></div>
</button>

<div
aria-hidden={!$error.show}
aria-hidden={!$errors.show}
className={cn(
"flex h-full flex-col gap-6 bg-surface-secondary p-6",
!$error.show && "pointer-events-none h-0 p-0",
!$errors.show && "pointer-events-none h-0 p-0",
)}
>
<p className="font-semibold text-content-primary text-xl">
An error has occurred
</p>
<p className="rounded-xl bg-surface-tertiary p-3 font-mono text-content-primary text-xs">
{$error.message}
</p>
<div className="flex w-full flex-col gap-3">
{$errors.diagnostics.map((diagnostic, index) => (
<ErrorBlock diagnostic={diagnostic} key={index} />
))}
</div>
</div>
</div>
</>
);
};

type ErroBlockPorps = {
diagnostic: Diagnostic;
};
const ErrorBlock: FC<ErroBlockPorps> = ({ diagnostic }) => {
return (
<div className="rounded-xl bg-surface-tertiary p-3 font-mono text-content-primary text-sm leading-normal">
<p
className={cn(
"text-content-destructive",
diagnostic.severity === "warning" && "text-content-warning",
)}
>
<span className="uppercase">
<strong>{diagnostic.severity}</strong>
</span>
{diagnostic.kind === "parameter"
? ` (${diagnostic.parameterName})`
: null}
: {diagnostic.summary}
</p>
<p>{diagnostic.detail}</p>
</div>
);
};

const WasmLoading: FC = () => {
return (
<div className="flex w-full max-w-xs flex-col items-center justify-center gap-2 rounded-xl border border-[#38BDF8] bg-surface-tertiary p-4">
Expand Down
45 changes: 45 additions & 0 deletions src/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { FriendlyDiagnostic, Parameter, PreviewOutput } from "./gen/types";

type FriendlyDiagnosticWithoutKind = Omit<FriendlyDiagnostic, "extra">;

export type ParameterDiagnostic = {
kind: "parameter";
parameterName: string;
} & FriendlyDiagnosticWithoutKind;

export type TopLevelDiagnostic = {
kind: "top-level";
} & FriendlyDiagnosticWithoutKind;

export type InternalDiagnostic = {
kind: "internal";
} & FriendlyDiagnosticWithoutKind;

export type Diagnostic =
| ParameterDiagnostic
| TopLevelDiagnostic
| InternalDiagnostic;

export const outputToDiagnostics = (output: PreviewOutput): Diagnostic[] => {
const parameterDiags = (output.output?.Parameters ?? []).flatMap(
parameterToDiagnostics,
);

const topLevelDiags: TopLevelDiagnostic[] = output.diags
.filter((d) => d !== null)
.map((d) => ({
kind: "top-level",
...d,
}));

return [...topLevelDiags, ...parameterDiags];
};

const parameterToDiagnostics = (parameter: Parameter): ParameterDiagnostic[] =>
parameter.diagnostics
.filter((d) => d !== null)
.map((d) => ({
kind: "parameter",
parameterName: parameter.name,
...d,
}));
20 changes: 19 additions & 1 deletion src/gen/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,17 @@ export interface Block {
}

// From types/diagnostics.go
export type Diagnostics = (unknown | null)[];
export interface DiagnosticExtra {
code: string;
// empty interface{} type, falling back to unknown
Wrapped: unknown;
}

// From types/diagnostics.go
export type DiagnosticSeverityString = string;

// From types/diagnostics.go
export type Diagnostics = (FriendlyDiagnostic | null)[];

// From hcl/structure.go
export interface File {
Expand All @@ -15,6 +25,14 @@ export interface File {
Nav: unknown;
}

// From apitypes/apitypes.go
export interface FriendlyDiagnostic {
severity: DiagnosticSeverityString;
summary: string;
detail: string;
extra: DiagnosticExtra;
}

// From apitypes/apitypes.go
export interface NullHCLString {
value: string;
Expand Down
37 changes: 20 additions & 17 deletions src/store.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { create } from "zustand";
import type { Diagnostic } from "@/diagnostics";

const defaultCode = `terraform {
required_providers {
Expand All @@ -10,42 +11,44 @@ const defaultCode = `terraform {

type WasmState = "loaded" | "loading" | "error";

type ErrorsState = {
diagnostics: Diagnostic[];
show: boolean;
};
const defaultErrorsState: ErrorsState = {
diagnostics: [],
show: true,
};

type State = {
code: string;
wasmState: WasmState;
error?: {
message?: string;
show: boolean;
};
errors: ErrorsState;
setCode: (code: string) => void;
setError: (error: string) => void;
setError: (diagnostics: Diagnostic[]) => void;
toggleShowError: () => void;
setWasmState: (wasmState: WasmState) => void;
};

export const useStore = create<State>()((set) => ({
code: defaultCode,
wasmState: "loading",
// error: {
// message: "wibble: wobble",
// show: false,
// },
errors: defaultErrorsState,
setCode: (code) => set((_) => ({ code })),
setError: (message) =>
setError: (data) =>
set((state) => {
// If there is currently no error, then we want to show this new error
const error = state.error ?? { show: true };

const errors = state.errors ?? defaultErrorsState;
return {
error: { ...error, message },
errors: { ...errors, diagnostics: data },
};
}),
toggleShowError: () =>
set((state) => {
const errors = state.errors ?? defaultErrorsState;
return {
error: {
show: !(state.error?.show ?? true),
message: state.error?.message ?? "",
errors: {
...errors,
show: !errors.show,
},
};
}),
Expand Down