Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .codacy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
exclude_paths:
# build/dev tooling, not shipped; the completions generator writes its
# output to a caller-supplied path by design (the drift test uses a temp file)
- frontend/scripts/**
33 changes: 33 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,36 @@
wb-mqtt-homeui (2.250.0) stable; urgency=medium

* Rules editor: TypeScript support. .ts rule files can be created and
edited with an in-browser TypeScript language service: live error
squiggles while typing, hover type info and type-aware completions,
seeded with the controller's installed wb-rules API declarations
(Editor.GetTypes RPC, vendored fallback for older firmware). The
completion popup shows each entry's signature and JSDoc, and a
parameter-hint tooltip appears while typing a call's arguments (the
@valtown/codemirror-ts adapter surfaces neither on its own).
* Plain .js rules get the same language service: completions and hover
reflect the running engine's API, and type problems such as a wrong-typed
write to a known control (dev["buzzer/enabled"] = 123) are shown as
advisory warnings, mirroring the controller's own check.
* Device references are typed from the controller's live device list:
dev["device/control"], getControl(...) and changed(...) know the
control's type and complete the existing names.
* Promise misuse is flagged while typing: a promise used as a condition,
a forgotten await in an endless loop, await of a non-promise.
* The controller's own background check verdict (Editor.Check RPC) and
runtime errors from the rules console (a rejected control write, an
exception with a file:line) are rendered inline at the reported lines,
de-duplicated against the local check and held back while the buffer
has unsaved edits.
* Diagnostics no longer depend on hovering: a short message after the
offending line, a marker in the gutter, a "N problems" button that opens
the problems panel (F8 jumps to the next one). The load error of the
file is shown in the same gutter.
* Requires wb-rules >= 2.47 for the TypeScript engine support; the
editor degrades gracefully on older firmware.

-- Evgeny Boger <boger@wirenboard.com> Mon, 24 Aug 2026 18:55:23 +0000

wb-mqtt-homeui (2.249.0) stable; urgency=medium

* Show the device template parameter variant matching the device firmware
Expand Down
2 changes: 1 addition & 1 deletion debian/control
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Depends: ${shlibs:Depends},
wb-homeui-backend (= ${binary:Version})
Recommends: wb-mqtt-logs (>= 1.2.0),
wb-device-manager,
wb-rules (>= 2.37.0~~)
wb-rules (>= 3.0.0~~)
Suggests: wb-mqtt-confed (>= 1.4.0),
Breaks: wb-mqtt-confed (<< 1.0.3),
wb-mqtt-db (<< 1.5),
Expand Down
3 changes: 3 additions & 0 deletions frontend/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ const getCustomConfig = (cfg) => {
const customIgnores = [
'src/custom.d.ts',
'src/components/json-editor/extensions/*',
// vendored wb-rules declarations and the completion list generated from them
'src/stores/rules/autocomplete/wb-rules.d.ts',
'src/stores/rules/autocomplete/globals-generated.ts',
];
const { ignores, ...rest } = cfg.at(0);

Expand Down
33 changes: 31 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"test:watch": "vitest",
"lint": "npx eslint --cache --cache-location .eslintcache",
"lint:fix": "npx eslint --fix",
"check:types": "tsc --noEmit"
"check:types": "tsc --noEmit",
"generate:completions": "node scripts/generate-wb-rules-completions.mjs"
},
"repository": {
"type": "git",
Expand All @@ -25,6 +26,7 @@
"dependencies": {
"@codemirror/lang-javascript": "6.2.5",
"@codemirror/lang-json": "6.0.2",
"@codemirror/lint": "6.9.6",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.0",
"@daypicker/react": "10.0.1",
Expand All @@ -34,7 +36,9 @@
"@dnd-kit/utilities": "3.2.2",
"@floating-ui/react": "0.27.19",
"@rpldy/uploady": "1.13.0",
"@typescript/vfs": "1.6.4",
"@uiw/react-codemirror": "4.25.10",
"@valtown/codemirror-ts": "2.3.1",
"@wirenboard/json-editor": "2.5.3-wb19",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-web-links": "0.12.0",
Expand Down Expand Up @@ -66,6 +70,7 @@
"react-select": "5.10.2",
"react-sortablejs": "6.1.4",
"sortablejs": "1.15.7",
"typescript": "6.0.3",
"use-file-picker": "2.1.4",
"xterm": "5.3.0"
},
Expand All @@ -87,7 +92,6 @@
"globals": "17.6.0",
"happy-dom": "20.9.0",
"rimraf": "6.1.3",
"typescript": "6.0.3",
"use-resize-observer": "9.1.0",
"vite": "8.0.13",
"vite-plugin-svgr": "5.2.0",
Expand Down
101 changes: 101 additions & 0 deletions frontend/scripts/generate-wb-rules-completions.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Generates autocomplete/globals-generated.ts from autocomplete/wb-rules.d.ts (synced
// from the wb-rules repo); run `npm run generate:completions` after updating it.
import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';
import ts from 'typescript';

// single-quoted to match the repo eslint style, so regenerating never dirties the tree
const q = (s) => `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, '\\\'')}'`;

const here = path.dirname(url.fileURLToPath(import.meta.url));
const dtsPath = path.join(here, '../src/stores/rules/autocomplete/wb-rules.d.ts');
// an explicit output path serves the drift test (globals-generated.test.ts)
const outPath = process.argv[2]
? path.resolve(process.argv[2])
: path.join(here, '../src/stores/rules/autocomplete/globals-generated.ts');

const source = ts.createSourceFile('wb-rules.d.ts', fs.readFileSync(dtsPath, 'utf8'), ts.ScriptTarget.Latest);
const printer = ts.createPrinter({ removeComments: true });

const seen = new Set();
const completions = [];

const signatureOf = (node) => {
const text = printer.printNode(ts.EmitHint.Unspecified, node, source)
.replace(/^declare\s+/, '')
.replace(/\s+/g, ' ')
.trim();
return text.length > 60 ? `${text.slice(0, 57)}...` : text;
};

const snippetFor = (name, params) => {
if (params.length === 0) return `${name}()`;
const args = params
.filter((p) => !p.questionToken && !p.dotDotDotToken)
.map((p, i) => `\${${i + 1}:${p.name.getText(source)}}`);
return `${name}(${args.join(', ')})`;
};

for (const stmt of source.statements) {
if (ts.isFunctionDeclaration(stmt) && stmt.name) {
const name = stmt.name.text;
if (seen.has(name)) continue; // keep the first overload only
seen.add(name);
completions.push({
label: name,
type: 'function',
detail: signatureOf(stmt),
snippet: snippetFor(name, stmt.parameters),
});
} else if (ts.isVariableStatement(stmt)) {
for (const decl of stmt.declarationList.declarations) {
const name = decl.name.getText(source);
if (seen.has(name)) continue;
seen.add(name);
// a callable global declared as a type literal (PersistentStorage): complete it like a function
const callSig = decl.type && ts.isTypeLiteralNode(decl.type)
? decl.type.members.find((m) => ts.isCallSignatureDeclaration(m))
: undefined;
if (callSig) {
const sigText = printer.printNode(ts.EmitHint.Unspecified, callSig, source)
.replace(/^\(/, '(')
.replace(/;\s*$/, '')
.replace(/\s+/g, ' ')
.trim();
const detail = `function ${name}${sigText}`;
completions.push({
label: name,
type: 'function',
detail: detail.length > 60 ? `${detail.slice(0, 57)}...` : detail,
snippet: snippetFor(name, callSig.parameters),
});
continue;
}
completions.push({
label: name,
type: 'variable',
detail: decl.type ? signatureOf(decl.type) : '',
});
}
}
}

const body = completions.map((c) => {
const detail = q(c.detail);
return c.snippet && c.snippet !== `${c.label}()`
? ` snippetCompletion(${q(c.snippet)}, { label: ${q(c.label)}, type: '${c.type}', detail: ${detail} }),`
: ` { label: ${q(c.label)}, type: '${c.type}', detail: ${detail}${c.snippet ? `, apply: ${q(c.snippet)}` : ''} },`;
}).join('\n');

// Codacy flags non-literal fs paths; the repo-level exclude is not honored here
// eslint-disable-next-line
fs.writeFileSync(outPath, `// GENERATED from wb-rules.d.ts — do not edit by hand.
// Regenerate with: npm run generate:completions
import { snippetCompletion, type Completion } from '@codemirror/autocomplete';

export const wbRulesGlobals: Completion[] = [
${body}
];
`);
console.log(`generated ${completions.length} completions -> ${outPath}`);
104 changes: 104 additions & 0 deletions frontend/src/components/code-editor/code-editor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// @vitest-environment happy-dom
// @uiw/react-codemirror reconfigures the whole extension stack whenever extensions or
// onChange change identity; CodeEditor must hand it identity-stable props
import { EditorView, runScopeHandlers } from '@codemirror/view';
import { render } from '@testing-library/react';
import { forwardRef } from 'react';
import { CodeEditor } from './code-editor';

const capturedProps: any[] = [];
vi.mock('@uiw/react-codemirror', () => ({
__esModule: true,
default: forwardRef((props: any, _ref: any) => {
capturedProps.push(props);
return <div data-testid="cm" />;
}),
}));
vi.mock('@/stores/ui', () => ({ uiStore: { resolvedTheme: 'light' } }));

beforeEach(() => {
capturedProps.length = 0;
});

const stableExtensions: any[] = [];

describe('CodeEditor extension stack stability', () => {
test('a rerender with new inline onSave/onChange hands CodeMirror the same extensions and onChange', () => {
const { rerender } = render(
<CodeEditor
text="a"
extensions={stableExtensions}
withBreakpoints={false}
onChange={() => {}}
onSave={() => {}}
/>,
);
const before = capturedProps.at(-1);
rerender(
<CodeEditor
text="ab"
extensions={stableExtensions}
withBreakpoints={false}
onChange={() => {}}
onSave={() => {}}
/>,
);
const after = capturedProps.at(-1);
expect(after.extensions).toBe(before.extensions);
expect(after.onChange).toBe(before.onChange);
});

test('a genuinely new extensions prop still rebuilds the stack', () => {
const { rerender } = render(
<CodeEditor text="a" extensions={stableExtensions} withBreakpoints={false} onChange={() => {}} />,
);
const before = capturedProps.at(-1);
rerender(
<CodeEditor
text="a"
extensions={[EditorView.editable.of(false)]}
withBreakpoints={false}
onChange={() => {}}
/>,
);
expect(capturedProps.at(-1).extensions).not.toBe(before.extensions);
});

test('Mod-s runs the latest onSave through the unchanged keymap', () => {
const first = vi.fn();
const second = vi.fn();
const { rerender } = render(
<CodeEditor text="a" extensions={stableExtensions} withBreakpoints={false} onChange={() => {}} onSave={first} />,
);
const stack = capturedProps.at(-1).extensions;
rerender(
<CodeEditor text="a" extensions={stableExtensions} withBreakpoints={false} onChange={() => {}} onSave={second} />,
);
expect(capturedProps.at(-1).extensions).toBe(stack); // keymap not rebuilt...
const view = new EditorView({ extensions: stack });
try {
const handled = runScopeHandlers(
view, new KeyboardEvent('keydown', { key: 's', ctrlKey: true }), 'editor',
);
expect(handled).toBe(true);
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledTimes(1); // ...yet the latest handler runs
} finally {
view.destroy();
}
});

test('the stable onChange bridges to the latest handler prop', () => {
const first = vi.fn();
const second = vi.fn();
const { rerender } = render(
<CodeEditor text="a" extensions={stableExtensions} withBreakpoints={false} onChange={first} />,
);
rerender(
<CodeEditor text="a" extensions={stableExtensions} withBreakpoints={false} onChange={second} />,
);
capturedProps.at(-1).onChange('typed text');
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledWith('typed text');
});
});
Loading