✨ FEAT: Add RelationSelect component and enhance entry editing
- Introduced a new RelationSelect component for managing relations in forms. - Updated EntryDetailModal and ObjectTable components to utilize RelationSelect for relation fields, allowing for inline editing and improved user experience. - Enhanced API route for fetching relation options based on user input. - Refactored EditableCell to support relation editing with dropdowns, improving data entry efficiency. - Added new API endpoint for fetching lightweight options for relation dropdowns. This update streamlines the handling of relational data within the workspace, enhancing the overall functionality and user interface.
This commit is contained in:
parent
c2a302b582
commit
d68b9350c6
@ -0,0 +1,90 @@
|
||||
import { duckdbQuery, duckdbPath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type ObjectRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
display_field?: string;
|
||||
};
|
||||
|
||||
type FieldRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
function sqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
function resolveDisplayField(
|
||||
obj: ObjectRow,
|
||||
fields: FieldRow[],
|
||||
): string {
|
||||
if (obj.display_field) {return obj.display_field;}
|
||||
const nameField = fields.find(
|
||||
(f) => /\bname\b/i.test(f.name) || /\btitle\b/i.test(f.name),
|
||||
);
|
||||
if (nameField) {return nameField.name;}
|
||||
const textField = fields.find((f) => f.type === "text");
|
||||
if (textField) {return textField.name;}
|
||||
return fields[0]?.name ?? "id";
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/workspace/objects/[name]/entries/options
|
||||
* Returns lightweight { options: [{ id, label }] } for relation dropdowns.
|
||||
* Supports optional ?q= search parameter.
|
||||
*/
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const { name } = await params;
|
||||
|
||||
if (!duckdbPath()) {
|
||||
return Response.json({ error: "DuckDB not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||
return Response.json({ error: "Invalid object name" }, { status: 400 });
|
||||
}
|
||||
|
||||
const objects = duckdbQuery<ObjectRow>(
|
||||
`SELECT * FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
|
||||
);
|
||||
if (objects.length === 0) {
|
||||
return Response.json({ error: `Object '${name}' not found` }, { status: 404 });
|
||||
}
|
||||
const obj = objects[0];
|
||||
|
||||
const fields = duckdbQuery<FieldRow>(
|
||||
`SELECT * FROM fields WHERE object_id = '${sqlEscape(obj.id)}' ORDER BY sort_order`,
|
||||
);
|
||||
const displayFieldName = resolveDisplayField(obj, fields);
|
||||
|
||||
// Optional search filter
|
||||
const url = new URL(req.url);
|
||||
const query = url.searchParams.get("q")?.trim() ?? "";
|
||||
|
||||
// Fetch entries with their display field value
|
||||
const rows = duckdbQuery<{ entry_id: string; label: string | null }>(
|
||||
`SELECT e.id as entry_id, ef.value as label
|
||||
FROM entries e
|
||||
LEFT JOIN entry_fields ef ON ef.entry_id = e.id
|
||||
LEFT JOIN fields f ON f.id = ef.field_id AND f.name = '${sqlEscape(displayFieldName)}'
|
||||
WHERE e.object_id = '${sqlEscape(obj.id)}'
|
||||
${query ? `AND (ef.value IS NOT NULL AND LOWER(ef.value) LIKE '%${sqlEscape(query.toLowerCase())}%')` : ""}
|
||||
ORDER BY ef.value ASC NULLS LAST
|
||||
LIMIT 200`,
|
||||
);
|
||||
|
||||
const options = rows.map((r) => ({
|
||||
id: r.entry_id,
|
||||
label: r.label || r.entry_id,
|
||||
}));
|
||||
|
||||
return Response.json({ options, displayField: displayFieldName });
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { RelationSelect } from "./relation-select";
|
||||
|
||||
|
||||
// --- Types ---
|
||||
@ -511,6 +512,22 @@ export function EntryDetailModal({
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{editingField === field.name ? (
|
||||
field.type === "relation" && field.related_object_name ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div className="flex-1">
|
||||
<RelationSelect
|
||||
relatedObjectName={field.related_object_name}
|
||||
value={String(value ?? "")}
|
||||
multiple={field.relationship_type === "many_to_many"}
|
||||
onChange={(v) => { handleSaveField(field.name, v); }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button type="button" onClick={() => setEditingField(null)} className="px-2 py-1 text-xs rounded-lg flex-shrink-0" style={{ color: "var(--color-text-muted)", border: "1px solid var(--color-border)" }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); handleSaveField(field.name, editValue); }}
|
||||
className="flex items-center gap-2 w-full"
|
||||
@ -556,16 +573,17 @@ export function EntryDetailModal({
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
className={`flex-1 ${!["relation", "user"].includes(field.type) ? "cursor-pointer hover:opacity-80" : ""}`}
|
||||
className={`flex-1 ${!["user"].includes(field.type) ? "cursor-pointer hover:opacity-80" : ""}`}
|
||||
onClick={() => {
|
||||
if (!["relation", "user"].includes(field.type)) {
|
||||
if (!["user"].includes(field.type)) {
|
||||
setEditingField(field.name);
|
||||
setEditValue(String(value ?? ""));
|
||||
}
|
||||
}}
|
||||
title={!["relation", "user"].includes(field.type) ? "Click to edit" : undefined}
|
||||
title={!["user"].includes(field.type) ? "Click to edit" : undefined}
|
||||
>
|
||||
<FieldValue
|
||||
value={value}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
||||
import { type ColumnDef, type CellContext } from "@tanstack/react-table";
|
||||
import { DataTable, type RowAction } from "./data-table";
|
||||
import { RelationSelect } from "./relation-select";
|
||||
|
||||
/* ─── Types ─── */
|
||||
|
||||
@ -182,8 +183,9 @@ function EditableCell({
|
||||
if (editing && inputRef.current) {inputRef.current.focus();}
|
||||
}, [editing]);
|
||||
|
||||
// Non-editable types: render read-only
|
||||
const isEditable = !["relation", "user"].includes(field.type);
|
||||
// Non-editable types: render read-only (relations are now editable via dropdown)
|
||||
const isEditable = !["user"].includes(field.type);
|
||||
const isRelation = field.type === "relation" && !!field.related_object_name;
|
||||
|
||||
const save = useCallback(async (val: string) => {
|
||||
try {
|
||||
@ -214,7 +216,6 @@ function EditableCell({
|
||||
|
||||
// Read-only display for non-editable types
|
||||
if (!isEditable) {
|
||||
if (field.type === "relation") {return <RelationCell value={initialValue} field={field} relationLabels={relationLabels} onNavigate={onNavigate} />;}
|
||||
if (field.type === "user") {return <UserCell value={initialValue} members={members} />;}
|
||||
return <span className="truncate block max-w-[300px]">{String(initialValue ?? "")}</span>;
|
||||
}
|
||||
@ -222,6 +223,26 @@ function EditableCell({
|
||||
// Editing mode — Excel-style seamless inline editing
|
||||
if (editing) {
|
||||
let editInput;
|
||||
if (isRelation) {
|
||||
return (
|
||||
<div
|
||||
className="-mx-3 -my-2 px-3 py-2"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
boxShadow: "inset 0 0 0 2px var(--color-accent)",
|
||||
}}
|
||||
>
|
||||
<RelationSelect
|
||||
relatedObjectName={field.related_object_name!}
|
||||
value={String(initialValue ?? "")}
|
||||
multiple={field.relationship_type === "many_to_many"}
|
||||
onChange={(v) => { save(v); setEditing(false); }}
|
||||
variant="inline"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (field.type === "enum" && field.enum_values) {
|
||||
editInput = (
|
||||
<select
|
||||
@ -283,6 +304,20 @@ function EditableCell({
|
||||
|
||||
// Display mode — double-click to edit
|
||||
const displayValue = initialValue;
|
||||
|
||||
// Relation fields: show chips with double-click to edit
|
||||
if (isRelation) {
|
||||
return (
|
||||
<div
|
||||
onDoubleClick={() => setEditing(true)}
|
||||
className="cursor-cell min-h-[1.5em]"
|
||||
title="Double-click to edit"
|
||||
>
|
||||
<RelationCell value={initialValue} field={field} relationLabels={relationLabels} onNavigate={onNavigate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onDoubleClick={() => setEditing(true)}
|
||||
@ -685,6 +720,14 @@ function AddEntryModal({
|
||||
}}
|
||||
placeholder={field.name}
|
||||
/>
|
||||
) : isRelation && field.related_object_name ? (
|
||||
<RelationSelect
|
||||
relatedObjectName={field.related_object_name}
|
||||
value={values[field.name] ?? ""}
|
||||
multiple={field.relationship_type === "many_to_many"}
|
||||
onChange={(v) => updateField(field.name, v)}
|
||||
placeholder={`Select ${field.related_object_name}...`}
|
||||
/>
|
||||
) : isUser ? (
|
||||
<select
|
||||
value={values[field.name] ?? ""}
|
||||
@ -712,7 +755,7 @@ function AddEntryModal({
|
||||
color: "var(--color-text)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
placeholder={isRelation ? `${field.related_object_name ?? field.name} ID` : field.name}
|
||||
placeholder={field.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
282
apps/web/app/components/workspace/relation-select.tsx
Normal file
282
apps/web/app/components/workspace/relation-select.tsx
Normal file
@ -0,0 +1,282 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
|
||||
type Option = { id: string; label: string };
|
||||
|
||||
type RelationSelectProps = {
|
||||
/** Name of the related object (e.g. "companies") */
|
||||
relatedObjectName: string;
|
||||
/** Current value — single ID string or JSON array of IDs */
|
||||
value: string;
|
||||
/** many_to_one = single select, many_to_many = multi-select */
|
||||
multiple?: boolean;
|
||||
/** Called when selection changes; value is a single ID or JSON array */
|
||||
onChange: (value: string) => void;
|
||||
/** Placeholder when nothing is selected */
|
||||
placeholder?: string;
|
||||
/** Visual variant: "modal" for form fields, "inline" for table cells */
|
||||
variant?: "modal" | "inline";
|
||||
/** Auto-focus the search input on mount */
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
|
||||
function parseRelationValue(value: string | null | undefined): string[] {
|
||||
if (!value) {return [];}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {return [];}
|
||||
if (trimmed.startsWith("[")) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {return parsed.map(String).filter(Boolean);}
|
||||
} catch { /* not JSON */ }
|
||||
}
|
||||
return [trimmed];
|
||||
}
|
||||
|
||||
export function RelationSelect({
|
||||
relatedObjectName,
|
||||
value,
|
||||
multiple = false,
|
||||
onChange,
|
||||
placeholder,
|
||||
variant = "modal",
|
||||
autoFocus = false,
|
||||
}: RelationSelectProps) {
|
||||
const [open, setOpen] = useState(autoFocus);
|
||||
const [search, setSearch] = useState("");
|
||||
const [options, setOptions] = useState<Option[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>(() => parseRelationValue(value));
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Sync external value changes
|
||||
useEffect(() => {
|
||||
setSelectedIds(parseRelationValue(value));
|
||||
}, [value]);
|
||||
|
||||
// Fetch options when dropdown opens or search changes
|
||||
const fetchOptions = useCallback(async (query: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (query) {params.set("q", query);}
|
||||
const res = await fetch(
|
||||
`/api/workspace/objects/${encodeURIComponent(relatedObjectName)}/entries/options?${params}`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setOptions(data.options ?? []);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoading(false); }
|
||||
}, [relatedObjectName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchOptions(search);
|
||||
}
|
||||
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
if (!open) {return;}
|
||||
if (debounceRef.current) {clearTimeout(debounceRef.current);}
|
||||
debounceRef.current = setTimeout(() => fetchOptions(search), 250);
|
||||
return () => { if (debounceRef.current) {clearTimeout(debounceRef.current);} };
|
||||
}, [search]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Focus input when opening
|
||||
useEffect(() => {
|
||||
if (open && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) {return;}
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
if (multiple) {
|
||||
const next = selectedIds.includes(id)
|
||||
? selectedIds.filter((x) => x !== id)
|
||||
: [...selectedIds, id];
|
||||
setSelectedIds(next);
|
||||
onChange(next.length === 0 ? "" : JSON.stringify(next));
|
||||
} else {
|
||||
setSelectedIds([id]);
|
||||
onChange(id);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeId = (id: string) => {
|
||||
const next = selectedIds.filter((x) => x !== id);
|
||||
setSelectedIds(next);
|
||||
if (multiple) {
|
||||
onChange(next.length === 0 ? "" : JSON.stringify(next));
|
||||
} else {
|
||||
onChange("");
|
||||
}
|
||||
};
|
||||
|
||||
// Find labels for currently selected IDs (from loaded options, fallback to ID)
|
||||
const selectedLabels = selectedIds.map((id) => {
|
||||
const opt = options.find((o) => o.id === id);
|
||||
return { id, label: opt?.label ?? id };
|
||||
});
|
||||
|
||||
const isInline = variant === "inline";
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full">
|
||||
{/* Trigger / display area */}
|
||||
<div
|
||||
onClick={() => setOpen(!open)}
|
||||
className={`w-full flex items-center flex-wrap gap-1 cursor-pointer min-h-[1.5em] ${isInline ? "text-xs" : "px-3 py-2 text-sm rounded-lg"}`}
|
||||
style={isInline ? {} : {
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{selectedLabels.length > 0 ? (
|
||||
selectedLabels.map(({ id, label }) => (
|
||||
<span
|
||||
key={id}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium"
|
||||
style={{
|
||||
background: "rgba(96, 165, 250, 0.1)",
|
||||
color: "#60a5fa",
|
||||
border: "1px solid rgba(96, 165, 250, 0.2)",
|
||||
}}
|
||||
>
|
||||
<span className="truncate max-w-[160px]">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); removeId(id); }}
|
||||
className="ml-0.5 hover:opacity-70"
|
||||
style={{ color: "#60a5fa" }}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={{ color: "var(--color-text-muted)", opacity: 0.5 }}>
|
||||
{placeholder ?? `Select ${relatedObjectName}...`}
|
||||
</span>
|
||||
)}
|
||||
{/* Chevron */}
|
||||
<svg
|
||||
width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
className="ml-auto flex-shrink-0"
|
||||
style={{ color: "var(--color-text-muted)", transform: open ? "rotate(180deg)" : undefined, transition: "transform 0.15s" }}
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Dropdown */}
|
||||
{open && (
|
||||
<div
|
||||
className="absolute z-50 mt-1 w-full rounded-lg shadow-lg overflow-hidden"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
maxHeight: 260,
|
||||
}}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="p-2 border-b" style={{ borderColor: "var(--color-border)" }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={`Search ${relatedObjectName}...`}
|
||||
className="w-full px-2.5 py-1.5 text-xs rounded-md outline-none"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {setOpen(false);}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Options list */}
|
||||
<div className="overflow-y-auto" style={{ maxHeight: 200 }}>
|
||||
{loading ? (
|
||||
<div className="px-3 py-4 text-center text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
Loading...
|
||||
</div>
|
||||
) : options.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{search ? "No matches found" : "No entries"}
|
||||
</div>
|
||||
) : (
|
||||
options.map((opt) => {
|
||||
const isSelected = selectedIds.includes(opt.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={opt.id}
|
||||
onClick={() => toggleSelect(opt.id)}
|
||||
className="w-full text-left px-3 py-2 text-xs flex items-center gap-2 transition-colors"
|
||||
style={{
|
||||
background: isSelected ? "var(--color-accent-light, rgba(96, 165, 250, 0.08))" : "transparent",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isSelected) {(e.currentTarget.style.background = "var(--color-surface-hover, rgba(255,255,255,0.04))");}}}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = isSelected ? "var(--color-accent-light, rgba(96, 165, 250, 0.08))" : "transparent"; }}
|
||||
>
|
||||
{/* Checkbox for multi-select */}
|
||||
{multiple && (
|
||||
<span
|
||||
className="w-3.5 h-3.5 rounded border flex items-center justify-center flex-shrink-0"
|
||||
style={{
|
||||
borderColor: isSelected ? "var(--color-accent)" : "var(--color-border)",
|
||||
background: isSelected ? "var(--color-accent)" : "transparent",
|
||||
}}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{/* Single-select check indicator */}
|
||||
{!multiple && isSelected && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="truncate">{opt.label}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -76,8 +76,17 @@ const WEB_CHAT_DIR = join(homedir(), ".openclaw", "web-chat");
|
||||
const INDEX_FILE = join(WEB_CHAT_DIR, "index.json");
|
||||
|
||||
// ── Singleton registry ──
|
||||
// Store on globalThis so the Map survives Next.js HMR reloads in dev mode.
|
||||
// Without this, hot-reloading any server module resets the Map, orphaning
|
||||
// running child processes and dropping SSE streams mid-flight.
|
||||
|
||||
const activeRuns = new Map<string, ActiveRun>();
|
||||
const GLOBAL_KEY = "__openclaw_activeRuns" as const;
|
||||
|
||||
const activeRuns: Map<string, ActiveRun> =
|
||||
(globalThis as Record<string, unknown>)[GLOBAL_KEY] as Map<string, ActiveRun> ??
|
||||
new Map<string, ActiveRun>();
|
||||
|
||||
(globalThis as Record<string, unknown>)[GLOBAL_KEY] = activeRuns;
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user