diff --git a/web/package-lock.json b/web/package-lock.json index c05f4d0..fdfee82 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -24,7 +24,8 @@ "recharts": "^3.10.1", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", - "tailwind-merge": "^3.7.0" + "tailwind-merge": "^3.7.0", + "yaml": "^2.9.1" }, "devDependencies": { "@tailwindcss/typography": "^0.5.20", @@ -1417,6 +1418,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1433,6 +1435,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1449,6 +1452,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1465,6 +1469,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1481,6 +1486,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1497,6 +1503,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1513,6 +1520,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1529,6 +1537,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1545,6 +1554,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1561,6 +1571,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1577,6 +1588,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1593,6 +1605,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1609,6 +1622,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1625,6 +1639,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1641,6 +1656,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1657,6 +1673,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1673,6 +1690,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1689,6 +1707,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1705,6 +1724,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1721,6 +1741,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5093,6 +5114,21 @@ "node": ">=20.11" } }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/web/package.json b/web/package.json index 3d4c87b..7fd8a57 100644 --- a/web/package.json +++ b/web/package.json @@ -30,7 +30,8 @@ "recharts": "^3.10.1", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", - "tailwind-merge": "^3.7.0" + "tailwind-merge": "^3.7.0", + "yaml": "^2.9.1" }, "devDependencies": { "@tailwindcss/typography": "^0.5.20", diff --git a/web/packages/ui/src/components/code-block.tsx b/web/packages/ui/src/components/code-block.tsx index f3d48be..d35d5d0 100644 --- a/web/packages/ui/src/components/code-block.tsx +++ b/web/packages/ui/src/components/code-block.tsx @@ -88,6 +88,13 @@ const COLOR: Record, string> = { w: "var(--code-keyword)", } +/** 一行的高亮,与 CodeBlock 画法相同。token 拼回去就是原文,所以可以垫在透明 textarea 下面做可编辑的高亮。 */ +export function highlightLine(line: string, language: CodeBlockLanguage = "shell"): React.ReactNode[] { + return tokenizeLine(line, language).map((tok, j) => + tok.t ? {tok.v} : {tok.v}, + ) +} + export function CodeBlock({ value, language = "shell", @@ -121,9 +128,7 @@ export function CodeBlock({ ) : null} - {tokenizeLine(line, language).map((tok, j) => - tok.t ? {tok.v} : {tok.v}, - )} + {highlightLine(line, language)} {line === "" ? "​" : null} diff --git a/web/packages/ui/src/index.ts b/web/packages/ui/src/index.ts index 1424731..379a176 100644 --- a/web/packages/ui/src/index.ts +++ b/web/packages/ui/src/index.ts @@ -63,7 +63,7 @@ export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGr export { Sheet, SheetBody, SheetCancelButton, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, useSheetClose } from "./components/ui/sheet" export type { SheetSize } from "./components/ui/sheet" export { FieldNotice } from "./components/floating-field" -export { CodeBlock } from "./components/code-block" +export { CodeBlock, highlightLine } from "./components/code-block" export { CodeToolbar } from "./components/code-toolbar" export type { CodeBlockProps, CodeBlockLanguage } from "./components/code-block" export type { CodeToolbarProps } from "./components/code-toolbar" diff --git a/web/src/modules/inferences/components/DeployForm.tsx b/web/src/modules/inferences/components/DeployForm.tsx index 3d7ed80..1c42b77 100644 --- a/web/src/modules/inferences/components/DeployForm.tsx +++ b/web/src/modules/inferences/components/DeployForm.tsx @@ -13,7 +13,6 @@ import { FieldHint, FieldInput, FieldSelect, - FieldTextarea, FloatingField, FormSection, Input, @@ -23,7 +22,7 @@ import { cn, } from "@modelsphere/ui"; import { Plus, RefreshCw, X } from "lucide-react"; -import type { ClusterInfo } from "@swiss/lib/api"; +import type { ClusterInfo, HelmValues } from "@swiss/lib/api"; import { cacheFieldError, type CacheCard, type CacheDefaults } from "@swiss/lib/cache"; import { unlistedProducts, type GPUCapacity } from "@swiss/lib/gpu"; import { effective, imageChoices, type Form, type Toleration } from "@swiss/components/DeploySettings"; @@ -31,6 +30,7 @@ import { MAX_VALUES, rejectKey } from "@swiss/lib/routeDirectives"; import { FALLBACK_ROUTE_OPTIONS, routeDirectives, runtimeValue, type RouteDirective, type RouteOptions } from "@/modules/inferences/routeOptions"; import { useT } from "@/modules/inferences/i18n"; import { isAdaptiveOption, toggleAdaptive } from "../adaptive-concurrency"; +import { OverrideYaml } from "./OverrideYaml"; import { NAMED_VALUES, extraValue, fracOutOfRange, serviceIdError, type Section } from "@/modules/inferences/deploy-lib"; const EFFECTS = ["", "NoSchedule", "PreferNoSchedule", "NoExecute"]; @@ -56,9 +56,11 @@ export interface DeployFormProps { idPrefix: string; routeOptions?: RouteOptions | null; catalogRoute?: string; + // Composes the form with these overrides, storing nothing, for the Override YAML editor. + loadValues?: (edits: string) => Promise; } -export function DeployForm({ form, onChange, upgrade, submitted, target, cluster, defaults, gpuOptions, gpuListed, gpuResources, capacity, expanded, onExpand, idPrefix, routeOptions, catalogRoute }: DeployFormProps) { +export function DeployForm({ form, onChange, upgrade, submitted, target, cluster, defaults, gpuOptions, gpuListed, gpuResources, capacity, expanded, onExpand, idPrefix, routeOptions, catalogRoute, loadValues }: DeployFormProps) { const t = useT(); const on = effective(form); const f = (k: string) => t(`deploy.fields.${k}`); @@ -247,9 +249,7 @@ export function DeployForm({ form, onChange, upgrade, submitted, target, cluster {sw("skipSchemaValidation", f("skipSchemaValidation"))} - - - + onChange({ edits })} load={loadValues} /> , )} diff --git a/web/src/modules/inferences/components/DeploySheet.tsx b/web/src/modules/inferences/components/DeploySheet.tsx index 9ed8024..5cc4658 100644 --- a/web/src/modules/inferences/components/DeploySheet.tsx +++ b/web/src/modules/inferences/components/DeploySheet.tsx @@ -41,7 +41,7 @@ import { detailPath, sloEnabled } from "@/modules/inferences/lib"; import { SECTIONS, chartChoice, defaultServiceId, pipelineState, serviceIdError, whatMoves, type Section } from "@/modules/inferences/deploy-lib"; import { DeployForm } from "@/modules/inferences/components/DeployForm"; import { ExperimentalBadge } from "@/modules/inferences/components/InstallMethodLabel"; -import { AdaptivePlanChangedError, composeAdaptivePlan } from "../adaptive-concurrency"; +import { AdaptivePlanChangedError, composeAdaptivePlan, withAdaptiveConcurrency } from "../adaptive-concurrency"; import { readRouteOptions } from "@/modules/inferences/api"; export type DeployTarget = @@ -186,16 +186,22 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( // A product the variant does not list is one swissd takes only as forced. const forceGpuProducts = unlistedProducts(v?.requires.gpuProduct, form.gpuProducts).length > 0; const change = (patch: Partial
) => setForm((f) => ({ ...f, ...patch })); + // What a compose of f sends; the Override YAML editor's preview sends the same. + const requestFor = (f: Form) => planRequest( + { ...f, serviceId: f.serviceId || (upgrade ? f.serviceId : defaultServiceId(target.model)) }, + upgrade + ? { model: recorded!.model, version: version || undefined, variant: variant || undefined, catalog: moveTo || undefined, chartVersion: chartVersion || undefined, fromRelease: target.release, cacheUnsupported: !!cache.unsupported, forceGpuProducts } + : { model: target.model, version, variant: keptVariant, catalog: target.catalog, chartVersion: chartVersion || undefined, cacheUnsupported: !!cache.unsupported, forceGpuProducts }, + profile.data?.profile, + ); + const loadValues = (edits: string) => { + const f = { ...form, edits }; + return deployApi.helmValues(withAdaptiveConcurrency(requestFor(f), f, routeOptions.data)); + }; const compose = useMutation({ mutationFn: async () => { try { - return await composeAdaptivePlan(planRequest( - { ...form, serviceId: form.serviceId || (upgrade ? form.serviceId : defaultServiceId(target.model)) }, - upgrade - ? { model: recorded!.model, version: version || undefined, variant: variant || undefined, catalog: moveTo || undefined, chartVersion: chartVersion || undefined, fromRelease: target.release, cacheUnsupported: !!cache.unsupported, forceGpuProducts } - : { model: target.model, version, variant: keptVariant, catalog: target.catalog, chartVersion: chartVersion || undefined, cacheUnsupported: !!cache.unsupported, forceGpuProducts }, - profile.data?.profile, - ), form, routeOptions.data, deployApi.plan, catalogName); + return await composeAdaptivePlan(requestFor(form), form, routeOptions.data, deployApi.plan, catalogName); } catch (error) { if (error instanceof AdaptivePlanChangedError) throw new Error(t("deploy.fields.adaptiveChanged")); throw error; @@ -438,6 +444,7 @@ function DeploySheetBody({ target, onClose }: { target: DeployTarget; onClose: ( routeOptions={routeOptions.isSuccess ? routeOptions.data : undefined} catalogRoute={catalogRoute} idPrefix={ID_PREFIX} + loadValues={readOnly ? undefined : loadValues} />
diff --git a/web/src/modules/inferences/components/KeySearch.tsx b/web/src/modules/inferences/components/KeySearch.tsx new file mode 100644 index 0000000..9b90c03 --- /dev/null +++ b/web/src/modules/inferences/components/KeySearch.tsx @@ -0,0 +1,70 @@ +import { useMemo, useState } from "react"; +import { InputGroup, InputGroupAddon, InputGroupInput, cn } from "@modelsphere/ui"; +import { Search } from "lucide-react"; +import { useT } from "@/modules/inferences/i18n"; +import { searchKeys, type KeyLine } from "../override-yaml"; + +// A search over a YAML text's keys; picking one jumps to its line. Arrow keys +// move through the matches and Enter takes the highlighted one. +export function KeySearch({ keys, onJump }: { keys: KeyLine[]; onJump: (line: number) => void }) { + const t = useT(); + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const [active, setActive] = useState(0); + const hits = useMemo(() => searchKeys(keys, query), [keys, query]); + const jump = (key?: KeyLine) => { + if (!key) return; + setOpen(false); + onJump(key.line); + }; + return ( +
+ + + + + (setQuery(e.target.value), setActive(0), setOpen(true))} + onFocus={() => setOpen(true)} + onBlur={() => setOpen(false)} + onKeyDown={(e) => { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + setOpen(true); + setActive((i) => Math.max(0, Math.min(hits.length - 1, i + (e.key === "ArrowDown" ? 1 : -1)))); + } else if (e.key === "Enter") { + e.preventDefault(); + jump(hits[active]); + } + }} + /> + + {open && query.trim() && ( +
    + {hits.length ? ( + hits.map((key, i) => ( +
  • (e.preventDefault(), jump(key))} + onMouseEnter={() => setActive(i)} + > + {key.path} + {key.line + 1} +
  • + )) + ) : ( +
  • {t("deploy.overrides.noKey")}
  • + )} +
+ )} +
+ ); +} diff --git a/web/src/modules/inferences/components/OverrideYaml.test.tsx b/web/src/modules/inferences/components/OverrideYaml.test.tsx new file mode 100644 index 0000000..f9418d8 --- /dev/null +++ b/web/src/modules/inferences/components/OverrideYaml.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { OverrideYaml } from "./OverrideYaml"; +import en from "@/modules/inferences/locales/en-US.json"; +import zh from "@/modules/inferences/locales/zh-CN.json"; + +const render = (value: string) => renderToStaticMarkup( {}} />); + +describe("Override YAML in the form", () => { + it("shows the overridden paths in a read-only box beside its edit button", () => { + const html = render("replicaCount: 3\nimage:\n tag: v2\n"); + expect(html).not.toContain("]*readOnly[^>]*value="replicaCount, image.tag"|]*value="replicaCount, image.tag"[^>]*readOnly/); + expect(html).toMatch(/aria-label="(Edit|编辑)"/); + }); + + it("says when the overrides are not valid YAML", () => { + const html = render("a: ["); + expect(html.includes(en.deploy.overrides.invalid) || html.includes(zh.deploy.overrides.invalid)).toBe(true); + }); +}); + +describe("YAML textarea", () => { + it("draws the text highlighted under a transparent textarea holding it", async () => { + const { YamlTextarea } = await import("./YamlTextarea"); + const html = renderToStaticMarkup( {}} />); + expect(html).toMatch(/]*>[\s\S]*replicaCount: 2[\s\S]*<\/textarea>/); + expect(html).toContain('style="color:var(--code-comment)"># chart'); + expect(html).toContain('style="color:var(--code-key)">replicaCount'); + }); +}); + +describe("key search", () => { + it("is a search box, its matches listed only once typed into", async () => { + const { KeySearch } = await import("./KeySearch"); + const html = renderToStaticMarkup( {}} />); + expect(html).toMatch(/placeholder="(Search keys|搜索 key)"/); + expect(html).not.toContain('role="listbox"'); + }); +}); diff --git a/web/src/modules/inferences/components/OverrideYaml.tsx b/web/src/modules/inferences/components/OverrideYaml.tsx new file mode 100644 index 0000000..e14c087 --- /dev/null +++ b/web/src/modules/inferences/components/OverrideYaml.tsx @@ -0,0 +1,302 @@ +import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { + Alert, + AlertDescription, + Button, + CodeBlock, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FieldInput, + FloatingField, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@modelsphere/ui"; +import { Pencil } from "lucide-react"; +import type { HelmValues } from "@swiss/lib/api"; +import { useT } from "@/modules/inferences/i18n"; +import { KeySearch } from "./KeySearch"; +import { YamlTextarea, type YamlTextareaHandle } from "./YamlTextarea"; +import { computedYaml, keyLines, overrideEdit, overridePaths, parseValues, readYaml, valuesYaml, type KeyLine, type Values } from "../override-yaml"; + +const message = (error: unknown) => (error instanceof Error ? error.message : String(error)); + +// The Advanced section's Override YAML: a summary in the form, its editor in a dialog. +export function OverrideYaml({ value, onChange, load }: { + value: string; + onChange: (edits: string) => void; + // Composes the form with these overrides, storing nothing. + load?: (edits: string) => Promise; +}) { + const t = useT(); + const f = (k: string) => t(`deploy.fields.${k}`); + const [open, setOpen] = useState(false); + const paths = overridePaths(value); + const edit = () => setOpen(true); + return ( + <> + + + + } + > + {/* Read-only: a click, Enter or Space opens the editor. */} + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + edit(); + } + }} + className="cursor-pointer font-mono text-xs" + /> + + {open && setOpen(false)} onSave={(edits) => (onChange(edits), setOpen(false))} />} + + ); +} + +interface Loaded { + values: HelmValues; + // The overrides the values were composed with. + edits: Values; + // What the release would render from, as YAML and parsed. + computed: string; + base: Values; +} + +function OverrideDialog({ value, load, onClose, onSave }: { + value: string; + load?: (edits: string) => Promise; + onClose: () => void; + onSave: (edits: string) => void; +}) { + const t = useT(); + const o = (k: string, params?: Record) => t(`deploy.overrides.${k}`, params); + const [draft, setDraft] = useState(value); + const [loaded, setLoaded] = useState(); + const [final, setFinal] = useState(""); + // Parsing a large values.yaml takes tens of milliseconds, so it waits for a + // pause in typing rather than running on every keystroke. + const settledDraft = useSettled(draft); + const draftRead = useMemo(() => readYaml(settledDraft), [settledDraft]); + const draftError = draftRead.error; + const settledFinal = useSettled(final, loaded); + const finalRead = useMemo(() => readYaml(settledFinal), [settledFinal]); + const fetch = useMutation({ + mutationFn: async (): Promise => { + const values = await load!(draft); + const computed = computedYaml(values.defaults, values.overrides); + return { values, edits: parseValues(draft), computed, base: parseValues(computed) }; + }, + onSuccess: (l) => { + setLoaded(l); + setFinal(l.computed); + setTab("final"); + }, + }); + const [tab, setTab] = useState("final"); + const draftEditor = useRef(null); + const finalEditor = useRef(null); + const defaultsBox = useRef(null); + const overridesBox = useRef(null); + const loadedKeys = useMemo(() => loaded && { defaults: keyLines(loaded.values.defaults), overrides: keyLines(loaded.values.overrides) }, [loaded]); + // The search runs over whichever text is on screen. + const view: { keys: KeyLine[]; jump: (line: number) => void } = !loaded || !loadedKeys + ? { keys: draftRead.keys, jump: (line) => draftEditor.current?.jumpTo(line) } + : tab === "defaults" + ? { keys: loadedKeys.defaults, jump: (line) => jumpInBlock(defaultsBox.current, line) } + : tab === "overrides" + ? { keys: loadedKeys.overrides, jump: (line) => jumpInBlock(overridesBox.current, line) } + : { keys: finalRead.keys, jump: (line) => finalEditor.current?.jumpTo(line) }; + const edit = useMemo( + () => loaded && (finalRead.values ? overrideEdit(loaded.base, loaded.edits, finalRead.values) : { error: finalRead.error ?? "" }), + [loaded, finalRead], + ); + const blocked = !edit || "error" in edit; + // Saving reads the text as it is now, not the settled read behind it. + const saveDraft = () => { + if (!readYaml(draft).error) onSave(draft); + }; + const finish = () => { + const now = loaded && readYaml(final).values; + if (!loaded || !now) return; + const e = overrideEdit(loaded.base, loaded.edits, now); + onSave(e.changes ? valuesYaml(e.edits) : draft); + }; + const [size, setSize] = useState(); + + return ( + !open && onClose()}> + + + {t("deploy.fields.edits")} + + {o(loaded ? "layersDescription" : "description")} + + + + + {!loaded ? ( + <> + + {(draftError || fetch.error) && ( + + {draftError ?? message(fetch.error)} + + )} + + ) : ( + <> + {/* One at a time, in merge order: what the chart ships, what this deploy sets over it, and the result to edit. */} + setTab(v as Tab)} className="min-h-0 min-w-0 flex-1 gap-2"> + + {o("chart")} + {o("swiss")} + {o("final")} + + +

{o("chartHint")}

+
+ +
+
+ +

{o("swissHint")}

+
+ +
+
+ +

{o("finalHint")}

+ +
+
+ {edit && "error" in edit ? ( + + {edit.error} + + ) : edit && ( +

{o("changes", { n: edit.changes })}

+ )} + + )} + + + {!loaded ? ( + <> + + {load && ( + + )} + + + ) : ( + <> + + + + + )} + + +
+
+ ); +} + +const TAB = "flex min-h-0 flex-1 flex-col gap-1.5"; + +// Its props do not change while the editor is typed in, so it is not redrawn. +const ReadOnlyYaml = memo(CodeBlock); + +// value once it has not changed for ms. A new reset takes value at once: a +// load replaces the text rather than typing into it. +function useSettled(value: T, reset?: unknown, ms = 200): T { + const [settled, setSettled] = useState(value); + const [last, setLast] = useState(reset); + if (reset !== last) { + setLast(reset); + setSettled(value); + } + useEffect(() => { + const id = setTimeout(() => setSettled(value), ms); + return () => clearTimeout(id); + }, [value, ms]); + return reset !== last ? value : settled; +} + +type Tab = "defaults" | "overrides" | "final"; + +// Scrolls a CodeBlock to a 0-based line, a third of the way down, and flashes it. +function jumpInBlock(box: HTMLElement | null, line: number) { + const pre = box?.querySelector("pre"); + const row = pre?.children[line] as HTMLElement | undefined; + if (!pre || !row) return; + pre.scrollTop += row.getBoundingClientRect().top - pre.getBoundingClientRect().top - pre.clientHeight / 3; + row.animate([{ backgroundColor: "color-mix(in oklab, var(--primary) 25%, transparent)" }, { backgroundColor: "transparent" }], { duration: 1600, easing: "ease-out" }); +} + +interface Size { + w: number; + h: number; +} + +const MIN: Size = { w: 480, h: 360 }; + +// The bottom-right corner drags the dialog's size. It stays centred, so it +// grows by twice the drag, which keeps the corner under the pointer; the +// viewport caps it in CSS. +function ResizeHandle({ onResize }: { onResize: (size: Size) => void }) { + const start = useRef(undefined); + return ( +
{ + const box = e.currentTarget.parentElement!.getBoundingClientRect(); + start.current = { x: e.clientX, y: e.clientY, w: box.width, h: box.height }; + e.currentTarget.setPointerCapture(e.pointerId); + }} + onPointerMove={(e) => { + const s = start.current; + if (s) onResize({ w: Math.max(MIN.w, s.w + 2 * (e.clientX - s.x)), h: Math.max(MIN.h, s.h + 2 * (e.clientY - s.y)) }); + }} + onPointerUp={() => (start.current = undefined)} + > + + + +
+ ); +} diff --git a/web/src/modules/inferences/components/YamlTextarea.tsx b/web/src/modules/inferences/components/YamlTextarea.tsx new file mode 100644 index 0000000..d073a23 --- /dev/null +++ b/web/src/modules/inferences/components/YamlTextarea.tsx @@ -0,0 +1,80 @@ +import { memo, useImperativeHandle, useRef } from "react"; +import { cn, highlightLine } from "@modelsphere/ui"; + +const CODE = { fontSize: "var(--code-font-size)", lineHeight: "var(--code-line-height)" }; + +export interface YamlTextareaHandle { + // Selects a 0-based line and scrolls it a third of the way down. + jumpTo: (line: number) => void; +} + +// A YAML textarea, highlighted as CodeBlock draws: the text is drawn under a +// transparent textarea of the same font and padding, its scroll following the +// textarea's. The extra bottom and right room lets it scroll as far as the +// textarea does beside its scrollbars. +export function YamlTextarea({ value, onChange, invalid, className, ref, ...aria }: { + value: string; + onChange: (value: string) => void; + invalid?: boolean; + className?: string; + "aria-label"?: string; + ref?: React.Ref; +}) { + const under = useRef(null); + const area = useRef(null); + useImperativeHandle(ref, () => ({ + jumpTo(line) { + // The layer under the text has a row per line, so its offset is the line's. + const ta = area.current; + const row = under.current?.firstElementChild?.children[line] as HTMLElement | undefined; + if (!ta || !row) return; + const lines = ta.value.split("\n"); + const start = lines.slice(0, line).reduce((n, l) => n + l.length + 1, 0); + const text = lines[line] ?? ""; + ta.focus({ preventScroll: true }); + ta.setSelectionRange(start + text.length - text.trimStart().length, start + text.length); + ta.scrollTop = Math.max(0, row.offsetTop - ta.clientHeight / 3); + }, + }), []); + return ( +
+
+        
+ {value.split("\n").map((line, i) => ( + + ))} +
+
+