chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -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: 0360 (hue — position on the color wheel: 0=red, 120=green, 240=blue)
* s: 0100 (saturation — 0 is gray, 100 is full color)
* l: 0100 (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: 0360, s: 0100, l: 0100) 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";
}
}