From 0c567154731ebbd2b87ea158c4f607084b965921 Mon Sep 17 00:00:00 2001 From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:04:19 -0300 Subject: [PATCH] chore: add CLAUDE.md --- CLAUDE.md | 433 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 433 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..719cffa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,433 @@ +# XTerm Console Addon — Claude Guide + +## Project Overview + +A Vaadin Flow addon that integrates [xterm.js](https://xtermjs.org/) into a Java server-side component. It provides a full terminal emulator with console I/O, clipboard, fit-to-container, keyboard selection, command history, and state preservation. + +- **Group ID:** `com.flowingcode.addons` +- **Artifact ID:** `xterm-console` +- **Current Version:** `3.3.1-SNAPSHOT` (branch `master` → Vaadin 24) +- **License:** Apache 2.0 +- **Custom element tag:** `` + +## Build & Run + +```bash +# Build and launch demo app +mvn clean install jetty:run +# Visit http://localhost:8080/ + +# Build only (default: Vaadin 24, Java 17) +mvn clean package + +# Integration tests (requires Chrome) +mvn clean verify -Pintegration-tests + +# Package for Vaadin Directory (sources + javadoc + assembly zip) +mvn clean install -Pdirectory + +# Production build (minified frontend) +mvn clean package -Pproduction +``` + +## Maven Profiles + +| Profile | Java | Vaadin | Git Branch | +|---|---|---|---| +| *(default)* | 17 | 24.3.5 | `master` | +| `v25` | 21 | 25.0.3 | `master` | +| `v23` | 11 | 23.x | `2.x` | +| *(default on 1.x)* | 8 | 14.x | `1.x` | + +## Project Structure + +``` +src/ +├── main/ +│ ├── java/com/flowingcode/vaadin/addons/xterm/ +│ │ ├── XTerm.java # Public component: extends XTermBase, implements Fit+Console+Clipboard +│ │ ├── XTermBase.java # Abstract base: proxy bridge, deferred commands, addon registry +│ │ ├── ITerminal.java # xterm.js terminal operations (write, scroll, select, resize…) +│ │ ├── ITerminalOptions.java # xterm.js options setters (cursor, font, theme, bell…) +│ │ ├── ITerminalConsole.java # Console feature: prompt, insertMode, getCurrentLine, LineEvent +│ │ ├── ITerminalClipboard.java # Clipboard feature: useSystemClipboard, copy/paste settings +│ │ ├── ITerminalFit.java # Fit feature: fit(), fitOnResize +│ │ ├── ITerminalSelection.java # Keyboard selection: keyboardSelectionEnabled +│ │ ├── TerminalAddon.java # Abstract server-side addon base +│ │ ├── ClientTerminalAddon.java # Server addon with JS counterpart (executeJs in addon context) +│ │ ├── PreserveStateAddon.java # Preserves scrollback+options across detach/reattach +│ │ ├── TerminalHistory.java # Arrow-up/down command history (not an addon subclass) +│ │ ├── TerminalTheme.java # Color theme (all 16 ANSI colors + fg/bg/cursor) +│ │ └── utils/StateMemoizer.java # Proxy that remembers setter calls and can replay them +│ └── resources/META-INF/frontend/fc-xterm/ +│ ├── xterm-element.ts # XTermElement (LitElement base), TerminalAddon base, CustomKeyEventHandlerRegistry +│ ├── xterm.ts # Final composed XTermComponent registered as +│ ├── xterm-console-mixin.ts # ConsoleAddon: CSI handlers, cursor navigation, prompt, line events +│ ├── xterm-clipboard-mixin.ts # ClipboardAddon: Ctrl-C/V, right/middle click, system clipboard +│ ├── xterm-fit-mixin.ts # FitAddon: wraps xterm-addon-fit, fitOnResize, viewport width fix +│ ├── xterm-selection-mixin.ts # SelectionAddon: Shift+Arrow keyboard selection +│ └── xterm-insertfix-mixin.ts # InsertFixAddon: fixes insert mode + wraparound overflow bug +└── test/java/ + ├── com/flowingcode/vaadin/addons/ + │ └── DemoLayout.java # Full-size Div RouterLayout for demo routes + └── com/flowingcode/vaadin/addons/xterm/ + ├── DemoView.java # Route "": redirects to XtermDemoView + ├── XtermDemoView.java # Route "xterm" (layout DemoLayout): interactive demo with commands + ├── PreserveStateAddonTest.java # Unit tests: scrollback buffer, prompt+line tracking, clear/reset + ├── test/SerializationTest.java # Serialization tests for XTerm and TerminalHistory + ├── utils/StateMemoizerTest.java # Unit tests for StateMemoizer + └── integration/ + ├── AbstractViewTest.java # Base: Chrome driver, localhost:8080, screenshot on failure + ├── IntegrationView.java # Route "xterm/it": test server-side view + ├── IntegrationViewCallables.java # @ClientCallable interface for test→server RPC + ├── XTermElement.java # TestBench page object for + ├── Position.java # (x,y) cursor position helper with advance/plus/adjust + ├── XTermTestUtils.java # makeFullLine() helper + ├── SampleClientTerminalAddon.java # Minimal ClientTerminalAddon implementation for tests + ├── XTermIT.java # Component rendering, text input, CustomKeyEventHandlerRegistry + ├── ConsoleFeatureIT.java # Navigation, CSI sequences, wrapped lines, insert mode + ├── ClipboardFeatureIT.java # Mouse drag select + right-click paste + ├── FitFeatureIT.java # Window resize causes column-count change + ├── SelectionFeatureIT.java # Shift+Arrow selection + delete/backspace + ├── TerminalHistoryIT.java # Arrow-up/down history navigation edge cases + └── SampleClientTerminalAddonIT.java # ClientTerminalAddon round-trip via testbench-rpc +``` + +## Class Hierarchy and Responsibilities + +### `XTermBase` (abstract) +- Annotated: `@NpmPackage("xterm","5.1.0")`, `@JsModule("./fc-xterm/xterm-element.ts")`, `@CssImport("xterm/css/xterm.css")` +- Uses `@ExtensionMethod(JsonMigration.class)` for elemental JSON compatibility +- Two Lombok `@Delegate` fields pointing to the **same** proxy object implementing both `ITerminal` and `ITerminalOptions` +- The proxy class is pre-created in a `static` block via `Proxy.getProxyClass` +- Deferred commands: before `terminal-initialized` event, all `executeJs` calls are queued in `deferredCommands` (a `LinkedList`). After initialization the queue is drained once and set to `null`. +- Queries with a return value (`CompletableFuture`) throw `IllegalStateException` if called before initialization. +- `addCustomKeyListener`: builds a JSON array of key descriptors, constructs a JS filter string, fires a `CustomKey` DOM event, and calls `this.registerCustomKeyListener($0)` on the client. +- `registerServerSideAddon` is package-private; addons call it from their own constructors. + +### `ProxyInvocationHandler` (inner class of `XTermBase`) +Translates Java interface method calls to JavaScript: +- `setXxx(value)` → `this.terminal.options['xxx']=value` (camelCase property name) + - Exception: `setBellStyle`/`setBellSound` → `this['bellStyle']=value` (element property, not terminal option) +- Enum arguments are converted via `name().toLowerCase(Locale.ENGLISH)` +- `TerminalTheme` argument converted via `asJsonObject()` +- Zero-arg methods → `return this.terminal['name']()` +- N-arg methods → `return this.terminal['name']($1,$2,...)` +- Return type mapping: `void` → no result; `CompletableFuture` → `.asString()`; `CompletableFuture` → `.asBoolean()`; `CompletableFuture` → `(int)asNumber()` + +### `XTerm` +Thin public class. Extends `XTermBase`, implements `ITerminalFit`, `ITerminalConsole`, `ITerminalClipboard`. Constructor calls `setInsertMode(true)`. + +### `ITerminalConsole` (interface with default methods) +- `LineEvent` — `@DomEvent("line")`, carries `event.detail` as the line string. Fire it in unit tests via `ComponentUtil.fireEvent(component, new ITerminalConsole.LineEvent(xterm, true, "text"))`. +- `addLineListener` — subscribes via `ComponentUtil.addListener` +- `setInsertMode` — calls `((XTermBase)this).executeJs("this.insertMode=$0", insertMode)` +- `getCurrentLine` — `getElement().executeJs("return this.currentLine")`, returns `CompletableFuture` +- `setPrompt`/`getPrompt` — element property `"prompt"`. Setting `null` disables the prompt. +- `writePrompt` — `((XTermBase)this).executeJs("this.writePrompt()")` + +### `ITerminalClipboard` (interface with default methods) +All methods use `getElement().setProperty()`/`getElement().getProperty()`. Properties: `useSystemClipboard`, `pasteWithRightClick`, `pasteWithMiddleClick`, `copySelection`. `UseSystemClipboard` enum: `FALSE`, `WRITE`, `READWRITE`. + +### `ITerminalFit` (interface with default methods) +`@NpmPackage("xterm-addon-fit","0.7.0")`. `fit()` calls `getElement().executeJs("this.fit()")`. `fitOnResize` is an element property. + +### `ITerminalSelection` (interface with default methods) +`keyboardSelectionEnabled` is an element property. Defaults to `false` on the Java side (property getter default), but the client-side mixin initializes it to `true`. + +### `ITerminalOptions` enums +All uppercase in Java, lowercased by proxy for xterm.js: +- `BellStyle`: `NONE`, `SOUND` +- `CursorStyle`: `BLOCK`, `UNDERLINE`, `BAR` +- `FastScrollModifier`: `ALT`, `CTRL`, `SHIFT`, `UNDEFINED` +- `RendererType`: `DOM`, `CANVAS` + +### `TerminalAddon` +Abstract class. Constructor takes `XTermBase`, calls `xterm.registerServerSideAddon(this)`. Subclasses add behavior. + +### `ClientTerminalAddon` +Extends `TerminalAddon`. Requires `getName()` (non-null string, used as key in `this.addons[name]` on client). `executeJs(expr, params)`: +1. Wraps params in a JSON array using `encodeWithTypeInfo` (version-sensitive: uses `com.vaadin.flow.internal.JsonCodec` on Vaadin ≤24, `com.vaadin.flow.internal.JacksonCodec` on Vaadin ≥25 — detected via `Version.getMajorVersion()`). +2. Rewrites `$0`, `$1`... placeholders to `$1[0]`, `$1[1]`... (all params passed as a single array `$1`). +3. Executes: `(function(){expr}).apply(this.addons[$0],$1)` — `this` inside the expression is the client-side addon object. + +The client-side addon object must be registered in `this.addons[name]` before `executeJs` is called. See `SampleClientTerminalAddon` for the registration pattern using `window.Vaadin.Flow.fcXtermConnector`. + +### `PreserveStateAddon` +Extends `TerminalAddon`, implements both `ITerminal` and `ITerminalOptions`. Strategy: +- Options calls go through a `StateMemoizer` proxy so they can be replayed on reattach. +- `write(data)` and `writeln(data)` forward to xterm and append to `scrollbackBuffer` (a `StringBuilder`). Note: `writeln` appends data + `\n`, not data + CRLF. +- `clear()` and `reset()` forward to xterm and clear the buffer. +- `LineEvent` listener appends prompt + line + `\n` to buffer (the prompt at time of submit, not time of `writePrompt`). +- On `AttachEvent` (if previously detached): calls `optionsMemoizer.apply()`, then `xterm.write(scrollbackBuffer)`, then `xterm.writePrompt()`. +- Usage: call all `ITerminal`/`ITerminalOptions` methods **via this addon**, not directly on `XTerm`. + +### `TerminalHistory` +Not a `TerminalAddon` subclass. Attached to a terminal via `ComponentUtil.setData`. Static API: +- `TerminalHistory.extend(xterm)` — creates, enables, and registers history on the terminal +- `TerminalHistory.of(xterm)` — retrieves the registered instance (returns `null` if none) +- A terminal can have only one `TerminalHistory`; the constructor throws if one already exists. +- Uses a `LinkedList` with a bidirectional `ListIterator`. +- Arrow-up shows previous entry; Arrow-down shows next (restores the original line when reaching the end). +- When Arrow-up is first pressed, captures the current line as `initialLine` via `getCurrentLine()` async call before showing history. This is restored on Arrow-down past the last entry. +- Empty/whitespace-only lines are not added. Duplicate consecutive entries are allowed. +- History write sequence: `\033[<2K\033[`. The actual terminal div is in light DOM (`slot="terminal-container"`) — created by `XTermBase` Java constructor. +- Terminal is opened in `_slotchange()` → `requestAnimationFrame` → `terminal.open(slotted div)` → dispatches `terminal-initialized`. +- Bell: pre-generated 800 Hz sine WAV (0.1 s, 44100 Hz) encoded as `data:Audio/WAV;base64,...`. `bellStyle` defaults to `'none'`; set to `'sound'` to enable via `setBellStyle(BellStyle.SOUND)`. +- `_onData(e)`: replaces `\r` with `\x1b[` (abstract TS class)**: +- `$` — reference to the mixin host (typed as `T extends TerminalMixin`). +- `$node` — the `XTermElement` instance. +- `$core` — `(terminal as any)._core` (xterm.js internal core, not part of public API). +- `activate()` is the xterm.js `ITerminalAddon.activate` implementation — stores refs and calls `activateCallback`. +- `dispose()` calls `dispose()` on all `_disposables`. + +### `xterm-console-mixin.ts` — Console feature + +Custom CSI escape sequences with `<` prefix to avoid conflicts with standard xterm.js sequences. These are used both by key handlers (sent to `terminal.write`) and directly testable via `XTermElement.write()`: + +| Sequence | Param | Action | +|---|---|---| +| `\x1b[ addon.writePrompt())` to guarantee all pending writes are flushed before the prompt is positioned. + +`__yPrompt` tracks which buffer row the prompt was written on. If `writePrompt` is called again on the same row (re-prompting), it overwrites rather than inserting a new one. + +### `xterm-clipboard-mixin.ts` — Clipboard feature + +- `useSystemClipboard` defaults to `"write"` (writes to system clipboard, reads from internal buffer only). Set to `"readwrite"` to also paste from system clipboard. +- `Ctrl-C` and `Ctrl-V` are registered with no handler — this suppresses xterm.js default behavior (xterm.js would otherwise let Ctrl-C through to the terminal). Server-side key listeners may handle these if needed. +- Auto-copy on selection: `terminal.onSelectionChange` writes selected text to `writeText()` when `copySelection` is true. +- Right-click (`contextmenu`) pastes if `pasteWithRightClick` is true and `rightClickSelectsWord` option is false. +- Middle-click (`auxclick` with `button == 1`) pastes if `pasteWithMiddleClick` is true. +- Both paste operations check `_internalClipboard !== undefined` before attempting to paste. +- `navigator.clipboard` errors are caught and logged to console (not re-thrown). + +### `xterm-fit-mixin.ts` — Fit feature + +Subclasses the npm `FitAddonBase` from `xterm-addon-fit`. After each `fit()` call, sets `viewport._viewportElement.style.width = 'unset'` in a `requestAnimationFrame` (fixes a layout overflow bug). `fitOnResize` defaults to `true` — adds a `window.resize` listener. The public `fit()` method calls `proposeDimensions()` synchronously then defers the actual `fit()` call via `setTimeout` (to handle the case where the element is not yet sized). + +### `xterm-selection-mixin.ts` — Keyboard selection feature + +`keyboardSelectionEnabled` defaults to `true` on the client side. + +Selection state: +- `__selectionAnchor`: flat position (`y * cols + x`) where selection started; `undefined` when no selection. +- `__selectionLength`: number of characters selected (always non-negative after direction flip). +- `__selectionRight`: whether the active end is to the right of the anchor. + +Shift+Arrow extends the selection in the arrow direction. When selection length would go negative, the direction flips and `__selectionRight` is toggled. Plain Arrow (no shift) clears selection via `terminal.clearSelection()`. + +Shift+Home: moves anchor to start of logical line (accounting for prompt). Shift+End: moves to end of logical line. + +Delete/Backspace with active selection: if the selection is entirely within the current logical line, uses custom CSI sequences to delete the selected characters (`\x1b[<{len}L` cursor back if selecting leftward, then `\x1b[<{len}D` delete chars). Stops further processing via `ev.stopImmediatePropagation()`. If selection spans multiple lines or is outside the current line, just clears selection. + +### `xterm-insertfix-mixin.ts` — Insert mode fix + +Patches xterm.js internal `_inputHandler._parser._printHandler` to fix a bug where inserting characters at a wrapped line boundary in insert+wraparound mode causes incorrect behavior. When the condition is met (insert mode + wraparound + overflow), it: +1. Finds the wrapped line range. +2. Wraps overflow characters from the last line to a new line below. +3. Shifts characters from each line in the range to the next. +4. Then calls the original print handler. + +The original handler is restored on `dispose()`. + +### `xterm.ts` — Final composition + +```typescript +@customElement('fc-xterm') +class XTermComponent extends XTermInsertFixMixin( + XTermClipboardMixin( + XTermConsoleMixin( + XTermSelectionMixin( + XTermFitMixin(XTermElement) + ) + ) + ) +) {} +``` + +Mixin application order matters for addon loading. Every mixin calls `super.connectedCallback()` before its own setup code, so the innermost class's code runs first. Actual addon loading order: +1. `XTermElement.connectedCallback` — terminal `onData`/bell/custom-key handler setup +2. `XTermFitMixin` — loads `FitAddon`, calls `this.fit()` +3. `XTermSelectionMixin` — loads `SelectionAddon` +4. `XTermConsoleMixin` — loads `ConsoleAddon` +5. `XTermClipboardMixin` — loads `ClipboardAddon` +6. `XTermInsertFixMixin` — loads `InsertFixAddon` (outermost class, but its own code runs last) + +## Demo Views + +`DemoLayout` — trivial `RouterLayout` (full-size Div). Used as parent layout for demo routes. + +`DemoView` (`@Route("")`) — immediately forwards to `XtermDemoView` via `BeforeEnterEvent.forwardTo`. + +`XtermDemoView` (`@Route("xterm")`, layout `DemoLayout`) — the actual interactive demo. Demonstrates real-world usage of the API: +- Sets prompt `[user@xterm ~]$ ` +- Enables `BellStyle.SOUND`, `CursorStyle.UNDERLINE`, cursor blink +- Enables clipboard (READWRITE), right-click paste, middle-click paste, copy-on-select +- Calls `TerminalHistory.extend(xterm)` for arrow-up/down history +- Commands handled in `addLineListener`: `time`, `date`, `beep` (`\u0007`), `color on`/`color off` (changes `TerminalTheme`), `history` (prints `TerminalHistory.of(xterm).getLines()`), `prompt off`/`prompt ` +- `showHistory()` uses `TerminalHistory.of(xterm).getLines()` and formats with index + +## Testing + +### Unit tests (run without a browser) + +**`PreserveStateAddonTest`**: +- `smoke()` — just `new PreserveStateAddon(new XTerm())` +- `writeGoesToScrollbackBuffer` — `write("foo")`, `writeln("bar")`, `write("baz")` → buffer = `"foobar\nbaz"` +- `promptGoesToScrollbackBufferAfterSubmit` — fires `LineEvent` via `ComponentUtil.fireEvent(component, new ITerminalConsole.LineEvent(xterm, true, "line"))` — prompt+line+`\n` are added per submit, not on `writePrompt()` +- `clearClearsScrollbackBuffer` / `resetClearsScrollbackBuffer` — verify buffer is empty after clear/reset + +**`SerializationTest`**: +- `testSerialization()` — round-trips `new XTerm()` through Java serialization +- `testTerminalHistorySerialization()` — verifies that `ListIterator` cursor position is preserved through serialization/deserialization (uses reflection to access package-private `listIterator()`) + +**`StateMemoizerTest`**: covers empty apply, pass-through, partial replay, sequential setter calls (last value wins). + +### Integration tests (`*IT.java`, browser-based) + +All extend `AbstractViewTest`: +- Sets up ChromeDriver via `WebDriverManager.chromedriver().setup()` +- Navigates to `http://localhost:8080/xterm/it` (configurable via constructor) +- Calls `getCommandExecutor().waitForVaadin()` after navigation +- `ScreenshotOnFailureRule` saves screenshots on test failure +- Hub mode: if system property `test.use.hub=true`, connects to Selenium Hub at env var `HOSTNAME` + +**`XTermElement`** (TestBench page object for ``): +- `write(text)` — calls `this.terminal.write(text)` via JS (does not wait for flush) +- `sendKeys(keys)` — sends to `this.terminal.textarea` (hidden input), then waits for xterm.js to finish processing via `executeAsyncScript` with a `terminal.write('', callback)` barrier +- `currentLine()` — reads element property `this.currentLine` +- `lineAtOffset(n)` — reads `buffer.lines.get(ybase+y+n)`, returns trimmed string +- `cursorPosition()` — returns `new Position(buffer.cursorX, buffer.cursorY)` +- `getColumnWidth()` — returns `this.terminal.cols` +- `getSelection()` — returns `this.terminal.getSelection()` +- `setUseSystemClipboard(boolean)` — sets element property `useSystemClipboard` +- `setPrompt(String)` — sets element property `prompt` +- `executeScript(script, args...)` — binds `this` to the element: wraps script as `function(arguments){...}.bind(element)(args)` so `this` is always the `` element + +**`Position`** — cursor position helper (Lombok `@AllArgsConstructor`, `@EqualsAndHashCode`, `@ToString`): +- `advance(dx, dy)` — **mutates** this instance, returns `this` +- `plus(dx, dy)` — returns a **new** `Position` +- `adjust(columnWidth)` — converts flat index (`x + y*cols`) back to `(col, row)` — used when computing positions that wrap across lines + +**`XTermTestUtils.makeFullLine(term, hasPrompt)`** — builds a string of repeating `"0123456789"` that exactly fills from the current cursor x (if `hasPrompt=true`) or from column 0 (if `false`) to the right edge. Used to construct lines that wrap. + +**`XTermIT`** — Tests: +- `componentWorks` — verifies `` has a shadowRoot and the slotted terminal-container div +- `writeText` — typing text and pressing Enter updates `currentLine` and `cursorPosition` correctly +- `customKeyHandlerLowLevel` — `customKeyEventHandlers.register(predicate, handler)` API: multiple handlers for the same key all fire; `dispose()` removes them +- `customKeyHandlerRegistrationOrder` — handlers fire in registration order (verifies `keyCount` goes `1→12`, not `21`) +- `customKeyHandlerStopImmediatePropagation` — calling `ev.stopImmediatePropagation()` in a handler prevents later handlers from running (but earlier ones already ran) +- `customKeyHandlerHighLevel` — `registerCustomKeyListener({key:'E'})` returns a numeric ID; the `CustomKey` DOM event fires **once** even if the key is registered multiple times; deregister via `customKeyEventHandlers.remove(id)` + +**`ConsoleFeatureIT`** — Tests: +- `testWriteWrappedLine` — typing more chars than the column width wraps correctly; `currentLine` returns the full logical line +- `testFeature` — all keyboard navigation: Arrow keys, Home, End, Backspace, Delete, Insert mode toggle; also tests that insert mode correctly pushes characters into a wrapped line +- `testCsiSequences` — directly sends CSI escape sequences via `term.write()` and verifies cursor position / line content +- `testCsiSequencesWrapped` — same but on a multi-row wrapped line, including parameterized `\x1b[` → client `_slotchange()` fires in `requestAnimationFrame` → `terminal.open(div)` → fires `terminal-initialized` → server drains `deferredCommands`. +- **Deferred commands vs. return values:** All `void` JS calls made before `terminal-initialized` are buffered in `deferredCommands` and replayed. Any call returning a `CompletableFuture` before initialization throws `IllegalStateException` immediately. +- **Custom CSI prefix `<`:** The console mixin uses the `<` intermediate byte (`0x3C`) in CSI sequences to namespace all its operations. Standard xterm.js sequences never use this prefix, so there is no conflict. +- **`_onData` Enter routing:** The LitElement `_onData` handler replaces `\r` with `\x1b[