diff --git a/admin/package.json b/admin/package.json index 9869017b9fa..b3312f4f411 100644 --- a/admin/package.json +++ b/admin/package.json @@ -11,12 +11,13 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "build-copy": "pnpm gen:api && tsc && vite build --outDir ../src/templates/admin --emptyOutDir", "preview": "vite preview", - "test": "pnpm gen:api && tsx --test src/api/__tests__/client.test.ts" + "test": "pnpm gen:api && tsx --test 'src/**/__tests__/*.test.ts'" }, "dependencies": { "@radix-ui/react-switch": "^1.2.6", "@tanstack/react-query": "^5.100.10", "@tanstack/react-query-devtools": "^5.100.10", + "jsonc-parser": "^3.3.1", "openapi-fetch": "^0.17.0", "openapi-react-query": "^0.5.4" }, diff --git a/admin/src/App.css b/admin/src/App.css index e69de29bb2d..d7a364c5f72 100644 --- a/admin/src/App.css +++ b/admin/src/App.css @@ -0,0 +1,290 @@ +/* Raw textarea (kept dark to signal "this is code") */ +textarea.settings { + font-family: "Fira Code", "Cascadia Code", "Source Code Pro", monospace; + font-size: 14px; + white-space: pre; + overflow-wrap: normal; + overflow-x: auto; + width: 100%; + height: 500px; + padding: 15px; + background-color: #1e1e1e; + color: #d4d4d4; + line-height: 1.5; + border: 1px solid #333; + resize: vertical; +} +textarea.settings:focus { + outline: 2px solid #007acc; + outline-offset: -1px; +} + +.settings-button-bar { + display: flex; + flex-shrink: 0; + gap: 10px; + margin-top: 15px; +} + +.settings-links { + display: flex; + gap: 20px; + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid #ddd; +} + +/* --- mode toggle --- */ +.settings-mode-toggle { + display: inline-flex; + flex-shrink: 0; + border: 1px solid #ccc; + border-radius: 6px; + overflow: hidden; + margin-bottom: 16px; + background: #fff; +} +.settings-mode-toggle button { + padding: 6px 14px; + border: 0; + background: transparent; + color: #555; + font: inherit; + cursor: pointer; +} +.settings-mode-toggle button.active { + background: var(--etherpad-color, #0f775b); + color: #fff; +} + +/* --- form (light, two-column) --- */ +.settings-form { + font-family: inherit; + font-size: 14px; + color: #333; +} + +.settings-section { + background: #fff; + border: 1px solid #ddd; + border-radius: 6px; + margin-bottom: 18px; + overflow: hidden; +} +.settings-section-header { + padding: 14px 18px; + border-bottom: 1px solid #eee; + background: #fafafa; +} +.settings-section-header h2 { + margin: 0; + font-size: 15px; + font-weight: 600; + color: #222; + letter-spacing: 0.01em; + text-transform: uppercase; +} +.settings-section-header p { + margin: 4px 0 0; + font-size: 13px; + color: #666; + white-space: pre-wrap; +} +.settings-section-body { + padding: 4px 0; +} + +/* Two-column row: label | control, with help below spanning column 2. + * Single-column on narrow widths. */ +.settings-row { + display: grid; + grid-template-columns: minmax(180px, 220px) minmax(0, 1fr); + gap: 6px 18px; + padding: 10px 18px; + align-items: center; + border-top: 1px solid #f4f4f4; +} +.settings-row:first-child { + border-top: 0; +} +.settings-row-label { + font-weight: 600; + color: #333; + word-break: break-word; +} +.settings-row-control { + min-width: 0; +} +.settings-row-help { + grid-column: 2; + margin: 2px 0 0; + font-size: 12.5px; + color: #666; + white-space: pre-wrap; + line-height: 1.4; +} + +@media (max-width: 600px) { + .settings-row { + grid-template-columns: 1fr; + } + .settings-row-help { + grid-column: 1; + } +} + +/* --- nested subsections (objects/arrays inside a section) --- */ +.settings-subsection { + grid-column: 1 / -1; + margin: 8px 18px; + border-left: 3px solid #e2e2e2; + padding-left: 14px; +} +.settings-subsection-header { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 0; +} +.settings-subsection-title { + font-weight: 600; + color: #444; + font-size: 13.5px; + text-transform: uppercase; + letter-spacing: 0.01em; +} +.settings-subsection-help { + color: #777; + font-size: 12.5px; + white-space: pre-wrap; +} +.settings-subsection-body .settings-row { + padding-left: 0; + padding-right: 0; +} + +/* --- leaf widgets (light) --- */ +.settings-widget-string, +.settings-widget-number { + width: 100%; + background: #fff; + color: #222; + border: 1px solid #ccc; + border-radius: 4px; + padding: 6px 10px; + font-family: inherit; + font-size: inherit; +} +.settings-widget-string:focus, +.settings-widget-number:focus { + outline: none; + border-color: var(--etherpad-color, #0f775b); + box-shadow: 0 0 0 3px rgba(15, 119, 91, 0.15); +} +.settings-widget-number.invalid { + border-color: #ce5050; +} +.settings-widget-null { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + background: #f0f0f0; + color: #888; + font-style: italic; + font-size: 12.5px; +} +.settings-widget-env { + display: inline-flex; + align-items: center; + gap: 6px; + background: #f4f8ff; + color: #335; + border: 1px dashed #88a; + border-radius: 12px; + padding: 2px 10px; + font-size: 13px; +} +.settings-widget-env-icon { + font-style: normal; + color: #557; +} +.settings-widget-env-name { + font-family: "Fira Code", monospace; + font-weight: 600; +} +.settings-widget-env-default-label { + color: #557; + font-size: 12px; + text-transform: lowercase; +} +.settings-widget-env-default-input { + background: white; + border: 1px solid #ccd; + border-radius: 6px; + padding: 1px 6px; + font-family: "Fira Code", monospace; + font-size: 13px; + color: #804; + min-width: 80px; + max-width: 240px; + width: auto; +} +.settings-widget-env-default-input:focus { + outline: 2px solid var(--accent, #2b8a3e); + outline-offset: 1px; +} + +/* Radix switch (boolean) */ +.settings-widget-boolean { + appearance: none; + width: 36px; + height: 20px; + border-radius: 999px; + background: #ccc; + border: 0; + position: relative; + cursor: pointer; + transition: background 120ms ease; + padding: 0; +} +.settings-widget-boolean[data-state="checked"] { + background: var(--etherpad-color, #0f775b); +} +.settings-widget-boolean-thumb { + display: block; + width: 16px; + height: 16px; + background: #fff; + border-radius: 50%; + transform: translateX(2px); + transition: transform 120ms ease; + box-shadow: 0 1px 2px rgba(0,0,0,0.2); +} +.settings-widget-boolean[data-state="checked"] .settings-widget-boolean-thumb { + transform: translateX(18px); +} + +/* --- parse error --- */ +.settings-parse-error { + border: 1px solid #d99; + background: #fff5f5; + color: #842; + padding: 14px 18px; + border-radius: 6px; +} +.settings-parse-error-detail { + margin: 8px 0; + white-space: pre-wrap; + font-family: "Fira Code", monospace; + font-size: 12.5px; +} +.settings-parse-error button { + margin-top: 4px; + background: var(--etherpad-color, #0f775b); + color: #fff; + border: 0; + padding: 6px 14px; + border-radius: 4px; + cursor: pointer; + font: inherit; +} diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 62edc7468be..b7ce261b67e 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -63,7 +63,15 @@ export const App = () => { useStore.getState().setShowLoading(false); }); - settingSocket.on('saveprogress', (status) => console.log(status)) + settingSocket.on('saveprogress', (status: string, payload?: {message?: string}) => { + const {setToastState} = useStore.getState(); + if (status === 'saved') { + setToastState({open: true, title: t('admin_settings.toast.saved'), success: true}); + } else { + const detail = payload?.message ?? ''; + setToastState({open: true, title: t('admin_settings.toast.save_failed') + (detail ? ` (${detail})` : ''), success: false}); + } + }) return () => { settingSocket.disconnect(); diff --git a/admin/src/components/IconButton.tsx b/admin/src/components/IconButton.tsx index b73396c827d..2ea3c902dc7 100644 --- a/admin/src/components/IconButton.tsx +++ b/admin/src/components/IconButton.tsx @@ -1,17 +1,14 @@ -import {FC, JSX, ReactElement} from "react"; +import {ButtonHTMLAttributes, FC, JSX, ReactElement} from "react"; -export type IconButtonProps = { - style?: React.CSSProperties, +export type IconButtonProps = Omit, 'title' | 'onClick'> & { icon: JSX.Element, title: string|ReactElement, onClick: ()=>void, - className?: string, - disabled?: boolean } -export const IconButton:FC = ({icon,className,onClick,title, disabled, style})=>{ - return -} + +); diff --git a/admin/src/components/settings/FormView.tsx b/admin/src/components/settings/FormView.tsx new file mode 100644 index 00000000000..19cfad7c8c7 --- /dev/null +++ b/admin/src/components/settings/FormView.tsx @@ -0,0 +1,144 @@ +import { parseTree, getNodePath, type JSONPath, type Node, type ParseError } from 'jsonc-parser'; +import { useStore } from '../../store/store'; +import { useTranslation } from 'react-i18next'; +import { editJsonc } from './jsoncEdit'; +import { JsoncNode } from './JsoncNode'; +import { ParseErrorBanner } from './ParseErrorBanner'; +import { extractAdjacentComments } from './comments'; +import { lookupTemplateComment } from './templateComments'; +import { labelAndHelp } from './labels'; + +type Props = { + onSwitchToRaw: () => void; +}; + +// Parser-error token labels are kept in English — they are technical tokens +// matching the jsonc-parser error enum, not user-facing prose. +const ParseErrorMessage: Record = { + 1: 'Invalid symbol', + 2: 'Invalid number format', + 3: 'Property name expected', + 4: 'Value expected', + 5: 'Colon expected', + 6: 'Comma expected', + 7: 'Closing brace expected', + 8: 'Closing bracket expected', + 9: 'End of file expected', + 16: 'Unexpected end of comment', + 17: 'Unexpected end of string', + 18: 'Unexpected end of number', + 19: 'Invalid unicode', + 20: 'Invalid escape character', + 21: 'Invalid character', +}; + +const formatErrors = (errors: ParseError[]): string => + errors.length === 0 + ? '' + : errors.map(e => `offset ${e.offset}: ${ParseErrorMessage[e.error] ?? 'parse error'}`).join('\n'); + +const Section = ({ title, description, children }: { + title: string; + description?: string; + children: React.ReactNode; +}) => ( +
+
+

{title}

+ {description &&

{description}

} +
+
{children}
+
+); + +const propertyKey = (prop: Node): string => + prop.type === 'property' && prop.children?.[0]?.type === 'string' + ? String(prop.children[0].value) + : ''; + +const propertyComment = (prop: Node, text: string, key: string): string | null => { + const valueNode = prop.children?.[1]; + if (!valueNode) return null; + const live = extractAdjacentComments(text, prop.offset, valueNode.offset, valueNode.length); + if (live.leading) return live.leading; + // Section headings prefer the leading block comment; only fall back to the + // trailing comment if no leading documentation exists at all. + const tmpl = lookupTemplateComment([key]); + if (!tmpl) return null; + return tmpl.leading || tmpl.trailing || null; +}; + +export const FormView = ({ onSwitchToRaw }: Props) => { + const { t } = useTranslation(); + const rawText = useStore(s => s.settings); + + // While settings haven't loaded yet, show an empty busy placeholder so we + // don't flash a parse-error banner for the undefined→'' empty-string case. + if (rawText === undefined) { + return
; + } + + const text = rawText; + + const errors: ParseError[] = []; + const tree = parseTree(text, errors, { allowTrailingComma: true }); + + // Always read the latest text from the store instead of closing over the + // render-time snapshot, so rapid sequential edits don't clobber each other. + const onEdit = (path: JSONPath, value: unknown) => { + const current = useStore.getState().settings ?? ''; + useStore.getState().setSettings(editJsonc(current, path, value)); + }; + + if (!tree || errors.length > 0 || tree.type !== 'object') { + return ; + } + + const generalProps: Node[] = []; + const sectionProps: Node[] = []; + for (const prop of tree.children ?? []) { + if (prop.type !== 'property' || !prop.children?.[1]) continue; + const valueType = prop.children[1].type; + if (valueType === 'object' || valueType === 'array') sectionProps.push(prop); + else generalProps.push(prop); + } + + return ( +
+ {generalProps.length > 0 && ( +
+ {generalProps.map((prop) => { + const propPath = getNodePath(prop); + const propKey = propPath.join('.'); + return ( + + ); + })} +
+ )} + {sectionProps.map((prop) => { + const key = propertyKey(prop); + const { label, help } = labelAndHelp(propertyComment(prop, text, key), key); + const propPath = getNodePath(prop); + const sectionKey = propPath.join('.'); + return ( +
+ +
+ ); + })} +
+ ); +}; diff --git a/admin/src/components/settings/JsoncNode.tsx b/admin/src/components/settings/JsoncNode.tsx new file mode 100644 index 00000000000..52bc41ea2e8 --- /dev/null +++ b/admin/src/components/settings/JsoncNode.tsx @@ -0,0 +1,173 @@ +import type { JSONPath, Node } from 'jsonc-parser'; +import { getNodePath } from 'jsonc-parser'; +import { extractAdjacentComments } from './comments'; +import { matchEnvPlaceholder } from './envPill'; +import { lookupTemplateComment } from './templateComments'; +import { humanize, labelAndHelp } from './labels'; +import { StringInput } from './widgets/StringInput'; +import { NumberInput } from './widgets/NumberInput'; +import { BooleanToggle } from './widgets/BooleanToggle'; +import { NullChip } from './widgets/NullChip'; +import { EnvPill } from './widgets/EnvPill'; + +type Props = { + /** The value node (not the property node). */ + node: Node; + /** The property node, when this value is the value-side of `"key": value`. */ + property?: Node; + text: string; + onEdit: (path: JSONPath, value: unknown) => void; + /** + * When true, this group's own label/header is suppressed because a + * containing Section already rendered it. The group's children still + * render. Used for top-level object/array sections in FormView. + */ + suppressOwnHeader?: boolean; +}; + +const propertyKey = (property: Node | undefined): string => { + if (!property || property.type !== 'property') return ''; + const k = property.children?.[0]; + return k?.type === 'string' ? String(k.value) : ''; +}; + +const renderLeaf = ( + node: Node, + path: JSONPath, + text: string, + onEdit: (path: JSONPath, value: unknown) => void, +) => { + if (node.type === 'string') { + const raw = text.slice(node.offset, node.offset + node.length); + const env = matchEnvPlaceholder(raw); + if (env) { + return ( + onEdit(path, `\${${env.variable}:${d}}`)} + /> + ); + } + return ( + onEdit(path, v)} + /> + ); + } + if (node.type === 'number') { + return ( + onEdit(path, v)} + /> + ); + } + if (node.type === 'boolean') { + return ( + onEdit(path, v)} + /> + ); + } + if (node.type === 'null') { + return ; + } + return null; +}; + +export const JsoncNode = ({ node, property, text, onEdit, suppressOwnHeader }: Props) => { + const path = getNodePath(node); + const key = propertyKey(property); + + const anchor = property ?? node; + const fileComments = extractAdjacentComments(text, anchor.offset, node.offset, node.length); + const tmpl = property ? lookupTemplateComment(path) : null; + const leading = fileComments.leading || tmpl?.leading || ''; + const trailing = fileComments.trailing || tmpl?.trailing || ''; + + // Leading block comments (e.g. /* Description … */ above a key) carry the + // descriptive label — use labelAndHelp's first-sentence split. + // Trailing same-line comments (e.g. "altF9": true, /* focus on … */) are + // brief per-key annotations: the key itself reads as the label, the comment + // belongs in the help slot below the control. See #7740. + let label: string; + let help: string; + if (leading) { + const r = labelAndHelp(leading, key); + label = r.label; + help = [r.help, trailing].filter(Boolean).join(' '); + } else if (trailing) { + label = humanize(key); + help = trailing; + } else { + label = humanize(key); + help = ''; + } + + const rowId = `settings-row-${path.join('.') || 'root'}`; + const helpId = help ? `${rowId}-help` : undefined; + + // ---- Object / array groups ---- + if (node.type === 'object' || node.type === 'array') { + const children = (node.children ?? []).map((child) => { + // For object: child is a property node, drill into its value node. + // For array: child is a value node directly. + if (node.type === 'object') { + const valueNode = child.children?.[1]; + if (!valueNode) return null; + const propPath = getNodePath(child); + const propKey = propPath.join('.'); + return ( + + ); + } + // Array element: use stable JSON path as key. + const childPath = getNodePath(child); + return ; + }); + + if (suppressOwnHeader || !property) { + // Render children flat — the containing Section provides the label. + return <>{children}; + } + + // Nested group within a section: render as a sub-section with its own + // heading, indented under its parent. + return ( +
+
+ {label} + {help && {help}} +
+
{children}
+
+ ); + } + + // ---- Leaf row ---- + return ( +
+ +
+ {renderLeaf(node, path, text, onEdit)} +
+ {help && ( +

{help}

+ )} +
+ ); +}; diff --git a/admin/src/components/settings/ModeToggle.tsx b/admin/src/components/settings/ModeToggle.tsx new file mode 100644 index 00000000000..0f87f62ce84 --- /dev/null +++ b/admin/src/components/settings/ModeToggle.tsx @@ -0,0 +1,36 @@ +import { Trans, useTranslation } from 'react-i18next'; + +export type Mode = 'form' | 'raw'; + +type Props = { + mode: Mode; + onChange: (mode: Mode) => void; +}; + +export const ModeToggle = ({ mode, onChange }: Props) => { + const { t } = useTranslation(); + return ( +
+ + +
+ ); +}; diff --git a/admin/src/components/settings/ParseErrorBanner.tsx b/admin/src/components/settings/ParseErrorBanner.tsx new file mode 100644 index 00000000000..2a63333f80d --- /dev/null +++ b/admin/src/components/settings/ParseErrorBanner.tsx @@ -0,0 +1,16 @@ +import { Trans } from 'react-i18next'; + +type Props = { + message: string; + onSwitchToRaw: () => void; +}; + +export const ParseErrorBanner = ({ message, onSwitchToRaw }: Props) => ( +
+ +
{message}
+ +
+); diff --git a/admin/src/components/settings/__tests__/comments.test.ts b/admin/src/components/settings/__tests__/comments.test.ts new file mode 100644 index 00000000000..d7fe1f6e068 --- /dev/null +++ b/admin/src/components/settings/__tests__/comments.test.ts @@ -0,0 +1,93 @@ +// admin/src/components/settings/__tests__/comments.test.ts +// +// Regression coverage for https://github.com/ether/etherpad/issues/7740. +// A previous version of findLeading treated any line ending in `*/` as a +// comment continuation; a JSON line like +// "altF9": true, /* focus on the File Menu and/or editbar */ +// then leaked into the next sibling's "leading comment", which the form +// view rendered as the row label. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { extractAdjacentComments } from '../comments.ts'; +import { humanize, labelAndHelp } from '../labels.ts'; + +const padShortcutText = `{ + "padShortcutEnabled" : { + "altF9": true, /* focus on the File Menu and/or editbar */ + "altC": true, /* focus on the Chat window */ + "cmdShift2": true, /* shows a gritter popup showing a line author */ + "delete": true, + "return": true, + "esc": true, /* in mozilla versions 14-19 avoid reconnecting pad */ + "cmdS": true /* save a revision */ + } +}`; + +const offsetsFor = (text: string, key: string) => { + const keyOffset = text.indexOf(`"${key}"`); + const valOffset = text.indexOf('true', keyOffset); + return { keyOffset, valOffset, valLength: 4 }; +}; + +test('does not absorb prior JSON line with trailing comment as leading', () => { + const { keyOffset, valOffset, valLength } = offsetsFor(padShortcutText, 'altC'); + const { leading, trailing } = + extractAdjacentComments(padShortcutText, keyOffset, valOffset, valLength); + assert.equal(leading, ''); + assert.equal(trailing, 'focus on the Chat window'); +}); + +test('does not accumulate multiple prior trailing-comment lines', () => { + const { keyOffset, valOffset, valLength } = offsetsFor(padShortcutText, 'cmdShift2'); + const { leading } = + extractAdjacentComments(padShortcutText, keyOffset, valOffset, valLength); + assert.equal(leading, ''); +}); + +test('leading is empty when prior line is plain code (no trailing comment)', () => { + const { keyOffset, valOffset, valLength } = offsetsFor(padShortcutText, 'return'); + const { leading } = + extractAdjacentComments(padShortcutText, keyOffset, valOffset, valLength); + assert.equal(leading, ''); +}); + +test('still recognises JSDoc-style leading block comments', () => { + const text = `{ + /* + * Pad Shortcut Keys + */ + "padShortcutEnabled" : {} +}`; + const keyOffset = text.indexOf('"padShortcutEnabled"'); + const valOffset = text.indexOf('{}'); + const { leading, trailing } = extractAdjacentComments(text, keyOffset, valOffset, 2); + assert.equal(leading, 'Pad Shortcut Keys'); + assert.equal(trailing, ''); +}); + +test('still recognises single-line // leading comments', () => { + const text = `{ + // Whether to enable the thing. + "thing": true +}`; + const keyOffset = text.indexOf('"thing"'); + const valOffset = text.indexOf('true'); + const { leading } = extractAdjacentComments(text, keyOffset, valOffset, 4); + assert.equal(leading, 'Whether to enable the thing.'); +}); + +test('humanize spaces camelCase and capitalises only the first word', () => { + assert.equal(humanize('requireAuthentication'), 'Require authentication'); + assert.equal(humanize('altF9'), 'Alt f9'); +}); + +test('labelAndHelp splits a leading block at the first sentence boundary', () => { + const { label, help } = labelAndHelp( + 'Name your instance! Optional context follows.', + 'title', + ); + assert.equal(label, 'Name your instance!'); + assert.equal(help, 'Optional context follows.'); +}); diff --git a/admin/src/components/settings/comments.ts b/admin/src/components/settings/comments.ts new file mode 100644 index 00000000000..2e1f3ae700a --- /dev/null +++ b/admin/src/components/settings/comments.ts @@ -0,0 +1,95 @@ +// admin/src/components/settings/comments.ts +// +// Given the source text and a property's `keyOffset` (jsonc-parser's +// Node.offset for the property node), extract: +// - `leading`: the contiguous run of `/* */` or `//` comments +// immediately above the key. At most one blank line is allowed +// between the comment block and the key. +// - `trailing`: a single `// ...` or `/* ... */` on the same line +// as the value, after any trailing comma. + +export type AdjacentComments = { + leading: string; + trailing: string; +}; + +const LINE_BREAK = /\r?\n/; + +const stripCommentMarkers = (raw: string): string => { + // raw is a concatenation of comment tokens separated by newlines. + // Drop /* */ and // markers and trim each line. + return raw + .split(LINE_BREAK) + .map(line => line + .replace(/^\s*\/\*+/, '') + .replace(/\*+\/\s*$/, '') + .replace(/^\s*\*\s?/, '') + .replace(/^\s*\/\/\s?/, '') + .trim()) + .filter(line => line.length > 0) + .join(' '); +}; + +const findLeading = (text: string, keyOffset: number): string => { + // Walk backwards from keyOffset to the start of the line containing it. + const lineStart = text.lastIndexOf('\n', keyOffset - 1) + 1; + let cursor = lineStart; + let blankLineSeen = false; + const collected: string[] = []; + + while (cursor > 0) { + // Look at the previous line. + const prevLineEnd = cursor - 1; // the '\n' before our cursor's line + const prevLineStart = text.lastIndexOf('\n', prevLineEnd - 1) + 1; + const line = text.slice(prevLineStart, prevLineEnd); + const trimmed = line.trim(); + + if (trimmed === '') { + if (blankLineSeen) break; + blankLineSeen = true; + cursor = prevLineStart; + continue; + } + + // A JSON line with a trailing `/* … */` comment (e.g. + // "altF9": true, /* focus on the File Menu and/or editbar */ + // ) ends with `*/` but is NOT a comment continuation. Only treat a + // previous line as part of the leading comment block if it structurally + // opens (`/*`), continues (`*` — JSDoc style, covers ` */` close), or + // is a single-line `//` comment. This matches the comment styles used + // in settings.json.template; #7740. + const isComment = + trimmed.startsWith('//') || + trimmed.startsWith('/*') || + trimmed.startsWith('*'); + + if (!isComment) break; + + collected.unshift(line); + cursor = prevLineStart; + } + + return stripCommentMarkers(collected.join('\n')); +}; + +const findTrailing = (text: string, valueOffset: number, valueLength: number): string => { + // Trailing comments only exist on the same line as the value. If there's + // no newline after the value the file has no line structure (e.g. minified + // settings.json) and `//` inside any later string literal would otherwise + // be matched as a comment. + const lineEnd = text.indexOf('\n', valueOffset + valueLength); + if (lineEnd === -1) return ''; + const slice = text.slice(valueOffset + valueLength, lineEnd); + const m = /,?\s*(\/\/.*|\/\*.*?\*\/)\s*$/.exec(slice); + return m ? stripCommentMarkers(m[1]) : ''; +}; + +export const extractAdjacentComments = ( + text: string, + keyOffset: number, + valueOffset: number, + valueLength: number, +): AdjacentComments => ({ + leading: findLeading(text, keyOffset), + trailing: findTrailing(text, valueOffset, valueLength), +}); diff --git a/admin/src/components/settings/envPill.ts b/admin/src/components/settings/envPill.ts new file mode 100644 index 00000000000..2e31f9c51aa --- /dev/null +++ b/admin/src/components/settings/envPill.ts @@ -0,0 +1,21 @@ +// admin/src/components/settings/envPill.ts +// +// Detect `"${VAR}"` and `"${VAR:default}"` placeholders inside the raw +// slice of a string node. The slice INCLUDES the surrounding quotes, +// because jsonc-parser exposes node.offset/length over the whole literal. + +export type EnvPlaceholder = { + variable: string; + defaultValue: string | null; +}; + +const RE = /^"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::([^}]*))?\}"$/; + +export const matchEnvPlaceholder = (rawSlice: string): EnvPlaceholder | null => { + const m = RE.exec(rawSlice); + if (!m) return null; + return { + variable: m[1], + defaultValue: m[2] ?? null, + }; +}; diff --git a/admin/src/components/settings/jsoncEdit.ts b/admin/src/components/settings/jsoncEdit.ts new file mode 100644 index 00000000000..f69303accf2 --- /dev/null +++ b/admin/src/components/settings/jsoncEdit.ts @@ -0,0 +1,11 @@ +// admin/src/components/settings/jsoncEdit.ts +import { applyEdits, modify, type JSONPath } from 'jsonc-parser'; + +const FORMATTING = { + formattingOptions: { tabSize: 2, insertSpaces: true, eol: '\n' as const }, +}; + +export const editJsonc = (text: string, path: JSONPath, value: unknown): string => { + const edits = modify(text, path, value, FORMATTING); + return edits.length === 0 ? text : applyEdits(text, edits); +}; diff --git a/admin/src/components/settings/labels.ts b/admin/src/components/settings/labels.ts new file mode 100644 index 00000000000..df588483969 --- /dev/null +++ b/admin/src/components/settings/labels.ts @@ -0,0 +1,46 @@ +// Pretty-label derivation. The first sentence of a key's documentation +// comment is its label; the rest stays in the help-text slot. When no +// comment exists, fall back to a humanized key name (camelCase → "Camel +// case"). + +const SENTENCE_END = /[.!?](\s|$)/; + +export const humanize = (key: string): string => { + if (!key) return key; + // Split camelCase / PascalCase / snake_case / kebab-case + const words = key + .replace(/[_-]+/g, ' ') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .toLowerCase() + .trim() + .split(/\s+/); + if (words.length === 0) return key; + return words[0].charAt(0).toUpperCase() + words[0].slice(1) + + (words.length > 1 ? ' ' + words.slice(1).join(' ') : ''); +}; + +const splitFirstSentence = (text: string): { head: string; rest: string } => { + const trimmed = text.trim(); + const m = SENTENCE_END.exec(trimmed); + if (!m) return { head: trimmed, rest: '' }; + const cut = m.index + 1; // include the punctuation + return { + head: trimmed.slice(0, cut).trim(), + rest: trimmed.slice(cut).trim(), + }; +}; + +export const labelAndHelp = ( + comment: string | null | undefined, + key: string, +): { label: string; help: string } => { + if (!comment || !comment.trim()) { + return { label: humanize(key), help: '' }; + } + const { head, rest } = splitFirstSentence(comment); + return { + label: head || humanize(key), + help: rest, + }; +}; diff --git a/admin/src/components/settings/templateComments.ts b/admin/src/components/settings/templateComments.ts new file mode 100644 index 00000000000..df9de0bee7c --- /dev/null +++ b/admin/src/components/settings/templateComments.ts @@ -0,0 +1,50 @@ +// Build a fallback path → comment map from `settings.json.template`. The live +// settings.json is per-developer and often lacks comments; the template is the +// authoritative source of per-key documentation. + +import { parseTree, type JSONPath, type Node } from 'jsonc-parser'; +import { extractAdjacentComments, type AdjacentComments } from './comments'; + +// Injected by Vite at build time from settings.json.template (see vite.config.ts). +// Inlining at config time avoids widening the dev server's filesystem allowlist +// to the repo root, which would expose settings.json/credentials.json over the +// dev server. +declare const __SETTINGS_TEMPLATE__: string; +const templateText: string = __SETTINGS_TEMPLATE__; + +const pathKey = (path: JSONPath): string => path.map(String).join('.'); + +const buildMap = (text: string): Map => { + const map = new Map(); + const tree = parseTree(text, [], { allowTrailingComma: true }); + if (!tree) return map; + + const walk = (node: Node, path: JSONPath) => { + if (node.type === 'object') { + for (const prop of node.children ?? []) { + if (prop.type !== 'property' || !prop.children || prop.children.length < 2) continue; + const keyNode = prop.children[0]; + const valueNode = prop.children[1]; + if (keyNode.type !== 'string') continue; + const childPath = [...path, String(keyNode.value)]; + const adjacent = extractAdjacentComments( + text, prop.offset, valueNode.offset, valueNode.length, + ); + if (adjacent.leading || adjacent.trailing) { + map.set(pathKey(childPath), adjacent); + } + walk(valueNode, childPath); + } + } else if (node.type === 'array') { + (node.children ?? []).forEach((child, i) => walk(child, [...path, i])); + } + }; + + walk(tree, []); + return map; +}; + +const templateMap = buildMap(templateText); + +export const lookupTemplateComment = (path: JSONPath): AdjacentComments | null => + templateMap.get(pathKey(path)) ?? null; diff --git a/admin/src/components/settings/widgets/BooleanToggle.tsx b/admin/src/components/settings/widgets/BooleanToggle.tsx new file mode 100644 index 00000000000..d2d91fadfdc --- /dev/null +++ b/admin/src/components/settings/widgets/BooleanToggle.tsx @@ -0,0 +1,20 @@ +import * as Switch from '@radix-ui/react-switch'; +import type { JSONPath } from 'jsonc-parser'; + +type Props = { + value: boolean; + path: JSONPath; + onChange: (next: boolean) => void; +}; + +export const BooleanToggle = ({ value, path, onChange }: Props) => ( + + + +); diff --git a/admin/src/components/settings/widgets/EnvPill.tsx b/admin/src/components/settings/widgets/EnvPill.tsx new file mode 100644 index 00000000000..1440d9d0fa6 --- /dev/null +++ b/admin/src/components/settings/widgets/EnvPill.tsx @@ -0,0 +1,57 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { JSONPath } from 'jsonc-parser'; +import type { EnvPlaceholder } from '../envPill'; + +type Props = { + placeholder: EnvPlaceholder; + path: JSONPath; + onChange: (newDefault: string) => void; +}; + +const sanitize = (s: string) => s.replace(/[}]/g, ''); + +export const EnvPill = ({ placeholder, path, onChange }: Props) => { + const { t } = useTranslation(); + const initial = placeholder.defaultValue ?? ''; + const [draft, setDraft] = useState(initial); + const focused = useRef(false); + + // Sync local draft from upstream (server canonicalisation, raw-mode edit) + // only while the input isn't focused so we don't trample mid-typing. + useEffect(() => { + if (!focused.current) setDraft(initial); + }, [initial]); + + const id = `field-${path.join('.')}`; + const testid = `env-${path.join('.')}`; + + return ( + + + {placeholder.variable} + + {t('admin_settings.env_pill.default_label')} + + { focused.current = true; }} + onBlur={() => { focused.current = false; }} + onChange={e => { + const v = sanitize(e.target.value); + setDraft(v); + onChange(v); + }} + /> + + ); +}; diff --git a/admin/src/components/settings/widgets/NullChip.tsx b/admin/src/components/settings/widgets/NullChip.tsx new file mode 100644 index 00000000000..9c705fcdd49 --- /dev/null +++ b/admin/src/components/settings/widgets/NullChip.tsx @@ -0,0 +1,10 @@ +import type { JSONPath } from 'jsonc-parser'; + +type Props = { path: JSONPath }; + +export const NullChip = ({ path }: Props) => ( + null +); diff --git a/admin/src/components/settings/widgets/NumberInput.tsx b/admin/src/components/settings/widgets/NumberInput.tsx new file mode 100644 index 00000000000..d339e97a80e --- /dev/null +++ b/admin/src/components/settings/widgets/NumberInput.tsx @@ -0,0 +1,48 @@ +import { useEffect, useRef, useState } from 'react'; +import type { JSONPath } from 'jsonc-parser'; + +type Props = { + value: number; + path: JSONPath; + onChange: (next: number) => void; +}; + +export const NumberInput = ({ value, path, onChange }: Props) => { + const [draft, setDraft] = useState(String(value)); + const [invalid, setInvalid] = useState(false); + const focusedRef = useRef(false); + + // Sync draft when the prop value changes (e.g. after a server round-trip + // canonicalises the number) — but only when the input is not focused so we + // don't stomp on the user while they are typing. + useEffect(() => { + if (!focusedRef.current) { + setDraft(String(value)); + setInvalid(false); + } + }, [value]); + + return ( + { focusedRef.current = true; }} + onBlur={() => { focusedRef.current = false; }} + onChange={e => { + const next = e.target.value; + setDraft(next); + const parsed = Number(next); + if (next.trim() !== '' && Number.isFinite(parsed)) { + setInvalid(false); + onChange(parsed); + } else { + setInvalid(true); + } + }} + /> + ); +}; diff --git a/admin/src/components/settings/widgets/StringInput.tsx b/admin/src/components/settings/widgets/StringInput.tsx new file mode 100644 index 00000000000..dd3efe51591 --- /dev/null +++ b/admin/src/components/settings/widgets/StringInput.tsx @@ -0,0 +1,19 @@ +import type { JSONPath } from 'jsonc-parser'; + +type Props = { + value: string; + path: JSONPath; + onChange: (next: string) => void; +}; + +export const StringInput = ({ value, path, onChange }: Props) => ( + onChange(e.target.value)} + /> +); diff --git a/admin/src/index.css b/admin/src/index.css index 936aaf8401f..ed43c5240f8 100644 --- a/admin/src/index.css +++ b/admin/src/index.css @@ -272,16 +272,9 @@ td, th { display: flex; flex-direction: column; gap: 20px; - height: 100%; + min-height: 100%; } -.settings { - flex-grow: max(1, 1); - outline: none; - width: 100%; - resize: none; - font-family: monospace; -} #response { display: inline; diff --git a/admin/src/pages/SettingsPage.tsx b/admin/src/pages/SettingsPage.tsx index 96cc1fe6fe6..7511b8e399f 100644 --- a/admin/src/pages/SettingsPage.tsx +++ b/admin/src/pages/SettingsPage.tsx @@ -1,51 +1,123 @@ -import {useStore} from "../store/store.ts"; -import {isJSONClean, cleanComments} from "../utils/utils.ts"; -import {Trans, useTranslation} from "react-i18next"; -import {IconButton} from "../components/IconButton.tsx"; -import {RotateCw, Save} from "lucide-react"; - -export const SettingsPage = ()=>{ - const settingsSocket = useStore(state=>state.settingsSocket) - const settings = cleanComments(useStore(state=>state.settings)) - const {t} = useTranslation() - - return
-

-