chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
import { CheckIcon, PencilSquareIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import type { AITimeFilter } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/types";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type StreamEventType =
|
||||
| { type: "thinking"; content: string }
|
||||
| { type: "tool_call"; tool: string; args: unknown }
|
||||
| { type: "time_filter"; filter: AITimeFilter }
|
||||
| { type: "result"; success: true; query: string; timeFilter?: AITimeFilter }
|
||||
| { type: "result"; success: false; error: string };
|
||||
|
||||
export type AIQueryMode = "new" | "edit";
|
||||
|
||||
interface AIQueryInputProps {
|
||||
onQueryGenerated: (query: string) => void;
|
||||
/** Called when the AI sets a time filter - updates URL search params */
|
||||
onTimeFilterChange?: (filter: AITimeFilter) => void;
|
||||
/** Set this to a prompt to auto-populate and immediately submit */
|
||||
autoSubmitPrompt?: string;
|
||||
/** Change this to force re-submission even if prompt is the same */
|
||||
autoSubmitKey?: number;
|
||||
/** Get the current query in the editor (used for edit mode) */
|
||||
getCurrentQuery?: () => string;
|
||||
}
|
||||
|
||||
export function AIQueryInput({
|
||||
onQueryGenerated,
|
||||
onTimeFilterChange,
|
||||
autoSubmitPrompt,
|
||||
autoSubmitKey,
|
||||
getCurrentQuery,
|
||||
}: AIQueryInputProps) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [mode, setMode] = useState<AIQueryMode>("new");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [thinking, setThinking] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showThinking, setShowThinking] = useState(false);
|
||||
const [lastResult, setLastResult] = useState<"success" | "error" | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const lastAutoSubmitRef = useRef<{ prompt: string; key?: number } | null>(null);
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const resourcePath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/query/ai-generate`;
|
||||
|
||||
// Can only use edit mode if there's a current query
|
||||
const canEdit = Boolean(getCurrentQuery?.()?.trim());
|
||||
|
||||
// If mode is edit but there's no current query, switch to new
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && !canEdit) {
|
||||
setMode("new");
|
||||
}
|
||||
}, [mode, canEdit]);
|
||||
|
||||
const submitQuery = useCallback(
|
||||
async (queryPrompt: string, submitMode: AIQueryMode = mode) => {
|
||||
if (!queryPrompt.trim() || isLoading) return;
|
||||
const currentQuery = getCurrentQuery?.();
|
||||
if (submitMode === "edit" && !currentQuery?.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setThinking("");
|
||||
setError(null);
|
||||
setShowThinking(true);
|
||||
setLastResult(null);
|
||||
|
||||
// Abort any existing request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("prompt", queryPrompt);
|
||||
formData.append("mode", submitMode);
|
||||
if (submitMode === "edit" && currentQuery) {
|
||||
formData.append("currentQuery", currentQuery);
|
||||
}
|
||||
|
||||
const response = await fetch(resourcePath, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = (await response.json()) as { error?: string };
|
||||
setError(errorData.error || "Failed to generate query");
|
||||
setIsLoading(false);
|
||||
setLastResult("error");
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
setError("No response stream");
|
||||
setIsLoading(false);
|
||||
setLastResult("error");
|
||||
return;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete events from buffer
|
||||
const lines = buffer.split("\n\n");
|
||||
buffer = lines.pop() || ""; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
try {
|
||||
const event = JSON.parse(line.slice(6)) as StreamEventType;
|
||||
processStreamEvent(event);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining data
|
||||
if (buffer.startsWith("data: ")) {
|
||||
try {
|
||||
const event = JSON.parse(buffer.slice(6)) as StreamEventType;
|
||||
processStreamEvent(event);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
// Request was aborted, ignore
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
setLastResult("error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[isLoading, resourcePath, mode, getCurrentQuery]
|
||||
);
|
||||
|
||||
const processStreamEvent = useCallback(
|
||||
(event: StreamEventType) => {
|
||||
switch (event.type) {
|
||||
case "thinking":
|
||||
setThinking((prev) => prev + event.content);
|
||||
break;
|
||||
case "tool_call":
|
||||
// Tool calls are handled silently — no UI text needed
|
||||
break;
|
||||
case "time_filter":
|
||||
// Apply time filter immediately when the AI sets it
|
||||
onTimeFilterChange?.(event.filter);
|
||||
break;
|
||||
case "result":
|
||||
if (event.success) {
|
||||
// Apply time filter if included in result (backup in case time_filter event was missed)
|
||||
if (event.timeFilter) {
|
||||
onTimeFilterChange?.(event.timeFilter);
|
||||
}
|
||||
onQueryGenerated(event.query);
|
||||
setPrompt("");
|
||||
setLastResult("success");
|
||||
// Keep thinking visible to show what happened
|
||||
} else {
|
||||
setError(event.error);
|
||||
setLastResult("error");
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[onQueryGenerated, onTimeFilterChange]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
submitQuery(prompt);
|
||||
},
|
||||
[prompt, submitQuery]
|
||||
);
|
||||
|
||||
// Auto-submit when autoSubmitPrompt or autoSubmitKey changes
|
||||
useEffect(() => {
|
||||
if (!autoSubmitPrompt || !autoSubmitPrompt.trim() || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const last = lastAutoSubmitRef.current;
|
||||
const isDifferent =
|
||||
last === null || autoSubmitPrompt !== last.prompt || autoSubmitKey !== last.key;
|
||||
|
||||
if (isDifferent) {
|
||||
lastAutoSubmitRef.current = { prompt: autoSubmitPrompt, key: autoSubmitKey };
|
||||
setPrompt(autoSubmitPrompt);
|
||||
submitQuery(autoSubmitPrompt);
|
||||
}
|
||||
}, [autoSubmitPrompt, autoSubmitKey, isLoading, submitQuery]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-hide error after delay
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
const timer = setTimeout(() => setError(null), 15000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* Gradient border wrapper like the schedules AI input */}
|
||||
<div
|
||||
className="overflow-hidden rounded-md p-px"
|
||||
style={{ background: "linear-gradient(to bottom right, #E543FF, #286399)" }}
|
||||
>
|
||||
<div className="overflow-hidden rounded-md bg-background-bright">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
name="prompt"
|
||||
placeholder={
|
||||
mode === "edit"
|
||||
? "e.g. add a filter for failed runs, change the limit to 50"
|
||||
: "e.g. show me failed runs from the last 7 days"
|
||||
}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
disabled={isLoading}
|
||||
rows={8}
|
||||
className="m-0 min-h-10 w-full resize-none border-0 bg-background-bright px-3 py-2.5 text-sm text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-text-dimmed focus:border-0 focus:outline-hidden focus:ring-0 focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && prompt.trim() && !isLoading) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 px-2 pb-2">
|
||||
{isLoading ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
disabled={true}
|
||||
LeadingIcon={Spinner}
|
||||
className="pl-2"
|
||||
iconSpacing="gap-1.5"
|
||||
>
|
||||
{mode === "edit" ? "Editing…" : "Generating…"}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
disabled={!prompt.trim()}
|
||||
LeadingIcon={PlusIcon}
|
||||
iconSpacing="gap-1.5"
|
||||
onClick={() => {
|
||||
setMode("new");
|
||||
submitQuery(prompt, "new");
|
||||
}}
|
||||
>
|
||||
New query
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
disabled={!prompt.trim() || !canEdit}
|
||||
LeadingIcon={PencilSquareIcon}
|
||||
className={cn(!canEdit && "opacity-50")}
|
||||
iconSpacing="gap-2"
|
||||
tooltip={!canEdit ? "Write a query first to enable editing" : undefined}
|
||||
onClick={() => {
|
||||
setMode("edit");
|
||||
submitQuery(prompt, "edit");
|
||||
}}
|
||||
>
|
||||
Edit query
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error">
|
||||
{error}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Thinking panel - stays visible after completion */}
|
||||
<AnimatePresence>
|
||||
{showThinking && thinking && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-1">
|
||||
<div className="rounded-b-lg border-x border-b border-grid-dimmed bg-background-dimmed p-3 pb-1">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
{isLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : lastResult === "success" ? (
|
||||
<CheckIcon className="size-4 text-success" />
|
||||
) : lastResult === "error" ? (
|
||||
<XMarkIcon className="size-4 text-error" />
|
||||
) : null}
|
||||
<span className="text-xs font-medium text-text-dimmed">
|
||||
{isLoading
|
||||
? "AI is thinking…"
|
||||
: lastResult === "success"
|
||||
? "Query generated"
|
||||
: lastResult === "error"
|
||||
? "Generation failed"
|
||||
: "AI response"}
|
||||
</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
setIsLoading(false);
|
||||
setShowThinking(false);
|
||||
setThinking("");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => {
|
||||
setShowThinking(false);
|
||||
setThinking("");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="streamdown-container max-h-96 overflow-y-auto text-xs text-text-dimmed scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<Suspense fallback={<p className="whitespace-pre-wrap">{thinking}</p>}>
|
||||
<StreamdownRenderer isAnimating={isLoading}>{thinking}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import { IconSortAscending, IconSortDescending } from "@tabler/icons-react";
|
||||
import { BarChart, CheckIcon, LineChart, Plus, XIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
type AggregationType,
|
||||
type ChartConfiguration,
|
||||
type SortDirection,
|
||||
} from "../metrics/QueryWidget";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
|
||||
import SegmentedControl from "../primitives/SegmentedControl";
|
||||
import { Select, SelectItem } from "../primitives/Select";
|
||||
import { Switch } from "../primitives/Switch";
|
||||
import { CHART_COLORS_BY_HUE, getSeriesColor } from "./chartColors";
|
||||
|
||||
export const defaultChartConfig: ChartConfiguration = {
|
||||
chartType: "bar",
|
||||
xAxisColumn: null,
|
||||
yAxisColumns: [],
|
||||
groupByColumn: null,
|
||||
stacked: false,
|
||||
sortByColumn: null,
|
||||
sortDirection: "asc",
|
||||
aggregation: "sum",
|
||||
seriesColors: {},
|
||||
};
|
||||
|
||||
interface ChartConfigPanelProps {
|
||||
columns: OutputColumnMetadata[];
|
||||
config: ChartConfiguration;
|
||||
onChange: (config: ChartConfiguration) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Type detection helpers
|
||||
function isNumericType(type: string): boolean {
|
||||
return (
|
||||
type.startsWith("Int") ||
|
||||
type.startsWith("UInt") ||
|
||||
type.startsWith("Float") ||
|
||||
type.startsWith("Decimal") ||
|
||||
type.startsWith("Nullable(Int") ||
|
||||
type.startsWith("Nullable(UInt") ||
|
||||
type.startsWith("Nullable(Float") ||
|
||||
type.startsWith("Nullable(Decimal")
|
||||
);
|
||||
}
|
||||
|
||||
function isDateTimeType(type: string): boolean {
|
||||
return (
|
||||
type === "DateTime" ||
|
||||
type === "DateTime64" ||
|
||||
type === "Date" ||
|
||||
type === "Date32" ||
|
||||
type.startsWith("DateTime64(") ||
|
||||
type.startsWith("Nullable(DateTime") ||
|
||||
type.startsWith("Nullable(Date")
|
||||
);
|
||||
}
|
||||
|
||||
function isStringType(type: string): boolean {
|
||||
return (
|
||||
type === "String" ||
|
||||
type === "LowCardinality(String)" ||
|
||||
type === "Nullable(String)" ||
|
||||
type.startsWith("Enum") ||
|
||||
type.startsWith("FixedString")
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartConfigPanel({ columns, config, onChange, className }: ChartConfigPanelProps) {
|
||||
// Categorize columns by type
|
||||
const { numericColumns, dateTimeColumns, categoricalColumns, allColumns } = useMemo(() => {
|
||||
const numeric: OutputColumnMetadata[] = [];
|
||||
const dateTime: OutputColumnMetadata[] = [];
|
||||
const categorical: OutputColumnMetadata[] = [];
|
||||
|
||||
for (const col of columns) {
|
||||
if (isNumericType(col.type)) {
|
||||
numeric.push(col);
|
||||
}
|
||||
if (isDateTimeType(col.type)) {
|
||||
dateTime.push(col);
|
||||
}
|
||||
if (isStringType(col.type) || isDateTimeType(col.type)) {
|
||||
categorical.push(col);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
numericColumns: numeric,
|
||||
dateTimeColumns: dateTime,
|
||||
categoricalColumns: categorical,
|
||||
allColumns: columns,
|
||||
};
|
||||
}, [columns]);
|
||||
|
||||
// Create a stable key from column names and types to detect actual changes
|
||||
const columnsKey = useMemo(() => columns.map((c) => `${c.name}:${c.type}`).join(","), [columns]);
|
||||
|
||||
// Use refs to access current config/onChange without adding them as dependencies
|
||||
const configRef = useRef(config);
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
configRef.current = config;
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
|
||||
// Auto-select defaults when columns change
|
||||
useEffect(() => {
|
||||
if (columns.length === 0) return;
|
||||
|
||||
const currentConfig = configRef.current;
|
||||
let needsUpdate = false;
|
||||
const updates: Partial<ChartConfiguration> = {};
|
||||
|
||||
// Auto-select X-axis (prefer datetime, then first categorical)
|
||||
if (!currentConfig.xAxisColumn) {
|
||||
const defaultX = dateTimeColumns[0] ?? categoricalColumns[0] ?? columns[0];
|
||||
if (defaultX) {
|
||||
updates.xAxisColumn = defaultX.name;
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-select Y-axis (first numeric column)
|
||||
if (currentConfig.yAxisColumns.length === 0 && numericColumns.length > 0) {
|
||||
updates.yAxisColumns = [numericColumns[0].name];
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
// Determine the effective x-axis column (either existing or newly selected)
|
||||
const effectiveXAxis = updates.xAxisColumn ?? currentConfig.xAxisColumn;
|
||||
|
||||
// Auto-set sort to x-axis ASC if it's a datetime column and no sort is configured
|
||||
if (
|
||||
effectiveXAxis &&
|
||||
!currentConfig.sortByColumn &&
|
||||
dateTimeColumns.some((col) => col.name === effectiveXAxis)
|
||||
) {
|
||||
updates.sortByColumn = effectiveXAxis;
|
||||
updates.sortDirection = "asc";
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
onChangeRef.current({ ...currentConfig, ...updates });
|
||||
}
|
||||
// Only re-run when the actual column structure changes, not on every config change.
|
||||
// columnsKey (a string) is stable when columns match, so this won't re-fire
|
||||
// unnecessarily when the same query is re-run with identical columns.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [columnsKey]);
|
||||
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<ChartConfiguration>) => {
|
||||
onChange({ ...config, ...updates });
|
||||
},
|
||||
[config, onChange]
|
||||
);
|
||||
|
||||
// X-axis options: prefer datetime and string columns at the top
|
||||
const xAxisOptions = useMemo(() => {
|
||||
const preferred = [
|
||||
...dateTimeColumns,
|
||||
...categoricalColumns.filter((c) => !isDateTimeType(c.type)),
|
||||
];
|
||||
const preferredNames = new Set(preferred.map((c) => c.name));
|
||||
const other = allColumns.filter((c) => !preferredNames.has(c.name));
|
||||
|
||||
const options: Array<{ value: string; label: string; type: string }> = [];
|
||||
|
||||
for (const col of preferred) {
|
||||
options.push({ value: col.name, label: col.name, type: col.type });
|
||||
}
|
||||
for (const col of other) {
|
||||
options.push({ value: col.name, label: col.name, type: col.type });
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [allColumns, dateTimeColumns, categoricalColumns]);
|
||||
|
||||
// Y-axis options: numeric columns only
|
||||
const yAxisOptions = useMemo(() => {
|
||||
return numericColumns.map((col) => ({
|
||||
value: col.name,
|
||||
label: col.name,
|
||||
type: col.type,
|
||||
}));
|
||||
}, [numericColumns]);
|
||||
|
||||
// Aggregation options
|
||||
const aggregationOptions = [
|
||||
{ value: "sum", label: "Sum" },
|
||||
{ value: "avg", label: "Average" },
|
||||
{ value: "count", label: "Count" },
|
||||
{ value: "min", label: "Min" },
|
||||
{ value: "max", label: "Max" },
|
||||
];
|
||||
|
||||
// Group by options: categorical columns (excluding selected X axis)
|
||||
const groupByOptions = useMemo(() => {
|
||||
const options = categoricalColumns
|
||||
.filter((col) => col.name !== config.xAxisColumn)
|
||||
.map((col) => ({
|
||||
value: col.name,
|
||||
label: col.name,
|
||||
type: col.type,
|
||||
}));
|
||||
|
||||
return [{ value: "__none__", label: "None", type: "" }, ...options];
|
||||
}, [categoricalColumns, config.xAxisColumn]);
|
||||
|
||||
// Sort by options: all columns
|
||||
const sortByOptions = useMemo(() => {
|
||||
const options = allColumns.map((col) => ({
|
||||
value: col.name,
|
||||
label: col.name,
|
||||
type: col.type,
|
||||
}));
|
||||
|
||||
return [{ value: "__none__", label: "None", type: "" }, ...options];
|
||||
}, [allColumns]);
|
||||
|
||||
if (columns.length === 0) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center p-4", className)}>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Run a query to configure the chart
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3 p-2", className)}>
|
||||
{/* Chart Type */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<ConfigField label="Type">
|
||||
<SegmentedControl
|
||||
name="chartType"
|
||||
value={config.chartType}
|
||||
variant="secondary/small"
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
<BarChart className="size-3" /> Bar
|
||||
</span>
|
||||
),
|
||||
value: "bar",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
<LineChart className="size-3" /> Line
|
||||
</span>
|
||||
),
|
||||
value: "line",
|
||||
},
|
||||
]}
|
||||
onChange={(value) => updateConfig({ chartType: value as "bar" | "line" })}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* X-Axis */}
|
||||
<ConfigField label="X-Axis">
|
||||
<Select
|
||||
value={config.xAxisColumn ?? ""}
|
||||
setValue={(value) => {
|
||||
const updates: Partial<ChartConfiguration> = { xAxisColumn: value || null };
|
||||
// Auto-set sort to x-axis ASC if selecting a datetime column
|
||||
if (value) {
|
||||
const selectedCol = columns.find((c) => c.name === value);
|
||||
if (selectedCol && isDateTimeType(selectedCol.type)) {
|
||||
updates.sortByColumn = value;
|
||||
updates.sortDirection = "asc";
|
||||
}
|
||||
}
|
||||
updateConfig(updates);
|
||||
}}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select column"
|
||||
items={xAxisOptions}
|
||||
dropdownIcon
|
||||
className="min-w-[140px]"
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{item.label}</span>
|
||||
<TypeBadge type={item.type} />
|
||||
</span>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
</ConfigField>
|
||||
|
||||
{/* Y-Axis / Series */}
|
||||
<ConfigField label={config.yAxisColumns.length > 1 ? "Series" : "Y-Axis"}>
|
||||
{yAxisOptions.length === 0 ? (
|
||||
<span className="text-xs text-text-dimmed">No numeric columns</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{/* Always show at least one dropdown, even if yAxisColumns is empty */}
|
||||
{(config.yAxisColumns.length === 0 ? [""] : config.yAxisColumns).map((col, index) => (
|
||||
<div key={index} className="flex items-center gap-1">
|
||||
{col && !config.groupByColumn && (
|
||||
<SeriesColorPicker
|
||||
color={config.seriesColors?.[col] ?? getSeriesColor(index)}
|
||||
onColorChange={(color) => {
|
||||
updateConfig({
|
||||
seriesColors: { ...config.seriesColors, [col]: color },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
value={col}
|
||||
setValue={(value) => {
|
||||
const newColumns = [...config.yAxisColumns];
|
||||
const updates: Partial<ChartConfiguration> = {};
|
||||
if (value) {
|
||||
// If this is a new slot (empty string), add it
|
||||
if (index >= config.yAxisColumns.length) {
|
||||
newColumns.push(value);
|
||||
} else {
|
||||
// If the column name changed, migrate the color
|
||||
const oldCol = newColumns[index];
|
||||
if (oldCol && oldCol !== value && config.seriesColors?.[oldCol]) {
|
||||
const newSeriesColors = { ...config.seriesColors };
|
||||
newSeriesColors[value] = newSeriesColors[oldCol];
|
||||
delete newSeriesColors[oldCol];
|
||||
updates.seriesColors = newSeriesColors;
|
||||
}
|
||||
newColumns[index] = value;
|
||||
}
|
||||
} else if (index < config.yAxisColumns.length) {
|
||||
newColumns.splice(index, 1);
|
||||
}
|
||||
updateConfig({ ...updates, yAxisColumns: newColumns });
|
||||
}}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select column"
|
||||
items={yAxisOptions.filter(
|
||||
(opt) => opt.value === col || !config.yAxisColumns.includes(opt.value)
|
||||
)}
|
||||
dropdownIcon
|
||||
className="min-w-[140px] flex-1"
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{item.label}</span>
|
||||
<TypeBadge type={item.type} />
|
||||
</span>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
|
||||
{index > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const removedCol = config.yAxisColumns[index];
|
||||
const newColumns = config.yAxisColumns.filter((_, i) => i !== index);
|
||||
const updates: Partial<ChartConfiguration> = { yAxisColumns: newColumns };
|
||||
// Clean up the color entry for the removed series
|
||||
if (removedCol && config.seriesColors?.[removedCol]) {
|
||||
const newSeriesColors = { ...config.seriesColors };
|
||||
delete newSeriesColors[removedCol];
|
||||
updates.seriesColors = newSeriesColors;
|
||||
}
|
||||
updateConfig(updates);
|
||||
}}
|
||||
className="rounded p-1 text-text-dimmed hover:bg-background-raised hover:text-text-bright"
|
||||
title="Remove series"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add another series button - only show when we have at least one series and not grouped */}
|
||||
{config.yAxisColumns.length > 0 &&
|
||||
config.yAxisColumns.length < yAxisOptions.length &&
|
||||
!config.groupByColumn && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const availableColumns = yAxisOptions.filter(
|
||||
(opt) => !config.yAxisColumns.includes(opt.value)
|
||||
);
|
||||
if (availableColumns.length > 0) {
|
||||
updateConfig({
|
||||
yAxisColumns: [...config.yAxisColumns, availableColumns[0].value],
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 self-start rounded px-1 py-0.5 text-xs text-text-dimmed hover:bg-background-raised hover:text-text-bright"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add series
|
||||
</button>
|
||||
)}
|
||||
|
||||
{config.groupByColumn && config.yAxisColumns.length === 1 && (
|
||||
<span className="text-xxs text-text-dimmed">
|
||||
Remove group by to add multiple series
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ConfigField>
|
||||
|
||||
{/* Aggregation */}
|
||||
<ConfigField label="Aggregation">
|
||||
<Select
|
||||
value={config.aggregation}
|
||||
setValue={(value) => updateConfig({ aggregation: value as AggregationType })}
|
||||
variant="tertiary/small"
|
||||
items={aggregationOptions}
|
||||
dropdownIcon
|
||||
className="min-w-[100px]"
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
</ConfigField>
|
||||
|
||||
{/* Group By - disabled when multiple series are selected */}
|
||||
<ConfigField label="Group by">
|
||||
{config.yAxisColumns.length > 1 ? (
|
||||
<span className="text-xs text-text-dimmed">Not available with multiple series</span>
|
||||
) : (
|
||||
<Select
|
||||
value={config.groupByColumn ?? "__none__"}
|
||||
setValue={(value) =>
|
||||
updateConfig({ groupByColumn: value === "__none__" ? null : value })
|
||||
}
|
||||
variant="tertiary/small"
|
||||
placeholder="None"
|
||||
items={groupByOptions}
|
||||
dropdownIcon
|
||||
className="min-w-[140px]"
|
||||
text={(t) => (t === "__none__" ? "None" : t)}
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{item.label}</span>
|
||||
{item.type && <TypeBadge type={item.type} />}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
)}
|
||||
</ConfigField>
|
||||
|
||||
{/* Stacked toggle (when grouped or multiple series) */}
|
||||
{(config.groupByColumn || config.yAxisColumns.length > 1) && (
|
||||
<ConfigField label={config.groupByColumn ? "Stack groups" : "Stack series"}>
|
||||
<Switch
|
||||
variant="medium"
|
||||
checked={config.stacked}
|
||||
onCheckedChange={(checked) => updateConfig({ stacked: checked })}
|
||||
/>
|
||||
</ConfigField>
|
||||
)}
|
||||
|
||||
{/* Order By */}
|
||||
<ConfigField label="Order by">
|
||||
<Select
|
||||
value={config.sortByColumn ?? "__none__"}
|
||||
setValue={(value) =>
|
||||
updateConfig({ sortByColumn: value === "__none__" ? null : value })
|
||||
}
|
||||
variant="tertiary/small"
|
||||
placeholder="None"
|
||||
items={sortByOptions}
|
||||
dropdownIcon
|
||||
className="min-w-[140px]"
|
||||
text={(t) => (t === "__none__" ? "None" : t)}
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{item.label}</span>
|
||||
{item.type && <TypeBadge type={item.type} />}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
</ConfigField>
|
||||
|
||||
{/* Sort Direction (only when sorting) */}
|
||||
{config.sortByColumn && (
|
||||
<ConfigField label="Sort direction">
|
||||
<SegmentedControl
|
||||
name="sortDirection"
|
||||
value={config.sortDirection}
|
||||
variant="secondary/small"
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
<IconSortAscending className="size-3" /> Asc
|
||||
</span>
|
||||
),
|
||||
value: "asc",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
<IconSortDescending className="size-3" /> Desc
|
||||
</span>
|
||||
),
|
||||
value: "desc",
|
||||
},
|
||||
]}
|
||||
onChange={(value) => updateConfig({ sortDirection: value as SortDirection })}
|
||||
/>
|
||||
</ConfigField>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigField({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{label && <span className="text-xs text-text-bright">{label}</span>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeriesColorPicker({
|
||||
color,
|
||||
onColorChange,
|
||||
}: {
|
||||
color: string;
|
||||
onColorChange: (color: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded p-0.5 hover:bg-background-raised"
|
||||
title="Change series color"
|
||||
>
|
||||
<span
|
||||
className="block h-4 w-4 rounded-full border border-white/30"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-auto p-2">
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{CHART_COLORS_BY_HUE.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onColorChange(c);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="group/swatch flex h-6 w-6 items-center justify-center rounded-full border border-white/30"
|
||||
style={{ backgroundColor: c }}
|
||||
title={c}
|
||||
>
|
||||
{c === color && <CheckIcon className="h-3.5 w-3.5 text-white drop-shadow-md" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: string }) {
|
||||
// Simplify type for display
|
||||
let displayType = type;
|
||||
if (type.startsWith("Nullable(")) {
|
||||
displayType = type.slice(9, -1) + "?";
|
||||
}
|
||||
if (type.startsWith("LowCardinality(")) {
|
||||
displayType = type.slice(15, -1);
|
||||
}
|
||||
|
||||
// Shorten long type names
|
||||
if (displayType.length > 12) {
|
||||
displayType = displayType.slice(0, 10) + "…";
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="rounded bg-background-hover px-1 py-0.5 font-mono text-xxs text-text-dimmed">
|
||||
{displayType}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
import { ArrowsPointingOutIcon } from "@heroicons/react/20/solid";
|
||||
import { Clipboard, ClipboardCheck } from "lucide-react";
|
||||
import type { Language, PrismTheme } from "prism-react-renderer";
|
||||
import { Highlight, Prism } from "prism-react-renderer";
|
||||
import type { ReactNode } from "react";
|
||||
import { forwardRef, useCallback, useEffect, useState } from "react";
|
||||
import { TextWrapIcon } from "~/assets/icons/TextWrapIcon";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { highlightSearchText } from "~/utils/logUtils";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../primitives/Dialog";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
import { TextInlineIcon } from "~/assets/icons/TextInlineIcon";
|
||||
|
||||
//This is a fork of https://github.com/mantinedev/mantine/blob/master/src/mantine-prism/src/Prism/Prism.tsx
|
||||
//it didn't support highlighting lines by dimming the rest of the code, or animations on the highlighting
|
||||
|
||||
async function setup() {
|
||||
(typeof global !== "undefined" ? global : window).Prism = Prism;
|
||||
//@ts-ignore
|
||||
await import("prismjs/components/prism-json");
|
||||
//@ts-ignore
|
||||
await import("prismjs/components/prism-typescript");
|
||||
//@ts-ignore
|
||||
await import("prismjs/components/prism-sql.js");
|
||||
}
|
||||
setup();
|
||||
|
||||
type CodeBlockProps = {
|
||||
/** Code which will be highlighted */
|
||||
code: string;
|
||||
|
||||
/** Programming language that should be highlighted */
|
||||
language?: Language;
|
||||
|
||||
/** Show copy to clipboard button */
|
||||
showCopyButton?: boolean;
|
||||
|
||||
/** Show text wrapping button */
|
||||
showTextWrapping?: boolean;
|
||||
|
||||
/** Display line numbers */
|
||||
showLineNumbers?: boolean;
|
||||
|
||||
/** Highlight line at given line number with color from theme.colors */
|
||||
highlightedRanges?: [number, number][];
|
||||
|
||||
/** Add/override classes on the overall element */
|
||||
className?: string;
|
||||
|
||||
/** Add/override code theme */
|
||||
theme?: PrismTheme;
|
||||
|
||||
/** Max lines */
|
||||
maxLines?: number;
|
||||
|
||||
/** Whether to show the chrome, if you provide a string it will be used as the title, */
|
||||
showChrome?: boolean;
|
||||
|
||||
/** filename */
|
||||
fileName?: string;
|
||||
|
||||
/** title text for the Title row */
|
||||
rowTitle?: ReactNode;
|
||||
|
||||
/** Whether to show the open in modal button */
|
||||
showOpenInModal?: boolean;
|
||||
|
||||
/** Search term to highlight in the code */
|
||||
searchTerm?: string;
|
||||
|
||||
/** Whether to wrap the code */
|
||||
wrap?: boolean;
|
||||
};
|
||||
|
||||
const dimAmount = 0.5;
|
||||
const extraLinesWhenClipping = 0.35;
|
||||
|
||||
const defaultTheme: PrismTheme = {
|
||||
plain: {
|
||||
color: "var(--color-code-constant)",
|
||||
backgroundColor: "rgba(0, 0, 0, 0)",
|
||||
},
|
||||
styles: [
|
||||
{
|
||||
types: ["comment", "prolog", "doctype", "cdata"],
|
||||
style: {
|
||||
color: "var(--color-code-muted)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["punctuation"],
|
||||
style: {
|
||||
color: "var(--color-code-foreground)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["property", "tag", "constant", "symbol", "deleted"],
|
||||
style: {
|
||||
color: "var(--color-code-language)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["boolean", "number"],
|
||||
style: {
|
||||
color: "var(--color-code-builtin)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["selector", "attr-name", "string", "char", "builtin", "inserted"],
|
||||
style: {
|
||||
color: "var(--color-code-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["operator", "entity", "url"],
|
||||
style: {
|
||||
color: "var(--color-code-plain)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["variable"],
|
||||
style: {
|
||||
color: "var(--color-code-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["atrule", "attr-value", "keyword"],
|
||||
style: {
|
||||
color: "var(--color-code-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["function", "class-name"],
|
||||
style: {
|
||||
color: "var(--color-code-function)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["regex"],
|
||||
style: {
|
||||
color: "var(--color-code-regexp)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["important", "bold"],
|
||||
style: {
|
||||
fontWeight: "bold",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["italic"],
|
||||
style: {
|
||||
fontStyle: "italic",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["namespace"],
|
||||
style: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["deleted"],
|
||||
style: {
|
||||
color: "var(--color-code-deleted)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["char"],
|
||||
style: {
|
||||
color: "var(--color-code-number)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["tag"],
|
||||
style: {
|
||||
color: "var(--color-code-escape)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["keyword.operator"],
|
||||
style: {
|
||||
color: "var(--color-code-storage)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["meta.template.expression"],
|
||||
style: {
|
||||
color: "var(--color-code-plain)",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
(
|
||||
{
|
||||
showCopyButton = true,
|
||||
showTextWrapping = false,
|
||||
showLineNumbers = true,
|
||||
showOpenInModal = true,
|
||||
highlightedRanges,
|
||||
code,
|
||||
className,
|
||||
language = "typescript",
|
||||
theme = defaultTheme,
|
||||
maxLines,
|
||||
showChrome = false,
|
||||
fileName,
|
||||
rowTitle,
|
||||
searchTerm,
|
||||
wrap = false,
|
||||
...props
|
||||
}: CodeBlockProps,
|
||||
ref
|
||||
) => {
|
||||
const [mouseOver, setMouseOver] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [modalCopied, setModalCopied] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isWrapped, setIsWrapped] = useState(wrap);
|
||||
|
||||
const onCopied = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1500);
|
||||
},
|
||||
[code]
|
||||
);
|
||||
|
||||
const onModalCopied = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
navigator.clipboard.writeText(code);
|
||||
setModalCopied(true);
|
||||
setTimeout(() => {
|
||||
setModalCopied(false);
|
||||
}, 1500);
|
||||
},
|
||||
[code]
|
||||
);
|
||||
|
||||
code = code?.trim() ?? "";
|
||||
const lineCount = code.split("\n").length;
|
||||
const maxLineWidth = lineCount.toString().length;
|
||||
let maxHeight: string | undefined = undefined;
|
||||
if (maxLines && lineCount > maxLines) {
|
||||
maxHeight = `calc(${(maxLines + extraLinesWhenClipping) * 0.75 * 1.625}rem + 1.5rem )`;
|
||||
}
|
||||
|
||||
const highlightLines = highlightedRanges?.flatMap(([start, end]) =>
|
||||
Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
||||
);
|
||||
|
||||
// if there are more than 1000 lines, don't highlight
|
||||
const shouldHighlight = lineCount <= 1000;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col overflow-hidden rounded-md border border-grid-bright",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: theme.plain.backgroundColor,
|
||||
}}
|
||||
ref={ref}
|
||||
{...props}
|
||||
translate="no"
|
||||
>
|
||||
{showChrome && <Chrome title={fileName} />}
|
||||
{rowTitle && <TitleRow title={rowTitle} />}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-3 top-2.5 z-50 flex gap-3",
|
||||
showChrome ? "right-1.5 top-1.5" : "top-2.5"
|
||||
)}
|
||||
>
|
||||
{showTextWrapping && (
|
||||
<TooltipProvider>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger
|
||||
onClick={() => setIsWrapped(!isWrapped)}
|
||||
className="transition-colors focus-custom hover:cursor-pointer hover:text-text-bright"
|
||||
>
|
||||
{isWrapped ? (
|
||||
<TextInlineIcon className="size-4" />
|
||||
) : (
|
||||
<TextWrapIcon className="size-4" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
{isWrapped ? "Unwrap" : "Wrap"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{showCopyButton && (
|
||||
<TooltipProvider>
|
||||
<Tooltip open={copied || mouseOver} disableHoverableContent>
|
||||
<TooltipTrigger
|
||||
onClick={onCopied}
|
||||
onMouseEnter={() => setMouseOver(true)}
|
||||
onMouseLeave={() => setMouseOver(false)}
|
||||
className={cn(
|
||||
"transition-colors duration-100 focus-custom hover:cursor-pointer",
|
||||
copied ? "text-success" : "text-text-dimmed hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<ClipboardCheck className="size-4" />
|
||||
) : (
|
||||
<Clipboard className="size-4" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{showOpenInModal && (
|
||||
<TooltipProvider>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger onClick={() => setIsModalOpen(true)}>
|
||||
<ArrowsPointingOutIcon className="size-4 transition-colors hover:text-text-bright" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
Expand
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shouldHighlight ? (
|
||||
<HighlightCode
|
||||
theme={theme}
|
||||
code={code}
|
||||
language={language}
|
||||
showLineNumbers={showLineNumbers}
|
||||
highlightLines={highlightLines}
|
||||
maxLineWidth={maxLineWidth}
|
||||
className="px-2 py-3"
|
||||
preClassName="text-xs"
|
||||
isWrapped={isWrapped}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
dir="ltr"
|
||||
className={cn(
|
||||
"min-h-0 flex-1 px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control",
|
||||
"overflow-auto"
|
||||
)}
|
||||
style={{
|
||||
maxHeight,
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
className={cn(
|
||||
"relative mr-2 p-2 font-mono text-xs leading-relaxed",
|
||||
isWrapped && "[&_span]:whitespace-pre-wrap [&_span]:wrap-break-word"
|
||||
)}
|
||||
dir="ltr"
|
||||
>
|
||||
{highlightSearchText(code, searchTerm)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="flex flex-col gap-0 p-0 pt-[2.9rem] sm:h-[80vh] sm:max-h-[80vh] sm:max-w-[80vw]">
|
||||
<DialogHeader className="h-fit">
|
||||
<DialogTitle className="absolute left-3.5 top-2.5">
|
||||
{fileName && fileName}
|
||||
{rowTitle && rowTitle}
|
||||
</DialogTitle>
|
||||
<Button
|
||||
variant="tertiary/small"
|
||||
onClick={onModalCopied}
|
||||
className="absolute right-4 top-16 z-50"
|
||||
LeadingIcon={modalCopied ? undefined : Clipboard}
|
||||
leadingIconClassName="size-3 -ml-1"
|
||||
>
|
||||
{modalCopied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</DialogHeader>
|
||||
|
||||
{shouldHighlight ? (
|
||||
<HighlightCode
|
||||
theme={theme}
|
||||
code={code}
|
||||
language={language}
|
||||
showLineNumbers={showLineNumbers}
|
||||
highlightLines={highlightLines}
|
||||
maxLineWidth={maxLineWidth}
|
||||
className="min-h-full"
|
||||
preClassName="text-sm"
|
||||
isWrapped={isWrapped}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
dir="ltr"
|
||||
className="overflow-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
>
|
||||
<pre className="relative mr-2 p-2 font-mono text-base leading-relaxed" dir="ltr">
|
||||
{highlightSearchText(code, searchTerm)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CodeBlock.displayName = "CodeBlock";
|
||||
|
||||
function Chrome({ title }: { title?: string }) {
|
||||
return (
|
||||
<div className="grid h-7 grid-cols-[100px_auto_100px] border-b border-background-bright bg-background-deep">
|
||||
<div className="ml-2 flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-background-raised" />
|
||||
<div className="h-3 w-3 rounded-full bg-background-raised" />
|
||||
<div className="h-3 w-3 rounded-full bg-background-raised" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className={cn("rounded-sm px-3 py-0.5 text-xs text-text-faint")}>{title}</div>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TitleRow({ title }: { title: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3">
|
||||
<Paragraph variant="small/bright" className="w-full border-b border-grid-dimmed py-2">
|
||||
{title}
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HighlightCodeProps = {
|
||||
theme: PrismTheme;
|
||||
code: string;
|
||||
language: Language;
|
||||
showLineNumbers: boolean;
|
||||
highlightLines?: number[];
|
||||
maxLineWidth?: number;
|
||||
className?: string;
|
||||
preClassName?: string;
|
||||
isWrapped: boolean;
|
||||
searchTerm?: string;
|
||||
};
|
||||
|
||||
function HighlightCode({
|
||||
theme,
|
||||
code,
|
||||
language,
|
||||
showLineNumbers,
|
||||
highlightLines,
|
||||
maxLineWidth,
|
||||
className,
|
||||
preClassName,
|
||||
isWrapped,
|
||||
searchTerm,
|
||||
}: HighlightCodeProps) {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
//@ts-ignore
|
||||
import("prismjs/components/prism-json"),
|
||||
//@ts-ignore
|
||||
import("prismjs/components/prism-typescript"),
|
||||
//@ts-ignore
|
||||
import("prismjs/components/prism-sql.js"),
|
||||
]).then(() => setIsLoaded(true));
|
||||
}, []);
|
||||
|
||||
const containerClasses = cn(
|
||||
"min-h-0 flex-1 px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control",
|
||||
!isWrapped && "overflow-auto",
|
||||
isWrapped && "overflow-auto",
|
||||
className
|
||||
);
|
||||
|
||||
const preClasses = cn(
|
||||
"relative mr-2 font-mono leading-relaxed",
|
||||
preClassName,
|
||||
isWrapped && "[&_span]:whitespace-pre-wrap [&_span]:wrap-break-word"
|
||||
);
|
||||
|
||||
if (!isLoaded) {
|
||||
return (
|
||||
<div dir="ltr" className={containerClasses}>
|
||||
<pre className={preClasses}>{code}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Highlight theme={theme} code={code} language={language}>
|
||||
{({
|
||||
className: inheritedClassName,
|
||||
style: inheritedStyle,
|
||||
tokens,
|
||||
getLineProps,
|
||||
getTokenProps,
|
||||
}) => (
|
||||
<div dir="ltr" className={containerClasses}>
|
||||
<pre className={cn(preClasses, inheritedClassName)} style={inheritedStyle} dir="ltr">
|
||||
{tokens
|
||||
.map((line, index) => {
|
||||
if (index === tokens.length - 1 && line.length === 1 && line[0].content === "\n") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lineNumber = index + 1;
|
||||
const lineProps = getLineProps({ line });
|
||||
|
||||
let hasAnyHighlights = highlightLines ? highlightLines.length > 0 : false;
|
||||
|
||||
let shouldDim = hasAnyHighlights;
|
||||
if (hasAnyHighlights && highlightLines?.includes(lineNumber)) {
|
||||
shouldDim = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lineNumber}
|
||||
{...lineProps}
|
||||
className={cn(
|
||||
"flex w-full justify-start transition-opacity duration-500",
|
||||
lineProps.className,
|
||||
isWrapped && "flex-wrap"
|
||||
)}
|
||||
style={{
|
||||
opacity: shouldDim ? dimAmount : undefined,
|
||||
...lineProps.style,
|
||||
}}
|
||||
>
|
||||
{showLineNumbers && (
|
||||
<div
|
||||
className={cn(
|
||||
"mr-2 flex-none select-none text-right text-text-faint transition-opacity duration-500",
|
||||
isWrapped && "sticky left-0"
|
||||
)}
|
||||
style={{
|
||||
width: `calc(8 * ${(maxLineWidth as number) / 16}rem)`,
|
||||
}}
|
||||
>
|
||||
{lineNumber}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
{line.map((token, key) => {
|
||||
const tokenProps = getTokenProps({ token });
|
||||
|
||||
// Highlight search term matches in token
|
||||
const content = highlightSearchText(token.content, searchTerm);
|
||||
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
{...tokenProps}
|
||||
style={{
|
||||
color: tokenProps?.style?.color as string,
|
||||
...tokenProps.style,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="w-4 flex-none" />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
.filter(Boolean)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</Highlight>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const inlineCode =
|
||||
"px-1 py-0.5 rounded border border-grid-bright bg-background-bright text-text-bright font-mono text-wrap";
|
||||
|
||||
const variants = {
|
||||
"extra-extra-small": "text-xxs",
|
||||
"extra-small": "text-xs",
|
||||
small: "text-sm",
|
||||
base: "text-base",
|
||||
};
|
||||
|
||||
export type InlineCodeVariant = keyof typeof variants;
|
||||
|
||||
type InlineCodeProps = {
|
||||
children: React.ReactNode;
|
||||
variant?: InlineCodeVariant;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function InlineCode({ variant = "small", children, className }: InlineCodeProps) {
|
||||
return <code className={cn(inlineCode, variants[variant], className)}>{children}</code>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
ClientTabs,
|
||||
ClientTabsList,
|
||||
ClientTabsTrigger,
|
||||
ClientTabsContent,
|
||||
} from "../primitives/ClientTabs";
|
||||
import { ClipboardField } from "../primitives/ClipboardField";
|
||||
|
||||
type InstallPackagesProps = {
|
||||
packages: string[];
|
||||
};
|
||||
|
||||
export function InstallPackages({ packages }: InstallPackagesProps) {
|
||||
return (
|
||||
<ClientTabs defaultValue="npm">
|
||||
<ClientTabsList>
|
||||
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"yarn"}>yarn</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
<ClientTabsContent value={"npm"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
value={`npm install ${packages.join(" ")}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"pnpm"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
value={`pnpm install ${packages.join(" ")}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"yarn"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
value={`yarn add ${packages.join(" ")}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { json as jsonLang, jsonParseLinter } from "@codemirror/lang-json";
|
||||
import type { EditorView, ViewUpdate } from "@codemirror/view";
|
||||
import { CheckIcon, ClipboardIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import type { ReactCodeMirrorProps, UseCodeMirror } from "@uiw/react-codemirror";
|
||||
import { useCodeMirror } from "@uiw/react-codemirror";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { getEditorSetup } from "./codeMirrorSetup";
|
||||
import { darkTheme } from "./codeMirrorTheme";
|
||||
import { linter, lintGutter, type Diagnostic } from "@codemirror/lint";
|
||||
|
||||
export interface JSONEditorProps extends Omit<ReactCodeMirrorProps, "onBlur"> {
|
||||
defaultValue?: string;
|
||||
language?: "json";
|
||||
readOnly?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
onUpdate?: (update: ViewUpdate) => void;
|
||||
onBlur?: (code: string) => void;
|
||||
showCopyButton?: boolean;
|
||||
showClearButton?: boolean;
|
||||
linterEnabled?: boolean;
|
||||
allowEmpty?: boolean;
|
||||
additionalActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const languages = {
|
||||
json: jsonLang,
|
||||
};
|
||||
|
||||
function emptyAwareJsonLinter() {
|
||||
return (view: EditorView): Diagnostic[] => {
|
||||
const content = view.state.doc.toString().trim();
|
||||
|
||||
// return no errors if content is empty
|
||||
if (!content) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return jsonParseLinter()(view);
|
||||
};
|
||||
}
|
||||
|
||||
type JSONEditorDefaultProps = Partial<JSONEditorProps>;
|
||||
|
||||
const defaultProps: JSONEditorDefaultProps = {
|
||||
language: "json",
|
||||
readOnly: true,
|
||||
basicSetup: false,
|
||||
linterEnabled: true,
|
||||
allowEmpty: true,
|
||||
};
|
||||
|
||||
export function JSONEditor(opts: JSONEditorProps) {
|
||||
const {
|
||||
defaultValue = "",
|
||||
language,
|
||||
readOnly,
|
||||
onChange,
|
||||
onUpdate,
|
||||
onBlur,
|
||||
basicSetup,
|
||||
autoFocus,
|
||||
showCopyButton = true,
|
||||
showClearButton = true,
|
||||
linterEnabled,
|
||||
allowEmpty,
|
||||
additionalActions,
|
||||
} = {
|
||||
...defaultProps,
|
||||
...opts,
|
||||
};
|
||||
|
||||
const extensions = getEditorSetup();
|
||||
|
||||
if (!language) throw new Error("language is required");
|
||||
const languageExtension = languages[language];
|
||||
|
||||
extensions.push(languageExtension());
|
||||
|
||||
if (linterEnabled) {
|
||||
extensions.push(lintGutter());
|
||||
|
||||
switch (language) {
|
||||
case "json": {
|
||||
extensions.push(allowEmpty ? linter(emptyAwareJsonLinter()) : linter(jsonParseLinter()));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
language satisfies never;
|
||||
}
|
||||
}
|
||||
|
||||
const editor = useRef<HTMLDivElement>(null);
|
||||
const settings: Omit<UseCodeMirror, "onBlur"> = {
|
||||
...opts,
|
||||
container: editor.current,
|
||||
extensions,
|
||||
editable: !readOnly,
|
||||
contentEditable: !readOnly,
|
||||
value: defaultValue,
|
||||
autoFocus,
|
||||
theme: darkTheme(),
|
||||
indentWithTab: false,
|
||||
basicSetup,
|
||||
onChange,
|
||||
onUpdate,
|
||||
};
|
||||
const { setContainer, view } = useCodeMirror(settings);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor.current) {
|
||||
setContainer(editor.current);
|
||||
}
|
||||
}, [setContainer]);
|
||||
|
||||
//if the defaultValue changes update the editor
|
||||
useEffect(() => {
|
||||
if (view !== undefined) {
|
||||
if (view.state.doc.toString() === defaultValue) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: defaultValue },
|
||||
});
|
||||
}
|
||||
}, [defaultValue, view]);
|
||||
|
||||
const clear = () => {
|
||||
if (view === undefined) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: undefined },
|
||||
});
|
||||
onChange?.("");
|
||||
};
|
||||
|
||||
const copy = useCallback(() => {
|
||||
if (view === undefined) return;
|
||||
navigator.clipboard.writeText(view.state.doc.toString());
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1500);
|
||||
}, [view]);
|
||||
|
||||
const showButtons = showClearButton || showCopyButton;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid",
|
||||
showButtons ? "grid-rows-[2.5rem_1fr]" : "grid-rows-[1fr]",
|
||||
opts.className
|
||||
)}
|
||||
>
|
||||
{showButtons && (
|
||||
<div className="mx-3 flex items-center justify-end gap-2 border-b border-grid-dimmed">
|
||||
{additionalActions && additionalActions}
|
||||
{showClearButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/small"
|
||||
TrailingIcon={TrashIcon}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clear();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
{showCopyButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/small"
|
||||
TrailingIcon={copied ? CheckIcon : ClipboardIcon}
|
||||
trailingIconClassName={
|
||||
copied ? "text-green-500 group-hover:text-green-500" : undefined
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
copy();
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="w-full overflow-auto"
|
||||
ref={editor}
|
||||
onBlur={() => {
|
||||
if (!onBlur) return;
|
||||
onBlur(editor.current?.textContent ?? "");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
import { lazy } from "react";
|
||||
import type { CodeHighlighterPlugin } from "streamdown";
|
||||
|
||||
export const StreamdownRenderer = lazy(() =>
|
||||
Promise.all([import("streamdown"), import("@streamdown/code"), import("./shikiTheme")]).then(
|
||||
([{ Streamdown }, { createCodePlugin }, { triggerDarkTheme }]) => {
|
||||
// Type assertion needed: @streamdown/code and streamdown resolve different shiki
|
||||
// versions under pnpm, causing structurally-identical CodeHighlighterPlugin types
|
||||
// to be considered incompatible (different BundledLanguage string unions).
|
||||
const codePlugin = createCodePlugin({
|
||||
themes: [triggerDarkTheme, triggerDarkTheme],
|
||||
}) as unknown as CodeHighlighterPlugin;
|
||||
|
||||
return {
|
||||
default: ({
|
||||
children,
|
||||
isAnimating = false,
|
||||
}: {
|
||||
children: string;
|
||||
isAnimating?: boolean;
|
||||
}) => (
|
||||
<Streamdown
|
||||
isAnimating={isAnimating}
|
||||
plugins={{ code: codePlugin }}
|
||||
controls={{ code: { copy: false, download: false } }}
|
||||
linkSafety={{ enabled: false }}
|
||||
>
|
||||
{children}
|
||||
</Streamdown>
|
||||
),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,387 @@
|
||||
import { autocompletion, startCompletion } from "@codemirror/autocomplete";
|
||||
import { sql, StandardSQL } from "@codemirror/lang-sql";
|
||||
import { linter, lintGutter } from "@codemirror/lint";
|
||||
import type { ViewUpdate } from "@codemirror/view";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import { CheckIcon, ClipboardIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import type { TableSchema } from "@internal/tsql";
|
||||
import {
|
||||
type ReactCodeMirrorProps,
|
||||
type UseCodeMirror,
|
||||
useCodeMirror,
|
||||
} from "@uiw/react-codemirror";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { format as formatSQL } from "sql-formatter";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { getEditorSetup } from "./codeMirrorSetup";
|
||||
import { darkTheme } from "./codeMirrorTheme";
|
||||
import { createTSQLCompletion } from "./tsql/tsqlCompletion";
|
||||
import { createTSQLLinter } from "./tsql/tsqlLinter";
|
||||
|
||||
export interface TSQLEditorProps extends Omit<ReactCodeMirrorProps, "onBlur"> {
|
||||
/** Initial value for the editor */
|
||||
defaultValue?: string;
|
||||
/** Whether the editor is read-only */
|
||||
readOnly?: boolean;
|
||||
/** Called when the editor content changes */
|
||||
onChange?: (value: string) => void;
|
||||
/** Called when the editor state updates */
|
||||
onUpdate?: (update: ViewUpdate) => void;
|
||||
/** Called when the editor loses focus */
|
||||
onBlur?: (code: string) => void;
|
||||
/** Schema for table/column autocompletion */
|
||||
schema?: TableSchema[];
|
||||
/** Show copy button */
|
||||
showCopyButton?: boolean;
|
||||
/** Show clear button */
|
||||
showClearButton?: boolean;
|
||||
/** Show format button */
|
||||
showFormatButton?: boolean;
|
||||
/** Enable linting (syntax checking) */
|
||||
linterEnabled?: boolean;
|
||||
/** Placeholder text when empty */
|
||||
placeholder?: string;
|
||||
/** Additional actions to show in the toolbar */
|
||||
additionalActions?: React.ReactNode;
|
||||
/** Minimum height of the editor */
|
||||
minHeight?: string;
|
||||
}
|
||||
|
||||
type TSQLEditorDefaultProps = Partial<TSQLEditorProps>;
|
||||
|
||||
const defaultProps: TSQLEditorDefaultProps = {
|
||||
readOnly: false,
|
||||
basicSetup: false,
|
||||
linterEnabled: true,
|
||||
showCopyButton: true,
|
||||
showClearButton: false,
|
||||
showFormatButton: true,
|
||||
schema: [],
|
||||
};
|
||||
|
||||
// Toggle comment on current line or selected lines with -- comment symbol
|
||||
const toggleLineComment = (view: EditorView): boolean => {
|
||||
const { from, to } = view.state.selection.main;
|
||||
const startLine = view.state.doc.lineAt(from);
|
||||
// When `to` is exactly at the start of a line and there's an actual selection,
|
||||
// the caret sits before that line — so exclude it by stepping back one position.
|
||||
const adjustedTo = to > from && view.state.doc.lineAt(to).from === to ? to - 1 : to;
|
||||
const endLine = view.state.doc.lineAt(adjustedTo);
|
||||
|
||||
// Collect all lines in the selection
|
||||
const lines: { from: number; to: number; text: string }[] = [];
|
||||
for (let i = startLine.number; i <= endLine.number; i++) {
|
||||
const line = view.state.doc.line(i);
|
||||
lines.push({ from: line.from, to: line.to, text: line.text });
|
||||
}
|
||||
|
||||
// Determine action: if all non-empty lines are commented, uncomment; otherwise comment
|
||||
const allCommented = lines.every((line) => {
|
||||
const trimmed = line.text.trimStart();
|
||||
return trimmed.length === 0 || trimmed.startsWith("--");
|
||||
});
|
||||
|
||||
const changes = lines
|
||||
.map((line) => {
|
||||
const trimmed = line.text.trimStart();
|
||||
if (trimmed.length === 0) return null; // skip empty lines
|
||||
const indent = line.text.length - trimmed.length;
|
||||
|
||||
if (allCommented) {
|
||||
// Remove comment: strip "-- " or just "--"
|
||||
const afterComment = trimmed.slice(2);
|
||||
const newText = line.text.slice(0, indent) + afterComment.replace(/^\s/, "");
|
||||
return { from: line.from, to: line.to, insert: newText };
|
||||
} else {
|
||||
// Add comment: prepend "-- " to the line content
|
||||
const newText = line.text.slice(0, indent) + "-- " + trimmed;
|
||||
return { from: line.from, to: line.to, insert: newText };
|
||||
}
|
||||
})
|
||||
.filter((c): c is { from: number; to: number; insert: string } => c !== null);
|
||||
|
||||
if (changes.length > 0) {
|
||||
view.dispatch({ changes });
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export function TSQLEditor(opts: TSQLEditorProps) {
|
||||
const {
|
||||
defaultValue = "",
|
||||
readOnly = false,
|
||||
onChange,
|
||||
onUpdate,
|
||||
onBlur,
|
||||
basicSetup = false,
|
||||
autoFocus,
|
||||
showCopyButton = true,
|
||||
showClearButton = false,
|
||||
showFormatButton = true,
|
||||
linterEnabled = true,
|
||||
schema = [],
|
||||
placeholder = "",
|
||||
additionalActions,
|
||||
minHeight = undefined,
|
||||
} = {
|
||||
...defaultProps,
|
||||
...opts,
|
||||
};
|
||||
|
||||
// Create extensions - memoize to avoid recreating on every render
|
||||
const extensions = useMemo(() => {
|
||||
const exts = getEditorSetup();
|
||||
|
||||
// Add SQL language support with StandardSQL dialect
|
||||
// This provides syntax highlighting
|
||||
exts.push(
|
||||
sql({
|
||||
dialect: StandardSQL,
|
||||
upperCaseKeywords: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Add custom TSQL completion
|
||||
if (schema && schema.length > 0) {
|
||||
exts.push(
|
||||
autocompletion({
|
||||
override: [createTSQLCompletion(schema)],
|
||||
activateOnTyping: true,
|
||||
maxRenderedOptions: 50,
|
||||
})
|
||||
);
|
||||
|
||||
// Trigger autocomplete when ' is typed in value context
|
||||
// CodeMirror's activateOnTyping only triggers on alphanumeric characters,
|
||||
// so we manually trigger for quotes after comparison operators
|
||||
exts.push(
|
||||
EditorView.domEventHandlers({
|
||||
keyup: (event, view) => {
|
||||
// Trigger on quote key (both ' and shift+' on some keyboards)
|
||||
if (event.key === "'" || event.key === '"' || event.code === "Quote") {
|
||||
setTimeout(() => {
|
||||
startCompletion(view);
|
||||
}, 50);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Add TSQL linter
|
||||
if (linterEnabled) {
|
||||
exts.push(lintGutter());
|
||||
exts.push(
|
||||
linter(createTSQLLinter({ schema }), {
|
||||
delay: 300, // Debounce linting for better performance
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Add keyboard shortcut for toggling comments
|
||||
exts.push(
|
||||
keymap.of([
|
||||
{ key: "Cmd-/", run: toggleLineComment },
|
||||
{ key: "Ctrl-/", run: toggleLineComment },
|
||||
])
|
||||
);
|
||||
|
||||
return exts;
|
||||
}, [schema, linterEnabled]);
|
||||
|
||||
const editor = useRef<HTMLDivElement>(null);
|
||||
|
||||
const settings: Omit<UseCodeMirror, "onBlur"> = {
|
||||
...opts,
|
||||
container: editor.current,
|
||||
extensions,
|
||||
editable: !readOnly,
|
||||
contentEditable: !readOnly,
|
||||
value: defaultValue,
|
||||
autoFocus,
|
||||
theme: darkTheme(),
|
||||
indentWithTab: false,
|
||||
basicSetup,
|
||||
onChange,
|
||||
onUpdate,
|
||||
placeholder,
|
||||
};
|
||||
|
||||
const { setContainer, view } = useCodeMirror(settings);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor.current) {
|
||||
setContainer(editor.current);
|
||||
}
|
||||
}, [setContainer]);
|
||||
|
||||
// Update editor when defaultValue changes
|
||||
useEffect(() => {
|
||||
if (view !== undefined) {
|
||||
if (view.state.doc.toString() === defaultValue) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: defaultValue },
|
||||
});
|
||||
}
|
||||
}, [defaultValue, view]);
|
||||
|
||||
const clear = () => {
|
||||
if (view === undefined) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: undefined },
|
||||
});
|
||||
onChange?.("");
|
||||
};
|
||||
|
||||
const copy = useCallback(() => {
|
||||
if (view === undefined) return;
|
||||
navigator.clipboard.writeText(view.state.doc.toString());
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1500);
|
||||
}, [view]);
|
||||
|
||||
const format = useCallback(() => {
|
||||
if (view === undefined) return;
|
||||
const currentContent = view.state.doc.toString();
|
||||
if (!currentContent.trim()) return;
|
||||
|
||||
try {
|
||||
const formatted = autoFormatSQL(currentContent);
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: formatted },
|
||||
});
|
||||
onChange?.(formatted);
|
||||
} catch {
|
||||
// If formatting fails (e.g., invalid SQL), silently ignore
|
||||
}
|
||||
}, [view, onChange]);
|
||||
|
||||
const showButtons = showClearButton || showCopyButton || showFormatButton || additionalActions;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative flex h-full flex-col", opts.className)}
|
||||
style={minHeight ? { minHeight } : undefined}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
)}
|
||||
ref={editor}
|
||||
onClick={() => {
|
||||
view?.focus();
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!onBlur) return;
|
||||
if (!view) return;
|
||||
onBlur(view.state.doc.toString());
|
||||
}}
|
||||
/>
|
||||
{showButtons && (
|
||||
<div className="absolute right-0 top-0 z-10 flex items-center justify-end bg-background-deep/80 p-1.5">
|
||||
{additionalActions && additionalActions}
|
||||
{showFormatButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/small"
|
||||
className="flex-none"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
format();
|
||||
}}
|
||||
shortcut={{ key: "f", modifiers: ["shift", "alt"], enabledOnInputElements: true }}
|
||||
>
|
||||
Format
|
||||
</Button>
|
||||
)}
|
||||
{showClearButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/small"
|
||||
TrailingIcon={TrashIcon}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clear();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
{showCopyButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/small"
|
||||
TrailingIcon={copied ? CheckIcon : ClipboardIcon}
|
||||
trailingIconClassName={
|
||||
copied ? "text-green-500 group-hover:text-green-500" : undefined
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
copy();
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// SQL keywords that legitimately appear before parentheses with a space
|
||||
const SQL_KEYWORDS_BEFORE_PAREN = new Set([
|
||||
"IN",
|
||||
"NOT",
|
||||
"EXISTS",
|
||||
"OVER",
|
||||
"USING",
|
||||
"VALUES",
|
||||
"BETWEEN",
|
||||
"LIKE",
|
||||
"AND",
|
||||
"OR",
|
||||
"ON",
|
||||
"SET",
|
||||
"INTO",
|
||||
"TABLE",
|
||||
"CASE",
|
||||
"WHEN",
|
||||
"THEN",
|
||||
"ELSE",
|
||||
"AS",
|
||||
"FROM",
|
||||
"WHERE",
|
||||
"HAVING",
|
||||
"JOIN",
|
||||
"SELECT",
|
||||
]);
|
||||
|
||||
export function autoFormatSQL(sql: string) {
|
||||
let formatted = formatSQL(sql, {
|
||||
language: "sql",
|
||||
keywordCase: "upper",
|
||||
indentStyle: "standard",
|
||||
linesBetweenQueries: 2,
|
||||
});
|
||||
|
||||
// sql-formatter adds a space before ( for unknown/custom functions (e.g. timeBucket ())
|
||||
// Remove that space for anything that isn't a SQL keyword
|
||||
formatted = formatted.replace(/(\b\w+)\s+\(/g, (match, name) => {
|
||||
if (SQL_KEYWORDS_BEFORE_PAREN.has(name.toUpperCase())) {
|
||||
return match;
|
||||
}
|
||||
return `${name}(`;
|
||||
});
|
||||
|
||||
return formatted;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
import type { ViewUpdate } from "@codemirror/view";
|
||||
import { EditorView, lineNumbers } from "@codemirror/view";
|
||||
import { CheckIcon, ClipboardIcon } from "@heroicons/react/20/solid";
|
||||
import type { ReactCodeMirrorProps, UseCodeMirror } from "@uiw/react-codemirror";
|
||||
import { useCodeMirror } from "@uiw/react-codemirror";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { getEditorSetup } from "./codeMirrorSetup";
|
||||
import { darkTheme } from "./codeMirrorTheme";
|
||||
|
||||
export interface TextEditorProps extends Omit<ReactCodeMirrorProps, "onBlur"> {
|
||||
defaultValue?: string;
|
||||
readOnly?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
onUpdate?: (update: ViewUpdate) => void;
|
||||
showCopyButton?: boolean;
|
||||
additionalActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TextEditor(opts: TextEditorProps) {
|
||||
const {
|
||||
defaultValue = "",
|
||||
readOnly = false,
|
||||
onChange,
|
||||
onUpdate,
|
||||
autoFocus,
|
||||
showCopyButton = true,
|
||||
additionalActions,
|
||||
} = opts;
|
||||
|
||||
// Don't use default line numbers from setup — add our own with proper sizing
|
||||
const extensions = getEditorSetup(false);
|
||||
extensions.push(EditorView.lineWrapping);
|
||||
extensions.push(
|
||||
lineNumbers({
|
||||
formatNumber: (n) => String(n),
|
||||
})
|
||||
);
|
||||
extensions.push(
|
||||
EditorView.theme({
|
||||
".cm-lineNumbers": {
|
||||
minWidth: "40px",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const editor = useRef<HTMLDivElement>(null);
|
||||
const settings: Omit<UseCodeMirror, "onBlur"> = {
|
||||
...opts,
|
||||
container: editor.current,
|
||||
extensions,
|
||||
editable: !readOnly,
|
||||
contentEditable: !readOnly,
|
||||
value: defaultValue,
|
||||
autoFocus,
|
||||
theme: darkTheme(),
|
||||
indentWithTab: false,
|
||||
basicSetup: false,
|
||||
onChange,
|
||||
onUpdate,
|
||||
};
|
||||
const { setContainer, view } = useCodeMirror(settings);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor.current) {
|
||||
setContainer(editor.current);
|
||||
}
|
||||
}, [setContainer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== undefined) {
|
||||
if (view.state.doc.toString() === defaultValue) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: defaultValue },
|
||||
});
|
||||
}
|
||||
}, [defaultValue, view]);
|
||||
|
||||
const copy = useCallback(() => {
|
||||
if (view === undefined) return;
|
||||
navigator.clipboard.writeText(view.state.doc.toString());
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}, [view]);
|
||||
|
||||
const showToolbar = showCopyButton || additionalActions;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid",
|
||||
showToolbar ? "grid-rows-[2.5rem_1fr]" : "grid-rows-[1fr]",
|
||||
opts.className
|
||||
)}
|
||||
>
|
||||
{showToolbar && (
|
||||
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
|
||||
<div className="flex items-center">{additionalActions}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{showCopyButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/small"
|
||||
TrailingIcon={copied ? CheckIcon : ClipboardIcon}
|
||||
trailingIconClassName={
|
||||
copied ? "text-green-500 group-hover:text-green-500" : undefined
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
copy();
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 min-w-0 overflow-auto" ref={editor} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Chart color palette defined in HSL (Hue, Saturation, Lightness).
|
||||
*
|
||||
* HSL is a human-friendly color model:
|
||||
* h: 0–360 (hue — position on the color wheel: 0=red, 120=green, 240=blue)
|
||||
* s: 0–100 (saturation — 0 is gray, 100 is full color)
|
||||
* l: 0–100 (lightness — 0 is black, 50 is pure color, 100 is white)
|
||||
*/
|
||||
|
||||
interface HSLColor {
|
||||
h: number;
|
||||
s: number;
|
||||
l: number;
|
||||
}
|
||||
|
||||
interface ChartColorDef {
|
||||
name: string;
|
||||
hsl: HSLColor;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Palette — 30 distinct colors for chart series, defined in HSL
|
||||
// ---------------------------------------------------------------------------
|
||||
const CHART_COLOR_DEFS: ChartColorDef[] = [
|
||||
// Primary colors (high contrast, spread across hue wheel)
|
||||
{ name: "Purple", hsl: { h: 252, s: 98, l: 66 } },
|
||||
{ name: "Green", hsl: { h: 142, s: 71, l: 45 } },
|
||||
{ name: "Amber", hsl: { h: 38, s: 92, l: 50 } },
|
||||
{ name: "Red", hsl: { h: 0, s: 84, l: 60 } },
|
||||
{ name: "Cyan", hsl: { h: 189, s: 95, l: 43 } },
|
||||
{ name: "Pink", hsl: { h: 330, s: 81, l: 60 } },
|
||||
{ name: "Violet", hsl: { h: 258, s: 90, l: 66 } },
|
||||
{ name: "Teal", hsl: { h: 173, s: 80, l: 40 } },
|
||||
{ name: "Orange", hsl: { h: 25, s: 95, l: 53 } },
|
||||
{ name: "Indigo", hsl: { h: 239, s: 84, l: 67 } },
|
||||
// Extended palette
|
||||
{ name: "Lime", hsl: { h: 84, s: 81, l: 44 } },
|
||||
{ name: "Sky", hsl: { h: 199, s: 89, l: 48 } },
|
||||
{ name: "Rose", hsl: { h: 350, s: 89, l: 60 } },
|
||||
{ name: "Fuchsia", hsl: { h: 271, s: 91, l: 65 } },
|
||||
{ name: "Yellow", hsl: { h: 45, s: 93, l: 47 } },
|
||||
{ name: "Emerald", hsl: { h: 160, s: 84, l: 39 } },
|
||||
{ name: "Blue", hsl: { h: 217, s: 91, l: 60 } },
|
||||
{ name: "Magenta", hsl: { h: 292, s: 84, l: 61 } },
|
||||
{ name: "Stone", hsl: { h: 25, s: 5, l: 45 } },
|
||||
{ name: "Gold", hsl: { h: 48, s: 96, l: 53 } },
|
||||
// Additional distinct colors (lighter variants)
|
||||
{ name: "Turquoise", hsl: { h: 173, s: 66, l: 50 } },
|
||||
{ name: "Light Orange", hsl: { h: 27, s: 96, l: 61 } },
|
||||
{ name: "Yellow-Green", hsl: { h: 83, s: 78, l: 55 } },
|
||||
{ name: "Light Blue", hsl: { h: 198, s: 93, l: 60 } },
|
||||
{ name: "Light Purple", hsl: { h: 270, s: 95, l: 75 } },
|
||||
{ name: "Light Green", hsl: { h: 142, s: 69, l: 58 } },
|
||||
{ name: "Light Amber", hsl: { h: 43, s: 96, l: 56 } },
|
||||
{ name: "Light Pink", hsl: { h: 329, s: 86, l: 70 } },
|
||||
{ name: "Light Cyan", hsl: { h: 187, s: 92, l: 69 } },
|
||||
{ name: "Light Indigo", hsl: { h: 235, s: 89, l: 74 } },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HSL ↔ Hex conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Convert an HSL color (h: 0–360, s: 0–100, l: 0–100) to a hex string */
|
||||
function hslToHex({ h, s, l }: HSLColor): string {
|
||||
const sNorm = s / 100;
|
||||
const lNorm = l / 100;
|
||||
|
||||
const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
|
||||
const hPrime = h / 60;
|
||||
const x = c * (1 - Math.abs((hPrime % 2) - 1));
|
||||
const m = lNorm - c / 2;
|
||||
|
||||
let r1: number, g1: number, b1: number;
|
||||
|
||||
if (hPrime < 1) {
|
||||
r1 = c;
|
||||
g1 = x;
|
||||
b1 = 0;
|
||||
} else if (hPrime < 2) {
|
||||
r1 = x;
|
||||
g1 = c;
|
||||
b1 = 0;
|
||||
} else if (hPrime < 3) {
|
||||
r1 = 0;
|
||||
g1 = c;
|
||||
b1 = x;
|
||||
} else if (hPrime < 4) {
|
||||
r1 = 0;
|
||||
g1 = x;
|
||||
b1 = c;
|
||||
} else if (hPrime < 5) {
|
||||
r1 = x;
|
||||
g1 = 0;
|
||||
b1 = c;
|
||||
} else {
|
||||
r1 = c;
|
||||
g1 = 0;
|
||||
b1 = x;
|
||||
}
|
||||
|
||||
const toHex = (v: number) =>
|
||||
Math.round((v + m) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
|
||||
return `#${toHex(r1)}${toHex(g1)}${toHex(b1)}`;
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived hex palette (for consumers that need plain hex strings)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Color palette for chart series — 30 distinct hex colors derived from HSL definitions */
|
||||
const CHART_COLORS: string[] = CHART_COLOR_DEFS.map((def) => hslToHex(def.hsl));
|
||||
|
||||
/** Get the hex color for a series by its index (wraps around) */
|
||||
export function getSeriesColor(index: number): string {
|
||||
return CHART_COLORS[index % CHART_COLORS.length];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hue-sorted palette (rainbow order for color pickers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SATURATION_THRESHOLD = 10;
|
||||
|
||||
/**
|
||||
* Chart colors sorted by perceived hue — the natural rainbow order
|
||||
* that humans expect: red -> orange -> yellow -> green -> cyan -> blue -> purple -> pink.
|
||||
*
|
||||
* Very desaturated colors (like grays) are placed at the end since they don't
|
||||
* have a strong hue.
|
||||
*/
|
||||
export const CHART_COLORS_BY_HUE: string[] = [...CHART_COLOR_DEFS]
|
||||
.sort((a, b) => {
|
||||
const aIsGray = a.hsl.s < SATURATION_THRESHOLD;
|
||||
const bIsGray = b.hsl.s < SATURATION_THRESHOLD;
|
||||
|
||||
// Push desaturated colors to the end
|
||||
if (aIsGray && !bIsGray) return 1;
|
||||
if (!aIsGray && bIsGray) return -1;
|
||||
if (aIsGray && bIsGray) return a.hsl.l - b.hsl.l;
|
||||
|
||||
// Sort by hue, then by saturation (more vivid first), then by lightness
|
||||
if (a.hsl.h !== b.hsl.h) return a.hsl.h - b.hsl.h;
|
||||
if (a.hsl.s !== b.hsl.s) return b.hsl.s - a.hsl.s;
|
||||
return a.hsl.l - b.hsl.l;
|
||||
})
|
||||
.map((def) => hslToHex(def.hsl));
|
||||
@@ -0,0 +1,60 @@
|
||||
import { closeBrackets } from "@codemirror/autocomplete";
|
||||
import { indentWithTab, history, historyKeymap, undo, redo } from "@codemirror/commands";
|
||||
import { bracketMatching } from "@codemirror/language";
|
||||
import { lintKeymap } from "@codemirror/lint";
|
||||
import { highlightSelectionMatches } from "@codemirror/search";
|
||||
import { Prec, type Extension } from "@codemirror/state";
|
||||
import {
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
highlightSpecialChars,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
} from "@codemirror/view";
|
||||
|
||||
export function getEditorSetup(showLineNumbers = true, showHighlights = true): Array<Extension> {
|
||||
const options = [
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
history(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
key: "Mod-Enter",
|
||||
run: () => {
|
||||
return true;
|
||||
},
|
||||
preventDefault: false,
|
||||
},
|
||||
])
|
||||
),
|
||||
// Explicit undo/redo keybindings with high precedence
|
||||
Prec.high(
|
||||
keymap.of([
|
||||
{ key: "Mod-z", run: undo },
|
||||
{ key: "Mod-Shift-z", run: redo },
|
||||
{ key: "Mod-y", run: redo },
|
||||
])
|
||||
),
|
||||
keymap.of([indentWithTab, ...historyKeymap, ...lintKeymap]),
|
||||
];
|
||||
|
||||
if (showLineNumbers) {
|
||||
options.push(lineNumbers());
|
||||
}
|
||||
|
||||
if (showHighlights) {
|
||||
options.push([
|
||||
highlightActiveLineGutter(),
|
||||
highlightSpecialChars(),
|
||||
highlightActiveLine(),
|
||||
highlightSelectionMatches(),
|
||||
]);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { tags } from "@lezer/highlight";
|
||||
|
||||
export function darkTheme(): Extension {
|
||||
// Values come from the --color-editor-* theme palette in tailwind.css.
|
||||
const chalky = "var(--color-editor-type)",
|
||||
coral = "var(--color-editor-heading)",
|
||||
cyan = "var(--color-editor-operator)",
|
||||
invalid = "var(--color-editor-invalid)",
|
||||
ivory = "var(--color-editor-foreground)",
|
||||
stone = "var(--color-editor-comment)",
|
||||
malibu = "var(--color-editor-function)",
|
||||
sage = "var(--color-editor-string)",
|
||||
whiskey = "var(--color-editor-constant)",
|
||||
violet = "var(--color-editor-keyword)",
|
||||
lilac = "var(--color-editor-name)",
|
||||
darkBackground = "var(--color-editor-panel-background)",
|
||||
highlightBackground = "var(--color-editor-highlight-background)",
|
||||
background = "var(--color-editor-background)",
|
||||
tooltipBackground = "var(--color-editor-tooltip-background)",
|
||||
selection = "var(--color-editor-selection)",
|
||||
cursor = "var(--color-editor-cursor)",
|
||||
scrollbarTrack = "rgba(0,0,0,0)",
|
||||
scrollbarTrackActive = "var(--color-editor-scrollbar-track-active)",
|
||||
scrollbarThumb = "var(--color-editor-scrollbar-thumb)",
|
||||
scrollbarThumbActive = "var(--color-editor-scrollbar-thumb-active)",
|
||||
scrollbarBg = "rgba(0,0,0,0)";
|
||||
|
||||
const jsonHeroEditorTheme = EditorView.theme(
|
||||
{
|
||||
"&": {
|
||||
color: ivory,
|
||||
backgroundColor: background,
|
||||
},
|
||||
|
||||
".cm-content": {
|
||||
caretColor: cursor,
|
||||
fontFamily: "Geist Mono Variable",
|
||||
fontSize: "14px",
|
||||
},
|
||||
|
||||
".cm-tooltip.cm-tooltip-lint": {
|
||||
backgroundColor: tooltipBackground,
|
||||
},
|
||||
|
||||
".cm-diagnostic": {
|
||||
padding: "4px 8px",
|
||||
color: ivory,
|
||||
fontFamily: "Geist Mono Variable",
|
||||
fontSize: "12px",
|
||||
},
|
||||
|
||||
".cm-diagnostic-error": {
|
||||
borderLeft: "2px solid var(--color-error)",
|
||||
},
|
||||
|
||||
".cm-lint-marker-error": {
|
||||
content: "none",
|
||||
backgroundColor: "var(--color-error)",
|
||||
height: "100%",
|
||||
width: "2px",
|
||||
},
|
||||
|
||||
".cm-lintPoint:after": {
|
||||
borderBottom: "4px solid var(--color-error)",
|
||||
},
|
||||
|
||||
".cm-cursor, .cm-dropCursor": { borderLeftColor: cursor },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
||||
backgroundColor: selection,
|
||||
},
|
||||
|
||||
".cm-panels": { backgroundColor: darkBackground, color: ivory },
|
||||
".cm-panels.cm-panels-top": { borderBottom: "2px solid black" },
|
||||
".cm-panels.cm-panels-bottom": { borderTop: "2px solid black" },
|
||||
|
||||
".cm-searchMatch": {
|
||||
backgroundColor: "var(--color-editor-search-match)",
|
||||
outline: "1px solid var(--color-editor-search-match-outline)",
|
||||
},
|
||||
".cm-searchMatch.cm-searchMatch-selected": {
|
||||
backgroundColor: "var(--color-editor-search-match-selected)",
|
||||
},
|
||||
|
||||
".cm-activeLine": { backgroundColor: highlightBackground },
|
||||
".cm-selectionMatch": { backgroundColor: "var(--color-editor-selection-match)" },
|
||||
|
||||
"&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": {
|
||||
backgroundColor: "var(--color-editor-matching-bracket)",
|
||||
outline: "1px solid var(--color-editor-matching-bracket-outline)",
|
||||
},
|
||||
|
||||
".cm-gutters": {
|
||||
backgroundColor: background,
|
||||
color: stone,
|
||||
border: "none",
|
||||
},
|
||||
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: highlightBackground,
|
||||
},
|
||||
|
||||
".cm-foldPlaceholder": {
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
color: "var(--color-editor-fold-placeholder)",
|
||||
},
|
||||
|
||||
".cm-tooltip": {
|
||||
border: "none",
|
||||
marginTop: "6px",
|
||||
backgroundColor: tooltipBackground,
|
||||
},
|
||||
".cm-tooltip .cm-tooltip-arrow:before": {
|
||||
borderTopColor: "transparent",
|
||||
borderBottomColor: "transparent",
|
||||
},
|
||||
".cm-tooltip .cm-tooltip-arrow:after": {
|
||||
borderTopColor: tooltipBackground,
|
||||
borderBottomColor: tooltipBackground,
|
||||
},
|
||||
".cm-tooltip-autocomplete": {
|
||||
"& > ul > li[aria-selected]": {
|
||||
backgroundColor: highlightBackground,
|
||||
color: ivory,
|
||||
},
|
||||
},
|
||||
".cm-scroller": {
|
||||
scrollbarWidth: "thin",
|
||||
scrollbarColor: `${scrollbarThumb} ${scrollbarTrack}`,
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar": {
|
||||
display: "block",
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-track": {
|
||||
backgroundColor: scrollbarTrack,
|
||||
borderRadius: "0",
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-track:hover": {
|
||||
backgroundColor: scrollbarTrackActive,
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-track:active": {
|
||||
backgroundColor: scrollbarTrackActive,
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-thumb": {
|
||||
backgroundColor: scrollbarThumb,
|
||||
borderRadius: "0",
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-thumb:hover": {
|
||||
backgroundColor: scrollbarThumbActive,
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-thumb:active": {
|
||||
backgroundColor: scrollbarThumbActive,
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-corner": {
|
||||
backgroundColor: scrollbarBg,
|
||||
borderRadius: "0",
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-corner:hover": {
|
||||
backgroundColor: scrollbarBg,
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-corner:active": {
|
||||
backgroundColor: scrollbarBg,
|
||||
},
|
||||
},
|
||||
{ dark: true }
|
||||
);
|
||||
|
||||
/// The highlighting style for code in the JSON Hero theme.
|
||||
const jsonHeroHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: violet },
|
||||
{
|
||||
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
|
||||
color: lilac,
|
||||
},
|
||||
{ tag: [tags.function(tags.variableName), tags.labelName], color: malibu },
|
||||
{
|
||||
tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)],
|
||||
color: whiskey,
|
||||
},
|
||||
{ tag: [tags.definition(tags.name), tags.separator], color: ivory },
|
||||
{
|
||||
tag: [
|
||||
tags.typeName,
|
||||
tags.className,
|
||||
tags.number,
|
||||
tags.bool,
|
||||
tags.changed,
|
||||
tags.annotation,
|
||||
tags.modifier,
|
||||
tags.self,
|
||||
tags.namespace,
|
||||
],
|
||||
color: chalky,
|
||||
},
|
||||
{
|
||||
tag: [
|
||||
tags.operator,
|
||||
tags.operatorKeyword,
|
||||
tags.url,
|
||||
tags.escape,
|
||||
tags.regexp,
|
||||
tags.link,
|
||||
tags.special(tags.string),
|
||||
],
|
||||
color: cyan,
|
||||
},
|
||||
{ tag: [tags.meta, tags.comment], color: stone },
|
||||
{ tag: tags.strong, fontWeight: "bold" },
|
||||
{ tag: tags.emphasis, fontStyle: "italic" },
|
||||
{ tag: tags.strikethrough, textDecoration: "line-through" },
|
||||
{ tag: tags.link, color: stone, textDecoration: "underline" },
|
||||
{ tag: tags.heading, fontWeight: "bold", color: coral },
|
||||
{
|
||||
tag: [tags.atom, tags.special(tags.variableName)],
|
||||
color: whiskey,
|
||||
},
|
||||
{
|
||||
tag: [tags.processingInstruction, tags.string, tags.inserted],
|
||||
color: sage,
|
||||
},
|
||||
{ tag: tags.invalid, color: invalid },
|
||||
]);
|
||||
|
||||
return [jsonHeroEditorTheme, syntaxHighlighting(jsonHeroHighlightStyle)];
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import type { ThemeRegistrationAny } from "streamdown";
|
||||
|
||||
// Custom Shiki theme matching the Trigger.dev VS Code dark theme.
|
||||
// Colors taken directly from the VS Code extension's tokenColors.
|
||||
export const triggerDarkTheme: ThemeRegistrationAny = {
|
||||
name: "trigger-dark",
|
||||
type: "dark",
|
||||
colors: {
|
||||
"editor.background": "var(--color-code-background)",
|
||||
"editor.foreground": "var(--color-code-foreground)",
|
||||
"editorLineNumber.foreground": "var(--color-code-line-number)",
|
||||
},
|
||||
tokenColors: [
|
||||
// Control flow keywords: pink-purple
|
||||
{
|
||||
scope: [
|
||||
"keyword.control",
|
||||
"keyword.operator.delete",
|
||||
"keyword.other.using",
|
||||
"keyword.other.operator",
|
||||
"entity.name.operator",
|
||||
],
|
||||
settings: { foreground: "var(--color-code-keyword)" },
|
||||
},
|
||||
// Storage type (const, let, var, function, class): purple
|
||||
{
|
||||
scope: "storage.type",
|
||||
settings: { foreground: "var(--color-code-storage)" },
|
||||
},
|
||||
// Storage modifiers (async, export, etc.): purple
|
||||
{
|
||||
scope: ["storage.modifier", "keyword.operator.noexcept"],
|
||||
settings: { foreground: "var(--color-code-storage)" },
|
||||
},
|
||||
// Keyword operator expressions (new, typeof, instanceof, etc.): purple
|
||||
{
|
||||
scope: [
|
||||
"keyword.operator.new",
|
||||
"keyword.operator.expression",
|
||||
"keyword.operator.cast",
|
||||
"keyword.operator.sizeof",
|
||||
"keyword.operator.instanceof",
|
||||
"keyword.operator.logical.python",
|
||||
"keyword.operator.wordlike",
|
||||
],
|
||||
settings: { foreground: "var(--color-code-storage)" },
|
||||
},
|
||||
// Types and namespaces: hot pink
|
||||
{
|
||||
scope: [
|
||||
"support.class",
|
||||
"support.type",
|
||||
"entity.name.type",
|
||||
"entity.name.namespace",
|
||||
"entity.name.scope-resolution",
|
||||
"entity.name.class",
|
||||
"entity.other.inherited-class",
|
||||
],
|
||||
settings: { foreground: "var(--color-code-type)" },
|
||||
},
|
||||
// Functions: lime/yellow-green
|
||||
{
|
||||
scope: ["entity.name.function", "support.function"],
|
||||
settings: { foreground: "var(--color-code-function)" },
|
||||
},
|
||||
// Variables and parameters: light lavender
|
||||
{
|
||||
scope: [
|
||||
"variable",
|
||||
"meta.definition.variable.name",
|
||||
"support.variable",
|
||||
"entity.name.variable",
|
||||
"constant.other.placeholder",
|
||||
],
|
||||
settings: { foreground: "var(--color-code-variable)" },
|
||||
},
|
||||
// Constants and enums: medium purple
|
||||
{
|
||||
scope: ["variable.other.constant", "variable.other.enummember"],
|
||||
settings: { foreground: "var(--color-code-constant)" },
|
||||
},
|
||||
// this/self: purple-blue
|
||||
{
|
||||
scope: "variable.language",
|
||||
settings: { foreground: "var(--color-code-language)" },
|
||||
},
|
||||
// Object literal keys: medium purple-blue
|
||||
{
|
||||
scope: "meta.object-literal.key",
|
||||
settings: { foreground: "var(--color-code-object-key)" },
|
||||
},
|
||||
// Strings: sage green
|
||||
{
|
||||
scope: ["string", "meta.embedded.assembly"],
|
||||
settings: { foreground: "var(--color-code-string)" },
|
||||
},
|
||||
// String interpolation punctuation: blue-purple
|
||||
{
|
||||
scope: [
|
||||
"punctuation.definition.template-expression.begin",
|
||||
"punctuation.definition.template-expression.end",
|
||||
"punctuation.section.embedded",
|
||||
],
|
||||
settings: { foreground: "var(--color-code-template-punctuation)" },
|
||||
},
|
||||
// Template expression reset
|
||||
{
|
||||
scope: "meta.template.expression",
|
||||
settings: { foreground: "var(--color-code-plain)" },
|
||||
},
|
||||
// Operators: gray (same as foreground)
|
||||
{
|
||||
scope: "keyword.operator",
|
||||
settings: { foreground: "var(--color-code-foreground)" },
|
||||
},
|
||||
// Comments: olive gray
|
||||
{
|
||||
scope: "comment",
|
||||
settings: { foreground: "var(--color-code-comment)" },
|
||||
},
|
||||
// Language constants (true, false, null, undefined): purple-blue
|
||||
{
|
||||
scope: "constant.language",
|
||||
settings: { foreground: "var(--color-code-language)" },
|
||||
},
|
||||
// Numeric constants: light green
|
||||
{
|
||||
scope: [
|
||||
"constant.numeric",
|
||||
"keyword.operator.plus.exponent",
|
||||
"keyword.operator.minus.exponent",
|
||||
],
|
||||
settings: { foreground: "var(--color-code-number)" },
|
||||
},
|
||||
// Regex: dark red
|
||||
{
|
||||
scope: "constant.regexp",
|
||||
settings: { foreground: "var(--color-code-regexp-constant)" },
|
||||
},
|
||||
// HTML/JSX tags: purple-blue
|
||||
{
|
||||
scope: "entity.name.tag",
|
||||
settings: { foreground: "var(--color-code-language)" },
|
||||
},
|
||||
// Tag brackets: dark gray
|
||||
{
|
||||
scope: "punctuation.definition.tag",
|
||||
settings: { foreground: "var(--color-code-muted)" },
|
||||
},
|
||||
// HTML/JSX attributes: light purple
|
||||
{
|
||||
scope: "entity.other.attribute-name",
|
||||
settings: { foreground: "var(--color-code-attribute)" },
|
||||
},
|
||||
// Escape characters: gold
|
||||
{
|
||||
scope: "constant.character.escape",
|
||||
settings: { foreground: "var(--color-code-escape)" },
|
||||
},
|
||||
// Regex string: dark red
|
||||
{
|
||||
scope: "string.regexp",
|
||||
settings: { foreground: "var(--color-code-regexp)" },
|
||||
},
|
||||
// Storage: purple-blue
|
||||
{
|
||||
scope: "storage",
|
||||
settings: { foreground: "var(--color-code-language)" },
|
||||
},
|
||||
// TS-specific: type casts, math/dom/json constants
|
||||
{
|
||||
scope: [
|
||||
"meta.type.cast.expr",
|
||||
"meta.type.new.expr",
|
||||
"support.constant.math",
|
||||
"support.constant.dom",
|
||||
"support.constant.json",
|
||||
],
|
||||
settings: { foreground: "var(--color-code-language)" },
|
||||
},
|
||||
// Markdown headings: purple-blue bold
|
||||
{
|
||||
scope: "markup.heading",
|
||||
settings: { foreground: "var(--color-code-language)", fontStyle: "bold" },
|
||||
},
|
||||
// Markup bold: purple-blue
|
||||
{
|
||||
scope: "markup.bold",
|
||||
settings: { foreground: "var(--color-code-language)", fontStyle: "bold" },
|
||||
},
|
||||
// Markup inline raw: sage green
|
||||
{
|
||||
scope: "markup.inline.raw",
|
||||
settings: { foreground: "var(--color-code-string)" },
|
||||
},
|
||||
// Markup inserted: light green
|
||||
{
|
||||
scope: "markup.inserted",
|
||||
settings: { foreground: "var(--color-code-number)" },
|
||||
},
|
||||
// Markup deleted: sage green
|
||||
{
|
||||
scope: "markup.deleted",
|
||||
settings: { foreground: "var(--color-code-string)" },
|
||||
},
|
||||
// Markup changed: purple-blue
|
||||
{
|
||||
scope: "markup.changed",
|
||||
settings: { foreground: "var(--color-code-language)" },
|
||||
},
|
||||
// Invalid: red
|
||||
{
|
||||
scope: "invalid",
|
||||
settings: { foreground: "var(--color-code-invalid)" },
|
||||
},
|
||||
// JSX text content
|
||||
{
|
||||
scope: ["meta.jsx.children"],
|
||||
settings: { foreground: "var(--color-code-jsx-text)" },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
// TSQL CodeMirror support
|
||||
// Provides syntax highlighting, autocompletion, and linting for TSQL queries
|
||||
|
||||
export { createTSQLCompletion } from "./tsqlCompletion";
|
||||
export {
|
||||
createTSQLLinter,
|
||||
isValidTSQLQuery,
|
||||
getTSQLError,
|
||||
type TSQLLinterConfig,
|
||||
} from "./tsqlLinter";
|
||||
@@ -0,0 +1,490 @@
|
||||
import type { CompletionContext, CompletionResult, Completion } from "@codemirror/autocomplete";
|
||||
import {
|
||||
type TableSchema,
|
||||
type ColumnSchema,
|
||||
TSQL_CLICKHOUSE_FUNCTIONS,
|
||||
TSQL_AGGREGATIONS,
|
||||
} from "@internal/tsql";
|
||||
|
||||
/**
|
||||
* SQL keywords for autocomplete
|
||||
*/
|
||||
const SQL_KEYWORDS = [
|
||||
"SELECT",
|
||||
"FROM",
|
||||
"WHERE",
|
||||
"AND",
|
||||
"OR",
|
||||
"NOT",
|
||||
"IN",
|
||||
"LIKE",
|
||||
"ILIKE",
|
||||
"BETWEEN",
|
||||
"IS",
|
||||
"NULL",
|
||||
"TRUE",
|
||||
"FALSE",
|
||||
"AS",
|
||||
"ORDER",
|
||||
"BY",
|
||||
"ASC",
|
||||
"DESC",
|
||||
"LIMIT",
|
||||
"OFFSET",
|
||||
"GROUP",
|
||||
"HAVING",
|
||||
"DISTINCT",
|
||||
"JOIN",
|
||||
"LEFT",
|
||||
"RIGHT",
|
||||
"INNER",
|
||||
"OUTER",
|
||||
"FULL",
|
||||
"CROSS",
|
||||
"ON",
|
||||
"UNION",
|
||||
"INTERSECT",
|
||||
"EXCEPT",
|
||||
"ALL",
|
||||
"WITH",
|
||||
"CASE",
|
||||
"WHEN",
|
||||
"THEN",
|
||||
"ELSE",
|
||||
"END",
|
||||
"OVER",
|
||||
"PARTITION",
|
||||
"ROWS",
|
||||
"RANGE",
|
||||
"UNBOUNDED",
|
||||
"PRECEDING",
|
||||
"FOLLOWING",
|
||||
"CURRENT",
|
||||
"ROW",
|
||||
"NULLS",
|
||||
"FIRST",
|
||||
"LAST",
|
||||
];
|
||||
|
||||
/**
|
||||
* Create keyword completions from the SQL keywords list
|
||||
*/
|
||||
function createKeywordCompletions(): Completion[] {
|
||||
return SQL_KEYWORDS.map((keyword) => ({
|
||||
label: keyword,
|
||||
type: "keyword",
|
||||
boost: -1, // Keywords should have lower priority than schema items
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create function completions from TSQL function definitions
|
||||
*/
|
||||
function createFunctionCompletions(): Completion[] {
|
||||
const functions: Completion[] = [];
|
||||
|
||||
// Add regular functions
|
||||
for (const [name, meta] of Object.entries(TSQL_CLICKHOUSE_FUNCTIONS)) {
|
||||
// Skip internal functions starting with _
|
||||
if (name.startsWith("_")) continue;
|
||||
|
||||
const argsHint =
|
||||
meta.maxArgs === 0
|
||||
? "()"
|
||||
: meta.minArgs === meta.maxArgs
|
||||
? `(${meta.minArgs} args)`
|
||||
: `(${meta.minArgs}${meta.maxArgs ? `-${meta.maxArgs}` : "+"} args)`;
|
||||
|
||||
functions.push({
|
||||
label: name,
|
||||
type: "function",
|
||||
detail: argsHint,
|
||||
apply: `${name}()`,
|
||||
});
|
||||
}
|
||||
|
||||
// Add aggregate functions with slightly higher boost
|
||||
for (const [name, meta] of Object.entries(TSQL_AGGREGATIONS)) {
|
||||
if (name.startsWith("_")) continue;
|
||||
|
||||
const argsHint =
|
||||
meta.maxArgs === 0
|
||||
? "()"
|
||||
: meta.minArgs === meta.maxArgs
|
||||
? `(${meta.minArgs} args)`
|
||||
: `(${meta.minArgs}${meta.maxArgs ? `-${meta.maxArgs}` : "+"} args)`;
|
||||
|
||||
functions.push({
|
||||
label: name,
|
||||
type: "function",
|
||||
detail: `aggregate ${argsHint}`,
|
||||
apply: `${name}()`,
|
||||
boost: 0.5,
|
||||
});
|
||||
}
|
||||
|
||||
// Add special TSQL functions not in the ClickHouse function registry
|
||||
functions.push({
|
||||
label: "timeBucket",
|
||||
type: "function",
|
||||
detail: "auto time bucket (0 args)",
|
||||
apply: "timeBucket()",
|
||||
boost: 1.5,
|
||||
info: "Automatically bucket by time using the table's time column. Interval is chosen based on the query's time range.",
|
||||
});
|
||||
|
||||
return functions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create table completions from schema
|
||||
*/
|
||||
function createTableCompletions(schema: TableSchema[]): Completion[] {
|
||||
return schema.map((table) => ({
|
||||
label: table.name,
|
||||
type: "class", // Using "class" type for tables gives them a nice icon
|
||||
detail: table.description || "table",
|
||||
boost: 1, // Tables should have higher priority
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create column completions for a specific table
|
||||
*/
|
||||
function createColumnCompletions(table: TableSchema, prefix?: string): Completion[] {
|
||||
const columns: Completion[] = [];
|
||||
|
||||
for (const [name, column] of Object.entries(table.columns)) {
|
||||
columns.push({
|
||||
label: prefix ? `${prefix}.${name}` : name,
|
||||
type: "property", // Using "property" type for columns
|
||||
detail: `${column.type}${column.description ? ` - ${column.description}` : ""}`,
|
||||
boost: 2, // Columns should have highest priority
|
||||
});
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract table names/aliases from the current query context
|
||||
* This is a simplified parser that looks for FROM and JOIN clauses
|
||||
*/
|
||||
function extractTablesFromQuery(doc: string, schema: TableSchema[]): Map<string, TableSchema> {
|
||||
const tableMap = new Map<string, TableSchema>();
|
||||
const _tableNames = schema.map((t) => t.name);
|
||||
|
||||
// Simple regex to find table references in FROM and JOIN clauses
|
||||
// Handles: FROM table_name, FROM table_name AS alias, FROM table_name alias
|
||||
const tablePattern = /(?:FROM|JOIN)\s+(\w+)(?:\s+(?:AS\s+)?(\w+))?/gi;
|
||||
|
||||
let match;
|
||||
while ((match = tablePattern.exec(doc)) !== null) {
|
||||
const tableName = match[1];
|
||||
const alias = match[2] || tableName;
|
||||
|
||||
// Find the table schema if it exists
|
||||
const tableSchema = schema.find((t) => t.name.toLowerCase() === tableName.toLowerCase());
|
||||
|
||||
if (tableSchema) {
|
||||
tableMap.set(alias.toLowerCase(), tableSchema);
|
||||
}
|
||||
}
|
||||
|
||||
return tableMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine what context we're in based on cursor position
|
||||
*/
|
||||
type CompletionContextType =
|
||||
| "table" // After FROM or JOIN
|
||||
| "column" // After SELECT, WHERE, ORDER BY, GROUP BY, etc.
|
||||
| "alias" // After table_name.
|
||||
| "value" // After comparison operator (=, !=, IN, etc.)
|
||||
| "general"; // Anywhere else
|
||||
|
||||
/**
|
||||
* Result of context detection
|
||||
*/
|
||||
interface ContextResult {
|
||||
type: CompletionContextType;
|
||||
tablePrefix?: string;
|
||||
/** Column being compared (for value context) */
|
||||
columnName?: string;
|
||||
/** Table alias for the column (for value context) */
|
||||
columnTableAlias?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract column name from text before a comparison operator
|
||||
* Handles: "column =", "table.column =", "column IN", "column = 'partial", etc.
|
||||
*/
|
||||
function extractColumnBeforeOperator(
|
||||
textBefore: string
|
||||
): { columnName: string; tableAlias?: string } | null {
|
||||
// Match patterns like: column =, column !=, column IN, table.column =, etc.
|
||||
// We need to capture the column (and optional table prefix) before the operator
|
||||
// Also match when user is typing a partial string value like: column = 'val
|
||||
const patterns = [
|
||||
// column = or column != or column <> (with optional whitespace and optional partial string value)
|
||||
/(\w+)\.(\w+)\s*(?:=|!=|<>)\s*(?:'[^']*)?$/i,
|
||||
/(\w+)\s*(?:=|!=|<>)\s*(?:'[^']*)?$/i,
|
||||
// column IN ( or column NOT IN ( (with optional partial string value)
|
||||
/(\w+)\.(\w+)\s+(?:NOT\s+)?IN\s*\(\s*(?:'[^']*)?$/i,
|
||||
/(\w+)\s+(?:NOT\s+)?IN\s*\(\s*(?:'[^']*)?$/i,
|
||||
// After a comma in IN clause (with optional partial string value)
|
||||
/(\w+)\.(\w+)\s+(?:NOT\s+)?IN\s*\([^)]*,\s*(?:'[^']*)?$/i,
|
||||
/(\w+)\s+(?:NOT\s+)?IN\s*\([^)]*,\s*(?:'[^']*)?$/i,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = textBefore.match(pattern);
|
||||
if (match) {
|
||||
if (match.length === 3) {
|
||||
// table.column pattern
|
||||
return { tableAlias: match[1], columnName: match[2] };
|
||||
} else {
|
||||
// just column pattern
|
||||
return { columnName: match[1] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function determineContext(doc: string, pos: number): ContextResult {
|
||||
// Get text before cursor
|
||||
const textBefore = doc.slice(0, pos);
|
||||
|
||||
// Check if we're in a value context (after comparison operator)
|
||||
// This should be checked before other contexts
|
||||
const columnInfo = extractColumnBeforeOperator(textBefore);
|
||||
if (columnInfo) {
|
||||
return {
|
||||
type: "value",
|
||||
columnName: columnInfo.columnName,
|
||||
columnTableAlias: columnInfo.tableAlias,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if we're completing after a dot (table.column)
|
||||
const dotMatch = textBefore.match(/(\w+)\.\s*$/);
|
||||
if (dotMatch) {
|
||||
return { type: "alias", tablePrefix: dotMatch[1] };
|
||||
}
|
||||
|
||||
// Find the LAST significant keyword before cursor
|
||||
// We match all keywords and take the last one
|
||||
const keywordPattern = /\b(SELECT|FROM|JOIN|WHERE|AND|OR|ORDER\s+BY|GROUP\s+BY|HAVING|ON)\b/gi;
|
||||
let lastMatch: RegExpExecArray | null = null;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = keywordPattern.exec(textBefore)) !== null) {
|
||||
lastMatch = match;
|
||||
}
|
||||
|
||||
if (lastMatch) {
|
||||
const keyword = lastMatch[1].toUpperCase().replace(/\s+/g, " ");
|
||||
|
||||
if (keyword === "FROM" || keyword === "JOIN") {
|
||||
return { type: "table" };
|
||||
}
|
||||
|
||||
if (
|
||||
keyword === "SELECT" ||
|
||||
keyword === "WHERE" ||
|
||||
keyword === "AND" ||
|
||||
keyword === "OR" ||
|
||||
keyword === "ORDER BY" ||
|
||||
keyword === "GROUP BY" ||
|
||||
keyword === "HAVING" ||
|
||||
keyword === "ON"
|
||||
) {
|
||||
return { type: "column" };
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "general" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a column schema by name in the tables map
|
||||
*/
|
||||
function findColumnSchema(
|
||||
columnName: string,
|
||||
tableAlias: string | undefined,
|
||||
tables: Map<string, TableSchema>
|
||||
): ColumnSchema | null {
|
||||
if (tableAlias) {
|
||||
// Look in specific table
|
||||
const tableSchema = tables.get(tableAlias.toLowerCase());
|
||||
if (tableSchema) {
|
||||
return tableSchema.columns[columnName] || null;
|
||||
}
|
||||
} else {
|
||||
// Look in all tables
|
||||
for (const tableSchema of tables.values()) {
|
||||
const col = tableSchema.columns[columnName];
|
||||
if (col) {
|
||||
return col;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create completions for enum values from allowedValues
|
||||
*/
|
||||
function createEnumValueCompletions(columnSchema: ColumnSchema): Completion[] {
|
||||
if (!columnSchema.allowedValues || columnSchema.allowedValues.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return columnSchema.allowedValues.map((value) => ({
|
||||
label: `'${value}'`,
|
||||
type: "enum",
|
||||
detail: columnSchema.description || "allowed value",
|
||||
boost: 3, // Highest priority for enum values in value context
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a TSQL-aware autocompletion source
|
||||
*
|
||||
* @param schema - Array of table schemas to use for completions
|
||||
* @returns A CodeMirror completion source function
|
||||
*/
|
||||
export function createTSQLCompletion(
|
||||
schema: TableSchema[]
|
||||
): (context: CompletionContext) => CompletionResult | null {
|
||||
// Pre-compute static completions
|
||||
const keywordCompletions = createKeywordCompletions();
|
||||
const functionCompletions = createFunctionCompletions();
|
||||
const tableCompletions = createTableCompletions(schema);
|
||||
|
||||
return (context: CompletionContext): CompletionResult | null => {
|
||||
// Get the word being typed - include single quotes for value completion
|
||||
const word = context.matchBefore(/[\w.']+/);
|
||||
|
||||
// Don't show completions if no word is being typed and not explicitly triggered
|
||||
if (!word && !context.explicit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const from = word ? word.from : context.pos;
|
||||
const doc = context.state.doc.toString();
|
||||
const queryContext = determineContext(doc, context.pos);
|
||||
|
||||
let options: Completion[] = [];
|
||||
// Track if we need to extend replacement range (e.g., to consume auto-paired closing quote)
|
||||
let to: number | undefined = undefined;
|
||||
|
||||
switch (queryContext.type) {
|
||||
case "table":
|
||||
// After FROM or JOIN, show only tables
|
||||
options = tableCompletions;
|
||||
break;
|
||||
|
||||
case "alias":
|
||||
// After table., show columns for that table
|
||||
if (queryContext.tablePrefix) {
|
||||
const tables = extractTablesFromQuery(doc, schema);
|
||||
const tableSchema = tables.get(queryContext.tablePrefix.toLowerCase());
|
||||
|
||||
if (tableSchema) {
|
||||
options = createColumnCompletions(tableSchema);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "value":
|
||||
// After comparison operator, show enum values if available
|
||||
if (queryContext.columnName) {
|
||||
const tables = extractTablesFromQuery(doc, schema);
|
||||
const columnSchema = findColumnSchema(
|
||||
queryContext.columnName,
|
||||
queryContext.columnTableAlias,
|
||||
tables
|
||||
);
|
||||
|
||||
if (columnSchema) {
|
||||
options = createEnumValueCompletions(columnSchema);
|
||||
// Check if there's a closing quote right after cursor (from auto-pairing)
|
||||
// If so, extend replacement range to include it to avoid 'Completed''
|
||||
const charAfterCursor = context.state.doc.sliceString(context.pos, context.pos + 1);
|
||||
if (charAfterCursor === "'") {
|
||||
to = context.pos + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "column":
|
||||
// After SELECT, WHERE, etc., show columns, functions, and some keywords
|
||||
{
|
||||
const tables = extractTablesFromQuery(doc, schema);
|
||||
|
||||
// Add columns from all tables in the query
|
||||
tables.forEach((tableSchema, alias) => {
|
||||
// If multiple tables, prefix with alias
|
||||
const prefix = tables.size > 1 ? alias : undefined;
|
||||
options.push(...createColumnCompletions(tableSchema, prefix));
|
||||
});
|
||||
|
||||
// Also add functions and relevant keywords
|
||||
options.push(...functionCompletions);
|
||||
options.push(
|
||||
...keywordCompletions.filter((k) =>
|
||||
[
|
||||
"AND",
|
||||
"OR",
|
||||
"NOT",
|
||||
"IN",
|
||||
"LIKE",
|
||||
"ILIKE",
|
||||
"BETWEEN",
|
||||
"IS",
|
||||
"NULL",
|
||||
"AS",
|
||||
"CASE",
|
||||
"WHEN",
|
||||
"THEN",
|
||||
"ELSE",
|
||||
"END",
|
||||
].includes(k.label as string)
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "general":
|
||||
default:
|
||||
// Show everything
|
||||
options = [...tableCompletions, ...functionCompletions, ...keywordCompletions];
|
||||
|
||||
// Also add columns from tables in query
|
||||
{
|
||||
const tables = extractTablesFromQuery(doc, schema);
|
||||
tables.forEach((tableSchema, alias) => {
|
||||
const prefix = tables.size > 1 ? alias : undefined;
|
||||
options.push(...createColumnCompletions(tableSchema, prefix));
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const result: CompletionResult = {
|
||||
from,
|
||||
options,
|
||||
validFor: /^[\w.']*$/,
|
||||
};
|
||||
// Only set 'to' if we need to extend the replacement range
|
||||
if (to !== undefined) {
|
||||
result.to = to;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isValidTSQLQuery, getTSQLError } from "./tsqlLinter";
|
||||
|
||||
describe("tsqlLinter", () => {
|
||||
describe("isValidTSQLQuery", () => {
|
||||
it("should return true for empty queries", () => {
|
||||
expect(isValidTSQLQuery("")).toBe(true);
|
||||
expect(isValidTSQLQuery(" ")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for valid SELECT queries", () => {
|
||||
expect(isValidTSQLQuery("SELECT * FROM users")).toBe(true);
|
||||
expect(isValidTSQLQuery("SELECT id, name FROM users WHERE status = 'active'")).toBe(true);
|
||||
expect(isValidTSQLQuery("SELECT count(*) FROM users GROUP BY status")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for queries with ORDER BY", () => {
|
||||
expect(isValidTSQLQuery("SELECT * FROM users ORDER BY created_at DESC")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for queries with LIMIT", () => {
|
||||
expect(isValidTSQLQuery("SELECT * FROM users LIMIT 10")).toBe(true);
|
||||
expect(isValidTSQLQuery("SELECT * FROM users LIMIT 10 OFFSET 20")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for queries with JOINs", () => {
|
||||
expect(isValidTSQLQuery("SELECT * FROM users JOIN orders ON users.id = orders.user_id")).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
isValidTSQLQuery("SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for invalid syntax", () => {
|
||||
expect(isValidTSQLQuery("SELEC * FROM users")).toBe(false);
|
||||
expect(isValidTSQLQuery("SELECT * FORM users")).toBe(false);
|
||||
expect(isValidTSQLQuery("SELECT FROM users")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for incomplete queries", () => {
|
||||
expect(isValidTSQLQuery("SELECT * FROM")).toBe(false);
|
||||
expect(isValidTSQLQuery("SELECT")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTSQLError", () => {
|
||||
it("should return null for empty queries", () => {
|
||||
expect(getTSQLError("")).toBeNull();
|
||||
expect(getTSQLError(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for valid queries", () => {
|
||||
expect(getTSQLError("SELECT * FROM users")).toBeNull();
|
||||
expect(getTSQLError("SELECT id, name FROM users WHERE id = 1")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return error message for invalid queries", () => {
|
||||
const error = getTSQLError("SELEC * FROM users");
|
||||
expect(error).not.toBeNull();
|
||||
expect(typeof error).toBe("string");
|
||||
});
|
||||
|
||||
it("should include position information in error", () => {
|
||||
const error = getTSQLError("SELECT * FORM users");
|
||||
expect(error).not.toBeNull();
|
||||
// Error message should contain line/column info
|
||||
expect(error).toContain("line");
|
||||
});
|
||||
|
||||
it("should handle missing FROM clause", () => {
|
||||
const error = getTSQLError("SELECT * WHERE id = 1");
|
||||
expect(error).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import type { Diagnostic } from "@codemirror/lint";
|
||||
import type { TableSchema } from "@internal/tsql";
|
||||
import { parseTSQLSelect, SyntaxError, QueryError, validateQuery } from "@internal/tsql";
|
||||
|
||||
/**
|
||||
* Configuration for the TSQL linter
|
||||
*/
|
||||
export interface TSQLLinterConfig {
|
||||
/** Optional schema for validating table/column names */
|
||||
schema?: TableSchema[];
|
||||
/** Delay in milliseconds before running the linter (debouncing) */
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract line and column from a TSQL error message
|
||||
* Error format: "Syntax error at line X:Y: message"
|
||||
*/
|
||||
function parseErrorPosition(message: string): { line: number; column: number } | null {
|
||||
const match = message.match(/at line (\d+):(\d+)/);
|
||||
if (match) {
|
||||
return {
|
||||
line: parseInt(match[1], 10),
|
||||
column: parseInt(match[2], 10),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert line/column to a document position
|
||||
*/
|
||||
function positionToOffset(doc: string, line: number, column: number): number {
|
||||
const lines = doc.split("\n");
|
||||
|
||||
// line is 1-indexed
|
||||
let offset = 0;
|
||||
for (let i = 0; i < line - 1 && i < lines.length; i++) {
|
||||
offset += lines[i].length + 1; // +1 for newline
|
||||
}
|
||||
|
||||
return offset + column;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the end of a word/token at the given position
|
||||
*/
|
||||
function findTokenEnd(doc: string, start: number): number {
|
||||
let end = start;
|
||||
|
||||
// Scan forward until we hit whitespace or end of string
|
||||
while (end < doc.length && /\S/.test(doc[end])) {
|
||||
end++;
|
||||
}
|
||||
|
||||
// If we didn't move, include at least one character
|
||||
if (end === start) {
|
||||
end = Math.min(start + 1, doc.length);
|
||||
}
|
||||
|
||||
return end;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a TSQL linter function for CodeMirror
|
||||
*
|
||||
* This linter uses the TSQL ANTLR parser to detect syntax errors
|
||||
* and optionally validates against a schema.
|
||||
*
|
||||
* @param config - Linter configuration
|
||||
* @returns A linter function for use with CodeMirror's linter extension
|
||||
*/
|
||||
export function createTSQLLinter(
|
||||
config: TSQLLinterConfig = {}
|
||||
): (view: EditorView) => Diagnostic[] {
|
||||
const { schema = [] } = config;
|
||||
|
||||
return (view: EditorView): Diagnostic[] => {
|
||||
const content = view.state.doc.toString().trim();
|
||||
|
||||
// Return no errors for empty content
|
||||
if (!content) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
|
||||
try {
|
||||
// Try to parse the query
|
||||
const ast = parseTSQLSelect(content);
|
||||
|
||||
// If parsing succeeds and we have a schema, run schema validation
|
||||
if (schema.length > 0) {
|
||||
const validationResult = validateQuery(ast, schema);
|
||||
|
||||
for (const issue of validationResult.issues) {
|
||||
// Map validation severity to CodeMirror diagnostic severity
|
||||
const severity: "error" | "warning" | "info" =
|
||||
issue.severity === "error"
|
||||
? "error"
|
||||
: issue.severity === "warning"
|
||||
? "warning"
|
||||
: "info";
|
||||
|
||||
diagnostics.push({
|
||||
from: 0,
|
||||
to: content.length,
|
||||
severity,
|
||||
message: issue.message,
|
||||
source: "tsql",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
const position = parseErrorPosition(error.message);
|
||||
|
||||
let from: number;
|
||||
let to: number;
|
||||
|
||||
if (position) {
|
||||
from = positionToOffset(content, position.line, position.column);
|
||||
to = findTokenEnd(content, from);
|
||||
} else {
|
||||
// If we can't parse the position, highlight the whole query
|
||||
from = 0;
|
||||
to = content.length;
|
||||
}
|
||||
|
||||
// Clean up the error message
|
||||
let message = error.message;
|
||||
// Remove the "Syntax error at line X:Y: " prefix if present
|
||||
message = message.replace(/^Syntax error at line \d+:\d+:\s*/, "");
|
||||
|
||||
diagnostics.push({
|
||||
from,
|
||||
to,
|
||||
severity: "error",
|
||||
message: message,
|
||||
source: "tsql",
|
||||
});
|
||||
} else if (error instanceof QueryError) {
|
||||
// Schema validation errors don't have position info,
|
||||
// so highlight the whole query
|
||||
diagnostics.push({
|
||||
from: 0,
|
||||
to: content.length,
|
||||
severity: "warning",
|
||||
message: error.message,
|
||||
source: "tsql",
|
||||
});
|
||||
} else if (error instanceof Error) {
|
||||
// Unknown error
|
||||
diagnostics.push({
|
||||
from: 0,
|
||||
to: content.length,
|
||||
severity: "error",
|
||||
message: error.message,
|
||||
source: "tsql",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a TSQL query is valid
|
||||
*
|
||||
* @param query - The query to validate
|
||||
* @returns true if the query is valid, false otherwise
|
||||
*/
|
||||
export function isValidTSQLQuery(query: string): boolean {
|
||||
if (!query.trim()) {
|
||||
return true; // Empty queries are considered valid
|
||||
}
|
||||
|
||||
try {
|
||||
parseTSQLSelect(query);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error message for a TSQL query, if any
|
||||
*
|
||||
* @param query - The query to validate
|
||||
* @returns Error message if invalid, null if valid
|
||||
*/
|
||||
export function getTSQLError(query: string): string | null {
|
||||
if (!query.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
parseTSQLSelect(query);
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return "Unknown error";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user