diff --git a/docs/plans/JSX_IMPLEMENTATION_PLAN.md b/docs/plans/JSX_IMPLEMENTATION_PLAN.md index 39f79d6..73630a3 100644 --- a/docs/plans/JSX_IMPLEMENTATION_PLAN.md +++ b/docs/plans/JSX_IMPLEMENTATION_PLAN.md @@ -569,18 +569,52 @@ void main() { 3. **Interoperable** - Can mix old and new syntax freely 4. **No Breaking Changes** - Pure additive feature -## Open Questions - -1. **Operator Choice** - Is `>>` the best operator? Alternatives: `|`, `%`, `&` -2. **Naming** - `$div` vs `Div` vs `div_` for factories -3. **Null Children** - How to handle conditional `null` children in lists? -4. **Keys** - How to specify React keys in the new syntax? -5. **Ref Forwarding** - How to attach refs cleanly? - -## Next Steps - -1. Create `jsx.dart` with Phase 1 implementation -2. Add tests for operator behavior -3. Document usage patterns -4. Gather feedback on syntax preferences -5. Iterate on API design +## Resolved Decisions + +The five original open questions are now settled and implemented in +[`jsx.dart`](../../packages/dart_node_react/lib/src/jsx.dart): + +1. **Operator Choice** — `>>` is the child operator. It reads top-to-bottom like + nesting and avoids clashing with common arithmetic/bitwise usage. +2. **Naming** — `$`-prefixed factories (`$div`, `$h1`, `$button`, …). The sigil + keeps the DSL distinct from the existing lowercase `div()`/`h1()` factories so + the two can coexist without import juggling. +3. **Null Children** — `null` is filtered out, so `if (cond) $p >> '...'` inside a + children list "just works" for conditional rendering. +4. **Keys** — every list-style factory takes an optional `key:` parameter + (`$li(key: 'item-1')`), threaded straight into the React props. +5. **Ref Forwarding** — interactive and media factories (`$div`, `$input`, + `$button`, `$textarea`, `$a`, `$select`, `$form`, `$img`, `$video`, `$audio`) + take an optional `ref:` (a `JsRef` from `createRef`). Because React 18 hoists + `ref`/`key` out of `props`, the `>>` operator recomposes elements with + `cloneElement` (not `createElement`), so `ref` and `key` survive when children + are attached. + +## Phase Status + +- **Phase 1 — Operator DSL:** ✅ shipped (`>>`, `$`-factories, fragments, events, + conditional rendering, keys, refs). +- **Phase 2 — Fluent Builders:** ⏸ deferred. The operator DSL covers the ergonomic + need; revisit only if users ask for a chained-builder style. +- **Phase 3 — Convenience Extensions:** ⏸ deferred (`'text'.el`, `20.px`, etc.). +- **Phase 4 — Code Generation:** ⏸ deferred until adoption warrants typed-prop + generation from React type defs. +- **Phase 5 — Macros:** ⏸ blocked on stable Dart macros. + +## TODO + +- [x] Phase 1: `El` operator type with `>>` for text / element / list children +- [x] Phase 1: `$`-prefixed element factories (container, heading, semantic, + interactive, form, list, media, table, text, misc) +- [x] Phase 1: fragment support (`$fragment`) and generic `$el(tag)` escape hatch +- [x] Conditional rendering via `null` children filtering +- [x] React `key:` parameter on list factories +- [x] Ref forwarding via `ref:` on interactive factories +- [x] Integration tests in + [`test/jsx_test.dart`](../../packages/dart_node_react/test/jsx_test.dart) +- [x] Remove the duplicate `test/jsx/jsx_dsl_test.dart` +- [x] Export the DSL from `dart_node_react.dart` +- [ ] Phase 2: fluent builders (`Div.className(...).children([...])`) — deferred +- [ ] Phase 3: convenience extensions (`'text'.el`, `20.px`) — deferred +- [ ] Phase 4: code generation for typed props — deferred +- [ ] Phase 5: macro-based `jsx'''...'''` literals — blocked on stable macros diff --git a/packages/dart_node_react/lib/src/jsx.dart b/packages/dart_node_react/lib/src/jsx.dart index 6691ad2..6275b52 100644 --- a/packages/dart_node_react/lib/src/jsx.dart +++ b/packages/dart_node_react/lib/src/jsx.dart @@ -50,6 +50,15 @@ /// $button(onClick: handleClick) >> 'Submit' /// ``` /// +/// ### Refs +/// +/// Attach a [JsRef] (from `createRef`) to wire up imperative access: +/// ```dart +/// final inputRef = createRef(); +/// $input(ref: inputRef.jsRef, type: 'text') +/// // later: inputRef.current?.focus(); +/// ``` +/// /// ### Conditional Rendering /// ```dart /// $div() >> [ @@ -63,6 +72,7 @@ import 'dart:js_interop'; import 'package:dart_node_react/src/elements.dart'; import 'package:dart_node_react/src/react.dart'; +import 'package:dart_node_react/src/ref.dart'; import 'package:dart_node_react/src/synthetic_event.dart'; // ============================================================================= @@ -75,17 +85,11 @@ import 'package:dart_node_react/src/synthetic_event.dart'; /// Uses a class instead of extension type to enable runtime type checking. final class El { /// Wraps an existing element for operator composition. - El(this._element) : _type = _element.type, _props = _element.props; + El(this._element); /// The wrapped element. final ReactElement _element; - /// The element's type for recreation. - final JSAny _type; - - /// The element's props for recreation. - final JSObject? _props; - /// Access the underlying element. ReactElement get element => _element; @@ -111,10 +115,10 @@ final class El { }; ReactElement _withTextChild(String text) => - ReactElement.fromJS(React.createElement(_type, _props, text.toJS)); + ReactElement.fromJS(React.cloneElement(_element, null, text.toJS)); ReactElement _withSingleChild(ReactElement child) => - ReactElement.fromJS(React.createElement(_type, _props, child)); + ReactElement.fromJS(React.cloneElement(_element, null, child)); ReactElement _withChildren(List children) { final normalized = []; @@ -122,7 +126,7 @@ final class El { final jsChild = _normalizeChild(child); if (jsChild != null) normalized.add(jsChild); } - return createElementWithChildren(_type, _props, normalized); + return cloneElementWithRawChildren(_element, normalized); } JSAny? _normalizeChild(Object? child) => switch (child) { @@ -160,6 +164,7 @@ Map _buildJsxProps({ String? key, String? className, String? id, + JsRef? ref, Map? style, Map? spread, Map? props, @@ -204,6 +209,7 @@ Map _buildJsxProps({ if (key != null) p['key'] = key; if (className != null) p['className'] = className; if (id != null) p['id'] = id; + if (ref != null) p['ref'] = ref; if (style != null) p['style'] = convertStyle(style); // Mouse events if (onClick != null) p['onClick'] = onClick; @@ -357,6 +363,7 @@ El $div({ String? key, String? className, String? id, + JsRef? ref, Map? style, Map? spread, void Function()? onClick, @@ -370,6 +377,7 @@ El $div({ key: key, className: className, id: id, + ref: ref, style: style, spread: spread, onClick: onClick, @@ -608,6 +616,7 @@ El $button({ String? id, String? type, bool? disabled, + JsRef? ref, Map? style, Map? spread, void Function()? onClick, @@ -616,6 +625,7 @@ El $button({ key: key, className: className, id: id, + ref: ref, style: style, spread: spread, onClick: onClick, @@ -633,6 +643,7 @@ El $a({ String? id, String? target, String? rel, + JsRef? ref, Map? style, Map? spread, void Function()? onClick, @@ -641,6 +652,7 @@ El $a({ key: key, className: className, id: id, + ref: ref, style: style, spread: spread, onClick: onClick, @@ -661,6 +673,7 @@ El $form({ String? id, String? action, String? method, + JsRef? ref, Map? style, Map? props, void Function(SyntheticEvent)? onSubmit, @@ -668,6 +681,7 @@ El $form({ final p = _buildJsxProps( className: className, id: id, + ref: ref, style: style, props: props, onSubmit: onSubmit, @@ -689,6 +703,7 @@ El $input({ bool? disabled, bool? readOnly, bool? required, + JsRef? ref, Map? style, Map? spread, void Function(SyntheticEvent)? onChange, @@ -702,6 +717,7 @@ El $input({ key: key, className: className, id: id, + ref: ref, style: style, spread: spread, onChange: onChange, @@ -733,6 +749,7 @@ El $textarea({ bool? disabled, bool? readOnly, bool? required, + JsRef? ref, Map? style, Map? props, void Function(SyntheticEvent)? onChange, @@ -743,6 +760,7 @@ El $textarea({ final p = _buildJsxProps( className: className, id: id, + ref: ref, style: style, props: props, onChange: onChange, @@ -770,6 +788,7 @@ El $select({ bool? disabled, bool? multiple, bool? required, + JsRef? ref, Map? style, Map? props, void Function(SyntheticEvent)? onChange, @@ -779,6 +798,7 @@ El $select({ final p = _buildJsxProps( className: className, id: id, + ref: ref, style: style, props: props, onChange: onChange, @@ -905,6 +925,7 @@ ImgElement $img({ String? id, int? width, int? height, + JsRef? ref, Map? style, Map? props, void Function()? onClick, @@ -912,6 +933,7 @@ ImgElement $img({ final p = _buildJsxProps( className: className, id: id, + ref: ref, style: style, props: props, onClick: onClick, @@ -935,12 +957,14 @@ El $video({ bool? loop, bool? muted, String? poster, + JsRef? ref, Map? style, Map? props, }) { final p = _buildJsxProps( className: className, id: id, + ref: ref, style: style, props: props, ); @@ -964,12 +988,14 @@ El $audio({ bool? autoplay, bool? loop, bool? muted, + JsRef? ref, Map? style, Map? props, }) { final p = _buildJsxProps( className: className, id: id, + ref: ref, style: style, props: props, ); diff --git a/packages/dart_node_react/lib/src/react.dart b/packages/dart_node_react/lib/src/react.dart index 2a85fe9..039fff9 100644 --- a/packages/dart_node_react/lib/src/react.dart +++ b/packages/dart_node_react/lib/src/react.dart @@ -141,6 +141,18 @@ JSObject _createElementApply(JSAny type, JSObject props, JSArray children) { @JS('Array.prototype.concat.call') external JSArray _concatArrays(JSArray arr1, JSArray arr2); +/// Clone a React element, replacing its children while preserving the +/// original element's props, `ref`, and `key`. +/// +/// React 18 hoists `ref`/`key` out of `props`, so rebuilding an element from +/// its `props` alone loses them. `cloneElement` carries them across, which is +/// what the JSX `>>` operator needs when it attaches children. Delegates to the +/// existing clone-with-children machinery, passing no config so props are kept. +ReactElement cloneElementWithRawChildren( + JSObject element, + List children, +) => ReactElement._(_cloneElementWithChildren(element, null, children.toJS)); + /// Create props object from a Map (with function conversion) JSObject createProps(Map props) { final obj = JSObject(); diff --git a/packages/dart_node_react/pubspec.lock b/packages/dart_node_react/pubspec.lock index c1e365e..0cf6868 100644 --- a/packages/dart_node_react/pubspec.lock +++ b/packages/dart_node_react/pubspec.lock @@ -95,14 +95,14 @@ packages: path: "../dart_node_core" relative: true source: path - version: "0.11.0-beta" + version: "0.0.0-dev" dart_node_coverage: dependency: "direct dev" description: path: "../dart_node_coverage" relative: true source: path - version: "0.9.0-beta" + version: "0.0.0-dev" file: dependency: transitive description: diff --git a/packages/dart_node_react/test/jsx/jsx_dsl_test.dart b/packages/dart_node_react/test/jsx/jsx_dsl_test.dart deleted file mode 100644 index d6bb7a9..0000000 --- a/packages/dart_node_react/test/jsx/jsx_dsl_test.dart +++ /dev/null @@ -1,197 +0,0 @@ -/// Tests for JSX DSL functionality. -@TestOn('js') -library; - -import 'dart:js_interop'; -import 'dart:js_interop_unsafe'; - -import 'package:dart_node_react/dart_node_react.dart' hide RenderResult, render; -import 'package:dart_node_react/src/testing_library.dart'; -import 'package:test/test.dart'; - -void main() { - test('creates element with text child using >> operator', () { - final component = registerFunctionComponent((props) => $h1 >> 'Hello JSX'); - - final result = render(fc(component)); - expect(result.container.textContent, equals('Hello JSX')); - result.unmount(); - }); - - test('creates nested elements with >> operator', () { - final component = registerFunctionComponent( - (props) => - $div(spread: {'data-testid': 'container'}) >> - [$h1 >> 'Title', $p() >> 'Content'], - ); - - final result = render(fc(component)); - final container = result.getByTestId('container'); - expect(container.textContent, contains('Title')); - expect(container.textContent, contains('Content')); - result.unmount(); - }); - - test(r'$div with className creates element', () { - final component = registerFunctionComponent( - (props) => - $div(className: 'my-class', spread: {'data-testid': 'styled'}) >> - 'Styled', - ); - - final result = render(fc(component)); - final el = result.getByTestId('styled'); - expect(el.className, equals('my-class')); - result.unmount(); - }); - - test(r'$button with onClick handler works', () { - final component = registerFunctionComponent((props) { - final count = useState(0); - return $div() >> - [ - $span(spread: {'data-testid': 'count'}) >> 'Count: ${count.value}', - $button( - onClick: () => count.set(count.value + 1), - spread: {'data-testid': 'btn'}, - ) >> - 'Click', - ]; - }); - - final result = render(fc(component)); - expect(result.getByTestId('count').textContent, equals('Count: 0')); - fireClick(result.getByTestId('btn')); - expect(result.getByTestId('count').textContent, equals('Count: 1')); - result.unmount(); - }); - - test(r'$input with onChange handler works', () { - final component = registerFunctionComponent((props) { - final text = useState(''); - return $div() >> - [ - $input( - type: 'text', - value: text.value, - onChange: (e) { - final target = e.target; - if (target case final JSObject t) { - final value = t['value']; - if (value case final JSString s) text.set(s.toDart); - } - }, - spread: {'data-testid': 'input'}, - ), - $span(spread: {'data-testid': 'output'}) >> 'Value: ${text.value}', - ]; - }); - - final result = render(fc(component)); - final inputEl = result.getByTestId('input'); - fireChange(inputEl, value: 'Hello'); - expect(result.getByTestId('output').textContent, equals('Value: Hello')); - result.unmount(); - }); - - test(r'$ul and $li create lists', () { - final component = registerFunctionComponent( - (props) => - $ul(spread: {'data-testid': 'list'}) >> - [$li() >> 'Item 1', $li() >> 'Item 2', $li() >> 'Item 3'], - ); - - final result = render(fc(component)); - final list = result.getByTestId('list'); - expect(list.textContent, contains('Item 1')); - expect(list.textContent, contains('Item 2')); - expect(list.textContent, contains('Item 3')); - result.unmount(); - }); - - test(r'$fragment groups elements without wrapper', () { - final component = registerFunctionComponent( - (props) => $fragment >> [$h1 >> 'First', $h2 >> 'Second'], - ); - - final result = render(fc(component)); - expect(result.container.textContent, contains('First')); - expect(result.container.textContent, contains('Second')); - result.unmount(); - }); - - test('conditional rendering with null children', () { - final component = registerFunctionComponent((props) { - final show = useState(false); - return $div() >> - [ - $button( - onClick: () => show.set(!show.value), - spread: {'data-testid': 'toggle'}, - ) >> - 'Toggle', - if (show.value) $p(spread: {'data-testid': 'content'}) >> 'Visible', - ]; - }); - - final result = render(fc(component)); - expect(result.queryByTestId('content'), isNull); - fireClick(result.getByTestId('toggle')); - expect(result.getByTestId('content').textContent, equals('Visible')); - result.unmount(); - }); - - test('numeric children are converted to string', () { - final component = registerFunctionComponent( - (props) => $span(spread: {'data-testid': 'num'}) >> 42, - ); - - final result = render(fc(component)); - expect(result.getByTestId('num').textContent, equals('42')); - result.unmount(); - }); - - test('El elements can be used as children', () { - final component = registerFunctionComponent((props) { - final child = $span() >> 'Inner'; - return $div(spread: {'data-testid': 'outer'}) >> child; - }); - - final result = render(fc(component)); - expect(result.getByTestId('outer').textContent, equals('Inner')); - result.unmount(); - }); - - test(r'$a creates anchor with href', () { - final component = registerFunctionComponent( - (props) => - $a(href: 'https://example.com', spread: {'data-testid': 'link'}) >> - 'Click me', - ); - - final result = render(fc(component)); - final link = result.getByTestId('link'); - expect(link.textContent, equals('Click me')); - expect(link.getAttribute('href'), isNotNull); - result.unmount(); - }); - - test('semantic elements work correctly', () { - final component = registerFunctionComponent( - (props) => - $main(spread: {'data-testid': 'main'}) >> - [ - $header() >> [$h1 >> 'Header'], - $section() >> [$p() >> 'Section content'], - $footer() >> [$span() >> 'Footer'], - ], - ); - - final result = render(fc(component)); - final mainEl = result.getByTestId('main'); - expect(mainEl.textContent, contains('Header')); - expect(mainEl.textContent, contains('Section content')); - expect(mainEl.textContent, contains('Footer')); - result.unmount(); - }); -} diff --git a/packages/dart_node_react/test/jsx_test.dart b/packages/dart_node_react/test/jsx_test.dart index 3f0769e..ac93520 100644 --- a/packages/dart_node_react/test/jsx_test.dart +++ b/packages/dart_node_react/test/jsx_test.dart @@ -172,6 +172,40 @@ void main() { final result = render(fc(component)); final link = result.getByTestId('link'); expect(link.textContent, equals('Click me')); + expect(link.getAttribute('href'), equals('https://example.com')); + result.unmount(); + }); + + test('ref forwards to the rendered DOM node', () { + final inputRef = createRef(); + final component = registerFunctionComponent( + (props) => + $div() >> + $input( + ref: inputRef.jsRef, + type: 'text', + spread: {'data-testid': 'reffed'}, + ), + ); + + final result = render(fc(component)); + expect(result.getByTestId('reffed'), isNotNull); + expect(inputRef.jsRef.current, isNotNull); + result.unmount(); + }); + + test('ref survives recomposition when children are added with >>', () { + final buttonRef = createRef(); + final component = registerFunctionComponent( + (props) => + $div() >> + ($button(ref: buttonRef.jsRef, spread: {'data-testid': 'save'}) >> + 'Save'), + ); + + final result = render(fc(component)); + expect(result.getByTestId('save').textContent, equals('Save')); + expect(buttonRef.jsRef.current, isNotNull); result.unmount(); }); diff --git a/packages/dart_node_vsix/README.md b/packages/dart_node_vsix/README.md index a354540..88227b1 100644 --- a/packages/dart_node_vsix/README.md +++ b/packages/dart_node_vsix/README.md @@ -269,9 +269,23 @@ void main() { | `event_emitter.dart` | Custom event handling | | `mocha.dart` | Testing utilities | -## Example - -Too Many Cooks, an MCP server originally built in this ecosystem, shipped a VSCode extension. It has since moved to its own home at [tmc-mcp.dev](https://tmc-mcp.dev) and is no longer part of this repository. +## Bundled extension: Dart Node + +This package ships **Dart Node**, a small general-purpose VS Code extension +written entirely in Dart ([`lib/extension.dart`](lib/extension.dart)). It doubles +as the self-test that exercises every binding in a real Extension Host, so the +APIs above are validated end-to-end. Its commands are a starting point you can +copy from: + +| Command | What it does | Bindings shown | +|---------|--------------|----------------| +| `dartNode.hello` | Shows a greeting | `window.showInformationMessage` | +| `dartNode.showVersion` | Shows the running VS Code version | `vscode.version` | +| `dartNode.echo` | Prompts for input and echoes it back | `window.showInputBox` | +| `dartNode.count` | Increments a persisted run counter | `context.globalState` memento | +| `dartNode.test` | Runs the bindings self-test | commands, status bar, tree view | + +Build it with `bash build.sh` and package a `.vsix` with `npm run package`. ## Source Code diff --git a/packages/dart_node_vsix/lib/extension.dart b/packages/dart_node_vsix/lib/extension.dart index 5f5444a..bd04bbc 100644 --- a/packages/dart_node_vsix/lib/extension.dart +++ b/packages/dart_node_vsix/lib/extension.dart @@ -1,9 +1,12 @@ -/// Test extension entry point for dart_node_vsix package. +/// The Dart Node extension entry point. /// -/// This extension exercises all the APIs in dart_node_vsix to ensure -/// they work correctly in a real VSCode Extension Host environment. +/// A general-purpose VS Code extension written entirely in Dart. It ships a +/// handful of basic commands (hello, version, echo, run counter) and also +/// exercises every binding in dart_node_vsix via a self-test command so the +/// APIs are validated in a real VS Code Extension Host environment. library; +import 'dart:async'; import 'dart:js_interop'; import 'package:dart_node_vsix/dart_node_vsix.dart'; @@ -12,6 +15,72 @@ import 'package:dart_node_vsix/src/js_helpers.dart' as js; // ignore: unused_import - used by tests to access TestAPI type import 'package:dart_node_vsix/test_api_types.dart'; +/// Command id: greet the user. +const _cmdHello = 'dartNode.hello'; + +/// Command id: show the running VS Code version. +const _cmdVersion = 'dartNode.showVersion'; + +/// Command id: prompt for input and echo it back. +const _cmdEcho = 'dartNode.echo'; + +/// Command id: increment and report a persisted run counter. +const _cmdCount = 'dartNode.count'; + +/// Command id: exercise every binding (self-test). +const _cmdTest = 'dartNode.test'; + +/// Explorer tree view id. +const _treeViewId = 'dartNode.tree'; + +/// Output channel name. +const _channelName = 'Dart Node'; + +/// Status bar text (with a codicon). +const _statusBarText = r'$(rocket) Dart Node'; + +/// Status bar hover tooltip. +const _statusBarTooltip = 'Dart Node extension'; + +/// Prefix for console log lines. +const _logPrefix = '[Dart Node]'; + +/// Greeting shown by the hello command. +const _helloMessage = 'Hello from Dart Node!'; + +/// Prefix for the version message. +const _versionPrefix = 'VS Code '; + +/// Prompt shown by the echo command. +const _echoPrompt = 'Type a message for Dart Node to echo'; + +/// Placeholder shown by the echo command. +const _echoPlaceholder = 'Your message'; + +/// Prefix for the echoed reply. +const _echoReplyPrefix = 'Dart Node echo: '; + +/// Global-state key for the run counter. +const _countKey = 'dartNode.runCount'; + +/// Prefix for the run-counter message. +const _countPrefix = 'Dart Node command run '; + +/// Suffix for the run-counter message. +const _countSuffix = ' time(s)'; + +/// Message shown by the self-test command. +const _testCommandMessage = 'Dart Node bindings self-test ran!'; + +/// Global-state key used by the activation memento self-check. +const _selfCheckKey = 'dartNode.selfCheck'; + +/// Value written then read back by the activation memento self-check. +const _selfCheckValue = 7; + +/// Prefix for the memento self-check log line. +const _selfCheckPrefix = 'globalState round-trip: '; + /// Log messages for testing. final List _logMessages = []; @@ -30,7 +99,7 @@ final Map _disposedState = {}; /// Log a message. void _log(String msg) { _logMessages.add(msg); - js.consoleLog('[VSIX TEST] $msg'); + js.consoleLog('$_logPrefix $msg'); } // Wrapper functions for JS interop (can't use tearoffs with closures). @@ -114,52 +183,102 @@ external set _deactivate(JSFunction fn); Future activate(ExtensionContext context) async { _log('Extension activating...'); - // Test output channel - _outputChannel = vscode.window.createOutputChannel('VSIX Test'); - _outputChannel!.appendLine('Test extension activated'); + // Output channel + _outputChannel = vscode.window.createOutputChannel(_channelName); + _outputChannel!.appendLine('Dart Node activated'); _log('Output channel created: ${_outputChannel!.name}'); - // Test status bar item + // Status bar item _statusBarItem = vscode.window.createStatusBarItem( StatusBarAlignment.left.value, 100, ); - _statusBarItem!.text = r'$(beaker) VSIX Test'; - _statusBarItem!.tooltip = 'dart_node_vsix test extension'; + _statusBarItem!.text = _statusBarText; + _statusBarItem!.tooltip = _statusBarTooltip; + _statusBarItem!.command = _cmdHello; _statusBarItem!.show(); _log('Status bar item created'); - // Test command registration - final cmd = vscode.commands.registerCommand( - 'dartNodeVsix.test', - _onTestCommand, - ); + // General-purpose feature commands. + _registerFeatureCommands(context); + + // Self-test command (exercises the bindings). + final cmd = vscode.commands.registerCommand(_cmdTest, _onTestCommand); context.addSubscription(cmd); - _log('Command registered: dartNodeVsix.test'); + _log('Command registered: $_cmdTest'); - // Test tree view + // Tree view _treeProvider = _TestTreeDataProvider(); _treeProvider!.addItem('Test Item 1'); _treeProvider!.addItem('Test Item 2'); _treeProvider!.addItem('Test Item 3'); final treeView = vscode.window.createTreeView( - 'dartNodeVsix.testTree', + _treeViewId, TreeViewOptions(treeDataProvider: JSTreeDataProvider(_treeProvider!)), ); // ignore: unnecessary_lambdas - can't tearoff external extension type members context.addSubscription(Disposable.fromFunction(() => treeView.dispose())); _log('Tree view created with ${_treeProvider!.items.length} items'); + // Self-check the globalState memento binding (Memento.update + get). If the + // binding were broken this throws and activation fails loudly — no silence. + await context.globalState.update(_selfCheckKey, _selfCheckValue); + final readBack = context.globalState.get(_selfCheckKey)?.toDartInt; + _log('$_selfCheckPrefix$readBack'); + _log('Extension activated'); return _createTestAPI(); } void _onTestCommand() { - vscode.window.showInformationMessage('dart_node_vsix test command!'); + vscode.window.showInformationMessage(_testCommandMessage); _log('Test command executed'); } +/// Registers the general-purpose Dart Node commands. +void _registerFeatureCommands(ExtensionContext context) { + context + ..addSubscription(vscode.commands.registerCommand(_cmdHello, _onHello)) + ..addSubscription(vscode.commands.registerCommand(_cmdVersion, _onVersion)) + ..addSubscription( + vscode.commands.registerCommand(_cmdEcho, () => unawaited(_onEcho())), + ) + ..addSubscription( + vscode.commands.registerCommand( + _cmdCount, + () => unawaited(_onCount(context)), + ), + ); + _log('Feature commands registered'); +} + +/// Greets the user. +void _onHello() => vscode.window.showInformationMessage(_helloMessage); + +/// Shows the running VS Code version. +void _onVersion() => + vscode.window.showInformationMessage('$_versionPrefix${vscode.version}'); + +/// Prompts for input and echoes it back. +Future _onEcho() async { + final options = InputBoxOptions( + prompt: _echoPrompt, + placeHolder: _echoPlaceholder, + ); + final result = await vscode.window.showInputBox(options).toDart; + if (result == null) return; + vscode.window.showInformationMessage('$_echoReplyPrefix${result.toDart}'); +} + +/// Increments and reports a persisted run counter. +Future _onCount(ExtensionContext context) async { + final stored = context.globalState.get(_countKey); + final next = (stored?.toDartInt ?? 0) + 1; + await context.globalState.update(_countKey, next); + vscode.window.showInformationMessage('$_countPrefix$next$_countSuffix'); +} + /// Extension deactivation. void deactivate() { _log('Extension deactivating...'); diff --git a/packages/dart_node_vsix/lib/src/extension_context.dart b/packages/dart_node_vsix/lib/src/extension_context.dart index 7825a4b..3f8d426 100644 --- a/packages/dart_node_vsix/lib/src/extension_context.dart +++ b/packages/dart_node_vsix/lib/src/extension_context.dart @@ -35,14 +35,19 @@ extension type ExtensionContext._(JSObject _) implements JSObject { /// A memento for storing extension state. extension type Memento._(JSObject _) implements JSObject { /// Gets a value from the memento. - T? get(String key) => _mementoGet(_, key.toJS); + T? get(String key) => _.callMethod('get'.toJS, key.toJS); /// Updates a value in the memento. - Future update(String key, Object? value) => - _mementoUpdate(_, key.toJS, value.jsify()).toDart; + Future update(String key, Object? value) => _ + .callMethod>('update'.toJS, key.toJS, value.jsify()) + .toDart; /// Gets all keys in the memento. - List keys() => _mementoKeys(_).toDart.cast(); + List keys() => _ + .callMethod>('keys'.toJS) + .toDart + .map((k) => k.toDart) + .toList(); } List _getSubscriptions(JSObject context) { @@ -54,16 +59,3 @@ void _pushSubscription(JSObject context, Disposable disposable) { final subs = context['subscriptions']! as JSObject; (subs['push']! as JSFunction).callAsFunction(subs, disposable); } - -@JS() -external T? _mementoGet(JSObject memento, JSString key); - -@JS() -external JSPromise _mementoUpdate( - JSObject memento, - JSString key, - JSAny? value, -); - -@JS() -external JSArray _mementoKeys(JSObject memento); diff --git a/packages/dart_node_vsix/lib/src/vscode.dart b/packages/dart_node_vsix/lib/src/vscode.dart index f1aec84..c28852b 100644 --- a/packages/dart_node_vsix/lib/src/vscode.dart +++ b/packages/dart_node_vsix/lib/src/vscode.dart @@ -10,6 +10,9 @@ extension type VSCode._(JSObject _) implements JSObject { /// Gets the vscode module. factory VSCode() => _requireVscode('vscode'); + /// The version of the running VS Code application (e.g. `1.100.0`). + external String get version; + /// The commands namespace. external Commands get commands; diff --git a/packages/dart_node_vsix/package-lock.json b/packages/dart_node_vsix/package-lock.json index a97b2b8..103180e 100644 --- a/packages/dart_node_vsix/package-lock.json +++ b/packages/dart_node_vsix/package-lock.json @@ -1,11 +1,11 @@ { - "name": "dart-node-vsix-test", + "name": "dart-node", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "dart-node-vsix-test", + "name": "dart-node", "version": "0.0.1", "devDependencies": { "@vscode/test-cli": "^0.0.10", diff --git a/packages/dart_node_vsix/package.json b/packages/dart_node_vsix/package.json index 4681d24..503ad4a 100644 --- a/packages/dart_node_vsix/package.json +++ b/packages/dart_node_vsix/package.json @@ -1,12 +1,15 @@ { - "name": "dart-node-vsix-test", - "displayName": "Dart Node VSIX Test", - "description": "Test extension for dart_node_vsix package", + "name": "dart-node", + "displayName": "Dart Node", + "description": "General-purpose VS Code extension written entirely in Dart via dart_node_vsix.", "publisher": "Nimblesite", "version": "0.0.1", "engines": { "vscode": "^1.100.0" }, + "categories": [ + "Other" + ], "main": "./out/lib/extension.js", "activationEvents": [ "*" @@ -14,15 +17,31 @@ "contributes": { "commands": [ { - "command": "dartNodeVsix.test", - "title": "Test dart_node_vsix" + "command": "dartNode.hello", + "title": "Dart Node: Hello" + }, + { + "command": "dartNode.showVersion", + "title": "Dart Node: Show VS Code Version" + }, + { + "command": "dartNode.echo", + "title": "Dart Node: Echo Input" + }, + { + "command": "dartNode.count", + "title": "Dart Node: Increment Run Counter" + }, + { + "command": "dartNode.test", + "title": "Dart Node: Run Bindings Self-Test" } ], "views": { "explorer": [ { - "id": "dartNodeVsix.testTree", - "name": "VSIX Test Tree" + "id": "dartNode.tree", + "name": "Dart Node" } ] } diff --git a/packages/dart_node_vsix/pubspec.lock b/packages/dart_node_vsix/pubspec.lock index 73bc934..d532bcc 100644 --- a/packages/dart_node_vsix/pubspec.lock +++ b/packages/dart_node_vsix/pubspec.lock @@ -95,7 +95,7 @@ packages: path: "../dart_node_coverage" relative: true source: path - version: "0.9.0-beta" + version: "0.0.0-dev" file: dependency: transitive description: diff --git a/packages/dart_node_vsix/test/suite/commands_test.dart b/packages/dart_node_vsix/test/suite/commands_test.dart index 35033ca..9d06657 100644 --- a/packages/dart_node_vsix/test/suite/commands_test.dart +++ b/packages/dart_node_vsix/test/suite/commands_test.dart @@ -24,17 +24,33 @@ void main() { ); test( - 'registerCommand registers a command', + 'registerCommand registers the self-test command', asyncTest(() async { final commands = await _getCommands(true.toJS).toDart; final list = commands.toDart.map((c) => c.toDart); assertOk( - list.contains('dartNodeVsix.test'), - 'Test command should be registered', + list.contains('dartNode.test'), + 'Self-test command should be registered', ); }), ); + test( + 'general-purpose feature commands are registered', + asyncTest(() async { + final commands = await _getCommands(true.toJS).toDart; + final list = commands.toDart.map((c) => c.toDart).toList(); + for (final id in const [ + 'dartNode.hello', + 'dartNode.showVersion', + 'dartNode.echo', + 'dartNode.count', + ]) { + assertOk(list.contains(id), '$id should be registered'); + } + }), + ); + test( 'getCommands returns array of commands', asyncTest(() async { diff --git a/packages/dart_node_vsix/test/suite/extension_activation_test.dart b/packages/dart_node_vsix/test/suite/extension_activation_test.dart index ea5e595..b0b75b8 100644 --- a/packages/dart_node_vsix/test/suite/extension_activation_test.dart +++ b/packages/dart_node_vsix/test/suite/extension_activation_test.dart @@ -59,6 +59,24 @@ void main() { assertOk(hasActivated, 'Must log activated'); }), ); + + test( + 'globalState memento round-trips through the binding', + syncTest(() { + final api = getTestAPI(); + final logs = api.getLogMessages(); + var roundTripped = false; + for (var i = 0; i < logs.length; i++) { + if (logs[i].toDart.contains('globalState round-trip: 7')) { + roundTripped = true; + } + } + assertOk( + roundTripped, + 'globalState.update then get must return the stored value (7)', + ); + }), + ); }), ); } diff --git a/packages/dart_node_vsix/test/suite/output_channel_test.dart b/packages/dart_node_vsix/test/suite/output_channel_test.dart index 38a264b..cd91b54 100644 --- a/packages/dart_node_vsix/test/suite/output_channel_test.dart +++ b/packages/dart_node_vsix/test/suite/output_channel_test.dart @@ -22,7 +22,7 @@ void main() { 'Extension creates output channel', syncTest(() { final api = getTestAPI(); - assertEqual(api.getOutputChannelName(), 'VSIX Test'); + assertEqual(api.getOutputChannelName(), 'Dart Node'); }), ); diff --git a/packages/dart_node_vsix/test/suite/status_bar_test.dart b/packages/dart_node_vsix/test/suite/status_bar_test.dart index 391865d..625f41e 100644 --- a/packages/dart_node_vsix/test/suite/status_bar_test.dart +++ b/packages/dart_node_vsix/test/suite/status_bar_test.dart @@ -24,8 +24,8 @@ void main() { final api = getTestAPI(); final text = api.getStatusBarText(); assertOk( - text.contains('VSIX Test'), - 'Status bar should have test text', + text.contains('Dart Node'), + 'Status bar should show the Dart Node label', ); }), ); diff --git a/packages/dart_node_vsix/test/suite/test_helpers.dart b/packages/dart_node_vsix/test/suite/test_helpers.dart index 4475e29..a3ef20e 100644 --- a/packages/dart_node_vsix/test/suite/test_helpers.dart +++ b/packages/dart_node_vsix/test/suite/test_helpers.dart @@ -37,8 +37,8 @@ external JSObject _createJSObjectFromProto(JSAny? proto); /// Create an empty JS object. JSObject createJSObject() => _createJSObjectFromProto(null); -/// Extension ID for the test extension. -const extensionId = 'Nimblesite.dart-node-vsix-test'; +/// Extension ID for the Dart Node extension. +const extensionId = 'Nimblesite.dart-node'; /// Cached TestAPI instance. TestAPI? _cachedTestAPI;