diff --git a/docs/arduino-ide-comparison.md b/docs/arduino-ide-comparison.md new file mode 100644 index 0000000..4a54546 --- /dev/null +++ b/docs/arduino-ide-comparison.md @@ -0,0 +1,212 @@ +# tinyStudio vs. Arduino IDE 2.x — Core Functionality Comparison + +**Date:** July 2, 2026 +**Scope:** Core IDE functionality only (editing, building, uploading, board/port handling, serial monitor, library/board management). The Visual and Circuit views are intentionally excluded. +**Sources compared:** `tinyStudio` (v0.2.0, this repo, plus the `@mister-industries/tinyservice` backend it ships) and `arduino-ide` (v2.3.11, `arduino-ide-extension` sources). + +--- + +## 1. Executive Summary + +tinyStudio covers a solid slice of the Arduino IDE's core loop: verify, upload, serial monitor, connected-board detection, a Library Manager, and a Boards Manager with additional-URL support. The largest functional gaps are in **code intelligence** (Arduino IDE runs a full clangd-based language server; tinyStudio has a regex syntax highlighter), **board/port architecture** (Arduino IDE has an event-driven port watcher, per-port monitor services, board options, programmers, and manual board selection; tinyStudio has a polled snapshot with a combined board+port dropdown), and the **serial monitor pipeline** (Arduino IDE uses arduino-cli's gRPC monitor API with pause/resume around uploads and persisted per-port settings; tinyStudio spawns and kills `arduino-cli monitor` CLI processes and filters its text output). + +The device-detection and monitor-switching behavior you've noticed is not a tuning issue — the two products use fundamentally different mechanisms. Arduino IDE talks to a long-lived `arduino-cli daemon` over gRPC and *subscribes* to port add/remove events (`BoardListWatch`), so plug/unplug appears within ~100 ms. tinyStudio shells out to `arduino-cli board list` on a timer that **stops polling once one board is found**, so unplugs and port changes go unnoticed until a manual refresh. Details in §3. + +Several concrete state bugs were found in tinyStudio's serial path, including a false "connected" status sent before the port actually opens, a filter that swallows the port-busy error message, a stale process-close handler that can delete the *new* monitor's registration, and an auto tab-switch that hides compile errors 400 ms after a failed build. Details in §5. + +--- + +## 2. Architecture: How Each IDE Talks to arduino-cli + +This difference explains most of the behavioral gaps, so it's worth stating plainly. + +**Arduino IDE 2.x** starts `arduino-cli daemon` once and keeps a persistent **gRPC** connection to it (`node/arduino-daemon-impl.ts`, `node/core-client-provider.ts`). Every operation — compile, upload, board list, monitor — is a gRPC call or a long-lived gRPC *stream* against that daemon. Streams matter: the daemon pushes port events and monitor data to the IDE the instant they happen, requests carry structured protobuf payloads (no text parsing), progress is reported natively, and operations are cancellable. + +**tinyStudio** spawns a fresh `arduino-cli ` child process per operation (`tinyservice/services/arduino-cli.service.js`) and parses its stdout — JSON for lists, raw text for everything else. The renderer talks to tinyService over a local WebSocket, and responses are correlated to requests **by action name only** — there are no request IDs (`WebSocketArduinoService.waitForResponse()`). The CLI-per-call model means every board scan pays process startup cost, there is no push channel for hardware events, upload progress must be faked (see Bug 9), and two in-flight requests with the same action name will cross-talk. + +This isn't a demand to rewrite on gRPC — arduino-cli's daemon mode is exactly what it exists for, and adopting it (or at least `board list --watch`, which streams JSON events over stdout **without** the daemon) would close the biggest gaps at moderate cost. + +--- + +## 3. Device Detection — Why Arduino IDE Feels Better + +**Arduino IDE** (`node/board-discovery.ts`): a singleton backend service opens a `BoardListWatch` gRPC stream at startup. The CLI's pluggable discoveries (serial, mDNS, etc.) push `add`/`remove` events; the IDE debounces them for 100 ms, diffs against current state, and broadcasts to the frontend. Consequences: + +- Plug in a board → it appears in the toolbar within ~100 ms. Unplug → it disappears just as fast. +- Ports that change address after upload (native-USB boards like your ESP32-S3 re-enumerate when the sketch resets) are tracked as remove+add events, and the IDE re-associates the selected board with the new port (`boards-service-provider.ts`). +- Network (mDNS/OTA) boards are discovered too, not just serial ports. +- The selected board + port are persisted per window and restored on restart (`selectedBoardStorageKey` in `boards-service-provider.ts`). +- If a detected board's platform isn't installed, the IDE prompts to install it (`boards-auto-installer.ts`). +- Ports with no identifiable board can still be used: the "Select other board and port" dialog lets the user manually pair any FQBN with any port (`boards-config-dialog.tsx`), and that pairing is remembered. + +**tinyStudio** (`useArduino.ts`, `arduino-cli.service.js#listBoards`): runs `arduino-cli board list` as a one-shot snapshot. Polling only runs **while zero boards are known** (every 8 s) and stops as soon as one is found — the comment in `useArduino.ts:585` says plug/unplug after that "is picked up by the manual Refresh or the next operation." On top of that, `WebSocketArduinoService.listBoards()` throttles to one real scan per 5 s and silently serves a cached list otherwise. Consequences, all of which match the symptoms you described: + +- Unplugging the selected board is never detected; the UI keeps showing it as connected until an upload fails. +- Swapping boards, or a board re-enumerating on a different COM port after flash, is invisible until manual refresh. +- Clicking Refresh within 5 s of the last scan animates the spinner but returns the stale cache. +- Unknown VID/PID devices are guessed by a hardcoded table (`identifyByUsb`) — any Espressif VID `303A` is labeled a tinyCore, and CP210x bridges produce a board with an **empty FQBN** that will fail to compile if selected. There is no manual board-to-port pairing to correct a wrong guess. +- Nothing is persisted; board selection resets every launch. + +**Recommendation:** replace the poll with `arduino-cli board list --watch --format json` (a long-lived child process that streams add/remove events as JSON lines — no gRPC required), keep the port list in tinyService as the single source of truth, and push changes to the renderer over the existing WebSocket. Remove the 5 s cache once events are push-based. Add a "choose board for this port" flow instead of the `identifyByUsb` guess table. + +--- + +## 4. Serial Monitor — Why Arduino IDE Switches Cleanly + +**Arduino IDE** (`node/monitor-manager.ts`, `node/monitor-service.ts`): each board+port combination gets its own `MonitorService` keyed by `fqbn-address-protocol`, using arduino-cli's **gRPC monitor stream** (not the `monitor` CLI command). Key behaviors: + +- **Upload coordination is first-class.** `notifyUploadStarted()` *pauses* the monitor (releases the port, keeps the service and its buffer/state alive); `notifyUploadFinished()` resumes it — and if the board came back on a *different* port, it stops the old service and starts a queued one on the new port. Monitor open requests that arrive mid-upload are queued, then started only after verifying the board is still present. +- **Settings are real and persisted.** Baud rates come from the board's pluggable monitor (`enumerate_port_settings`), so the list is board-appropriate rather than hardcoded. Baud, line ending (None/NL/CR/Both NL&CR), autoscroll, and timestamps are persisted per port (`node/monitor-settings/`, `browser/monitor-model.ts`). +- **Raw data fidelity.** The stream delivers bytes as-is to the frontend; timestamps are applied at render time; nothing is trimmed or filtered. +- There is also a **Serial Plotter** fed by the same monitor stream (`browser/serial/plotter/`). + +**tinyStudio** (`tinyservice/handlers/serial.handler.js`, `SerialContext.tsx`): opening the monitor spawns an `arduino-cli monitor -p ` child process; closing kills it (`taskkill /T /F` on Windows). The renderer keeps one app-level connection (`SerialProvider`) that auto-opens whenever a board is selected. Functional gaps against the IDE: + +- Only five hardcoded baud rates (9600–115200); missing 300–4800, 19200, 74880 (ESP boot messages), 230400, 460800, 921600, 2000000, and anything board-specific. +- No line-ending choice — every send unconditionally appends `\n` (`serial.handler.js:12`). +- No timestamps option; no persistence of baud/autoscroll across restarts (baud resets to 9600). +- Output is line-ified and sanitized: every chunk is split on newlines, each line is trimmed, blank lines are dropped, and any line starting with `Connected to`, `Disconnected`, etc. is discarded — so sketches that print those words, print leading whitespace, use `\r`-based progress output, or use `Serial.print()` without newline get visibly distorted output. +- No Serial Plotter. +- The port is held continuously while a board is selected (auto-open on selection). This also DTR-resets the board every time the app starts or the port reopens. Arduino IDE only opens the port while the monitor is actually in use. +- Because open/close is kill-and-respawn of a CLI process rather than pause/resume of a service, every upload/baud-change/reconnect pays process startup, reset, and the race conditions described in §5. + +--- + +## 5. Bugs and State Issues Found in tinyStudio + +These are concrete defects observed in the current code, roughly ordered by impact. Items 1–5 together explain the "weird COM port state" you've been seeing. + +**1. "Connected" is reported before the port actually opens.** +`serial.handler.js:70-74` sends `{opened: true}` ("Listening on PORT @ BAUD") immediately after `spawn()`, before arduino-cli has opened the port. If the port is busy or gone, the UI flashes connected, then flips to disconnected when the child exits — with no error shown, because of Bug 2. + +**2. The output filter swallows the actual error messages.** +The same handler's `emit()` filter (lines 36–43) deliberately drops lines containing `Port monitor error` and `command 'open' failed`. Those are exactly the lines arduino-cli prints when the port is busy or missing. Net effect: a failed monitor open is completely silent — combined with Bug 1, the user sees "connected… disconnected" with no explanation. + +**3. Stale close-handler can delete the *new* monitor's registration (Windows-biased race).** +`SerialHandler.close()` kills the old child and resolves on its `exit` event **or a 1.5 s fallback timeout** — on Windows the kill is an async `taskkill /T /F` whose result is ignored. If the old process outlives that window (slow taskkill, exclusive-port teardown), the new monitor is spawned and stored in `this.monitors` under the same connection ID; when the old child finally dies, its still-attached `close` handler runs `this.monitors.delete(connection.id)` — deleting the **new** child's entry — and pushes `{closed: true}` to the client. Result: UI shows disconnected while data still streams, `serial-write` silently no-ops (no child found), and the orphaned entry can't be closed cleanly. While the old process lingers it also still holds the COM port, so the new spawn hits Bug 1 + Bug 2. Fix: key handlers to the specific child (compare `this.monitors.get(id) === child` before deleting/emitting), and don't reuse the connection ID as the sole key. + +**4. Unplug/replug is undetected while a board is selected.** +`useArduino.ts:585-592` — the 8 s poll only runs when `boards.length === 0`. Once a board is listed, hardware changes are invisible (see §3). The stale selection then feeds uploads to a dead port and monitor opens to a dead port (which fail silently per Bugs 1–2). + +**5. Refresh within 5 s returns cached results while claiming a fresh scan.** +`WebSocketArduinoService.listBoards()` throttle (5 s) returns `cachedBoardList` silently; `refreshBoards()` still logs "Scanning for connected boards… Found N board(s)". The user's replugged board doesn't appear even though they explicitly asked for a rescan. + +**6. Failed compile output is hidden 400 ms after the build finishes.** +`SerialMonitor.tsx:38-45` — when `isCompiling || isUploading` goes false, the panel switches back to the Serial tab after 400 ms **unconditionally**, including on failure. The user gets a red toast, but the Output pane with the actual compiler errors is yanked away. It also overrides the tab the user selected manually. Arduino IDE keeps the Output panel up and additionally renders errors inline in the editor. Fix: only auto-return to Serial on *success*, and never if the user manually switched tabs during the build. + +**7. Response cross-talk: no request IDs on the WebSocket protocol.** +`waitForResponse()` matches replies by action string alone. Two overlapping `lib-search` calls (easy to trigger by typing in the Library Manager), or `list-boards` from two code paths, will interleave and resolve each other's promises with mixed output. Needs a per-request ID echoed by tinyService. + +**8. Every `tinyCore:*` FQBN is collapsed to one board variant.** +`normalizeTinyCoreFqbn()` (`WebSocketArduinoService.ts:80-85`) rewrites *any* detected tinyCore FQBN to `tiny_core_esp32s3_nopsram`, and `identifyByUsb()` claims every VID `303A` (all native-USB Espressif chips) as a tinyCore. Any future tinyCore variant — or any other ESP32-S2/S3/C3 board — gets compiled for the wrong target with no way to override. Related: CP210x devices are surfaced with `fqbn: ""`, which is selectable and then fails at compile time. + +**9. Upload progress bar is fake.** +`useArduino.ts:418-432` — a `setInterval` bumps the percentage by 10 every 200 ms up to 90%. It reflects wall-clock time, not the actual flash. arduino-cli reports real upload progress (the IDE consumes it via gRPC); at minimum, esptool's stdout percentages could be parsed from the streamed output. + +**10. Timeout "success sniffing" doesn't match your own boards.** +`waitForResponse()`'s timeout fallback (lines 195–211) infers a successful upload from `avrdude done` / `Upload complete` — AVR-only strings. ESP32 uploads (tinyCore's own target) end with esptool's `Hash of data verified` / `Hard resetting`, so a slow-but-successful ESP32 upload that hits the 90 s timeout is misreported as a failure. + +**11. Stale-closure bugs in the timeout-recovery paths.** +`compileSketch` reads `lastCompileResult` (line 337) and `uploadSketch` reads `lastUploadResult` (line 501), but neither is in its `useCallback` dependency array — the recovery logic always sees the value from when the callback was created. Low impact (only affects the timeout paths), but worth fixing while in there. + +**12. tinyService binds port 3000 with no fallback.** +`ServiceManager` hardcodes `ws://localhost:3000`. If any other dev server holds 3000, the backend fails and the app reports the agent as offline. A port scan + handoff of the chosen port to the renderer (it already supports a `tinyservice.url` override) would fix this. + +**13. Double/ambiguous `closed` events.** +On `serial-close`, both the handler's explicit `complete/closed` reply and the child's own `close` event handler fire `{closed: true}` messages, and the server's upload path (`websocket.service.js:90-95`) sends a third variant. Harmless today because the reducer is idempotent, but it makes ordering bugs like #3 harder to see and debug. + +--- + +## 6. Feature Gap Matrix + +Legend: ✅ present · 🟡 partial · ❌ missing. "Ignoring" cloud accounts and Arduino Cloud sync as non-core. + +### 6.1 Code Editing & Intelligence + +| Feature | Arduino IDE 2.x | tinyStudio | +|---|---|---| +| Syntax highlighting | ✅ semantic (clangd) | 🟡 regex tokenizer (`MonacoEditor.tsx`); no user-library symbols | +| Code completion | ✅ Arduino Language Server (clangd, board-aware) | 🟡 Monaco word-based suggestions only | +| Go to definition / hover docs / signature help | ✅ | ❌ | +| Live diagnostics while typing | ✅ | ❌ | +| Compile errors shown inline in editor (squiggles + jump) | ✅ (`compiler-errors.ts`, `cli-error-parser.ts`) | ❌ errors only as text in Output pane | +| Auto-format (ClangFormat, Arduino style, `.clang-format` override) | ✅ Ctrl+T | ❌ | +| Find/replace in file | ✅ | ✅ (Monaco built-in) | +| Find across sketch/workspace | ✅ | ❌ (no UI) | +| Auto-save | ✅ (default on) | 🟡 manual Ctrl+S; dirty buffers are flushed before Verify/Upload | +| Multi-file sketch tabs (.ino/.h/.cpp) | ✅ | ✅ | +| Undo history preserved across tab switches | ✅ | ✅ (`keepCurrentModel`) | + +### 6.2 Boards, Ports & Detection + +| Feature | Arduino IDE 2.x | tinyStudio | +|---|---|---| +| Event-driven port watch (instant plug/unplug) | ✅ gRPC `BoardListWatch` | ❌ snapshot polling, stops after first board (§3) | +| Port re-association after upload re-enumeration | ✅ | ❌ | +| Manual "select other board and port" pairing | ✅ dialog, searchable, remembered | ❌ only auto-detected entries selectable | +| Separate board vs. port selection | ✅ | ❌ combined; `PortSelect` is a disabled stub | +| Board/port persisted across restarts | ✅ | ❌ | +| Board options (Tools menu: PSRAM, partition, CPU freq, etc.) | ✅ `boards-data-store.ts`, FQBN options | ❌ FQBN fixed; tinyCore variants collapsed (Bug 8) | +| Programmer selection / Upload Using Programmer | ✅ | ❌ | +| Burn Bootloader | ✅ | ❌ | +| Prompt to install missing platform for detected board | ✅ `boards-auto-installer.ts` | 🟡 auto-installs esp32 + tinyCore only, at startup | +| Network/OTA (mDNS) board discovery + upload | ✅ | ❌ serial only | +| Board details view (VID/PID etc.) | ✅ | ❌ | + +### 6.3 Build & Upload + +| Feature | Arduino IDE 2.x | tinyStudio | +|---|---|---| +| Verify / Upload with streamed output | ✅ | ✅ | +| Real upload progress | ✅ gRPC progress | ❌ simulated (Bug 9) | +| Cancellable compile/upload | ✅ | ❌ no cancel; UI locked until timeout | +| Verbose output toggle, warning-level prefs | ✅ preferences | ❌ | +| Export compiled binary / build path access | ✅ | ❌ | +| Monitor paused & resumed around upload (incl. port change) | ✅ `monitor-manager.ts` | 🟡 closed before upload (client + server), reopened by effect; kill/respawn races (§5) | +| Serial-port auth / user fields for upload | ✅ | ❌ | +| Firmware & certificate uploader (WiFi radios) | ✅ | ❌ | +| Debugging (hardware debug via arduino-cli debug) | ✅ | ❌ | + +### 6.4 Serial Monitor & Plotter + +| Feature | Arduino IDE 2.x | tinyStudio | +|---|---|---| +| Serial Monitor | ✅ gRPC stream service | 🟡 spawned `arduino-cli monitor` process (§4) | +| Board-specific baud list | ✅ from pluggable monitor | ❌ 5 hardcoded rates | +| Line-ending selector (None/NL/CR/NL+CR) | ✅ | ❌ always `\n` | +| Timestamps toggle | ✅ | ❌ | +| Settings persisted per port | ✅ | ❌ | +| Raw output fidelity (whitespace, partial lines, CR) | ✅ | ❌ trimmed/filtered/line-split (§4) | +| Serial Plotter | ✅ | ❌ (Visual view exists but is out of scope per your note) | +| Monitor usable while no sketch open | ✅ | ✅ | + +### 6.5 Sketch, Library & Platform Management + +| Feature | Arduino IDE 2.x | tinyStudio | +|---|---|---| +| New sketch / open / save | ✅ | ✅ (project dialog + file explorer) | +| Save As, rename, archive sketch (.zip), delete | ✅ | ❌ (file-explorer rename/delete of files, but no sketch-level ops) | +| Recent sketches / sketchbook view | ✅ | ❌ no recent-projects list; reopen via folder picker each time | +| Examples menu from installed cores & libraries | ✅ auto-generated | 🟡 curated `examples.json` only | +| Include Library (auto-insert `#include`) | ✅ | ❌ | +| Add .ZIP library | ✅ | ❌ | +| Library Manager (search/install/uninstall/version) | ✅ | ✅ (`LibraryManager.tsx`) | +| Library type/topic filters, update-all | ✅ | ❌ | +| Boards Manager + additional URLs | ✅ | ✅ (`BoardManager.tsx`) | +| Index auto-update + progress UI | ✅ | 🟡 update on URL add only | +| Open sketch in external editor / reveal in OS | ✅ | ❌ | + +*(Not itemized: Theia platform niceties — command palette, keyboard shortcut editor, i18n, interface scaling. Real, but lower priority.)* + +--- + +## 7. Prioritized Recommendations + +1. **Switch board detection to `arduino-cli board list --watch --format json`** streamed from tinyService, and delete the 8 s poll + 5 s cache. This single change fixes Bugs 4–5 and closes most of the perceived detection gap. (§3) +2. **Fix the serial monitor lifecycle** (Bugs 1–3): only report `opened` after arduino-cli confirms the port, stop filtering error lines (surface them as `error` messages instead), and bind child-process event handlers to the specific child instance. Consider arduino-cli's daemon/gRPC monitor as the longer-term replacement for kill-and-respawn. +3. **Stop hiding failed builds** (Bug 6): keep the Output tab up on failure; parse `file:line:col` from compiler output and set Monaco markers so errors appear in the editor — this is cheap with `monaco.editor.setModelMarkers` and gets you 70% of the IDE's inline-error value. +4. **Add request IDs to the tinyService protocol** (Bug 7) — prerequisite for everything else being reliable under concurrency. +5. **Board options + manual board selection**: expose FQBN config options (arduino-cli reports them via `board details`) and a "choose board for this port" dialog; remove the `303A → tinyCore` assumption and the FQBN collapse (Bug 8). +6. **Monitor UX parity**: full/board-derived baud list, line-ending selector, timestamps, persist settings per port. All small, all frontend + one handler. +7. **Adopt the Arduino Language Server** (`arduino-language-server` + clangd, both distributed as binaries like arduino-cli): Monaco speaks LSP via `monaco-languageclient`. This is the single biggest step toward "everything Arduino IDE does" in the editor. +8. Quality-of-life follow-ups: real upload progress (parse esptool percentages or use gRPC), cancellable operations, ESP32-aware success strings (Bug 10), port-3000 fallback (Bug 12), Include Library / Add .ZIP, sketch archive/save-as. diff --git a/docs/parity-implementation-notes.md b/docs/parity-implementation-notes.md new file mode 100644 index 0000000..43614a3 --- /dev/null +++ b/docs/parity-implementation-notes.md @@ -0,0 +1,93 @@ +# Arduino IDE Parity — Implementation Notes (July 2026) + +Companion to `arduino-ide-comparison.md`. This documents what was implemented +from that report's §7 recommendations, across **tinyStudio** (branch +`development`) and **tinyService** (branch `claude-development`, shared +`1.2.0` / service `1.1.0`). Scope notes honored: the Visual view stands in +for the Serial Plotter, the Library Manager UX was left as-is, and everything +works in both the desktop and browser builds (LSP is desktop-only — the +language server needs the sketch on disk). + +## What changed + +**1. Event-driven board detection (report §3, Bugs 4–5).** tinyService now +runs one long-lived `arduino-cli board list --watch --format jsonmini` +process (`board-watch.service.ts`) and broadcasts a `board-events` push to +every client on plug/unplug (~150 ms debounce). New clients get a snapshot on +connect; `list-boards` is served instantly from the watcher's memory. The +frontend subscribes (`useArduino.ts`) instead of polling — the 8 s +poll-until-first-board and the 5 s refresh cache are gone. Unplugging the +selected board now clears the selection with a toast; a replacement port is +adopted automatically. + +**2. Serial monitor lifecycle (Bugs 1–3, 13).** `serial.handler.ts` was +rebuilt around per-session objects: `opened` is only reported once confirmed +(banner, first sketch output, or survive-the-confirm-timer for arduino-cli +≥1.x's quiet piped mode); port-open failures are surfaced as `error` messages +carrying arduino-cli's own words (no longer filtered away); stale +child-process handlers can no longer delete or "close" their replacement +session (the Windows taskkill race); output is forwarded verbatim (partial +lines flushed after 120 ms) instead of trimmed/filtered. + +**3. Failed builds stay visible + inline errors (Bug 6, rec 3).** The +monitor panel only snaps back to Serial on success, and never overrides a tab +the user picked mid-build. Compiler output is parsed into `file:line:col` +diagnostics (`lib/compileErrors.ts`) and rendered as Monaco markers in the +matching file (`arduino-compile` owner). + +**4. Request ids (Bug 7).** The shared protocol carries an optional `id` on +every request; the service echoes it on all replies; `waitForResponse` +matches on it. Concurrent same-action requests no longer cross-talk. Fully +backward compatible (id-less messages still work). + +**5. Board settings gear (rec 5, Bug 8).** New `BoardOptionsMenu` next to +the port pill: FQBN config options (PSRAM, partition scheme, CPU freq, …) via +the new `board-details` action, encoded into the FQBN like the Arduino IDE; +plus "Change board…" — a searchable picker over installed board definitions +for wrong VID/PID guesses (guessed boards are labeled). The +every-tinyCore-FQBN-collapses-to-one-variant behavior was removed; VID +`303A` still defaults to tinyCore (tradeoff kept) but is flagged as a guess +and overridable. + +**6. Monitor UX parity (rec 6).** Full baud list (300 → 2 000 000, incl. +74880 for ESP boot messages), line-ending selector (None/NL/CR/Both — sends +are raw now, the backend appends nothing), timestamps toggle, and per-port +persistence of baud + line ending (localStorage). + +**7. Language server (rec 7).** tinyService bridges +`arduino-language-server` + clangd over WebSocket at `/lsp?fqbn=…` +(`lsp.service.ts`; stdio framing handled server-side). The renderer has a +dependency-free LSP client (`lib/lsp/monacoLsp.ts`) wiring completion, hover, +signature help, and live diagnostics into Monaco. Desktop-only; degrades +silently when binaries are missing. **Fetch binaries with:** +`npm run fetch:language-server -- current` (they land in +`vendor/language-server/` and are picked up automatically; packaged builds +bundle them via electron-builder.yml). + +**8. Quality of life (rec 8, Bugs 9, 10, 12).** Real upload progress parsed +from esptool/avrdude output (the fake 10%-per-200ms timer is gone); esptool +success markers recognized in the timeout fallback; tinyService binds the +first free port from 3000 (the renderer asks the main process for the real +URL — web builds keep the `tinyservice.url` localStorage override); stale +React closures in the compile/upload timeout-recovery paths fixed. + +## Not done (deliberately) + +Programmer selection / Upload Using Programmer / Burn Bootloader (needs new +service actions — small follow-up now that `board-details` returns +programmers), Include Library / Add .ZIP, sketch archive/save-as, network +(mDNS) upload, and Library Manager UX changes (kept per preference). + +## To ship + +1. Publish `@mister-industries/shared@1.2.0` and + `@mister-industries/tinyservice@1.1.0` from the tinyService repo, then + bump both deps in tinyStudio's package.json. (Until then, the freshly + built dists were copied into `node_modules/@mister-industries/*/dist` for + local testing — `npm install` will overwrite them.) +2. `npm run fetch:language-server -- current` for LSP in dev. +3. Test on real hardware: plug/unplug detection, upload with the monitor + open, monitor across baud changes, a failing sketch (inline errors), the + board options gear on a tinyCore, and — if binaries fetched — completion + and hover in the editor. The LSP client is new wiring and has not run + against real hardware/binaries yet; treat it as experimental. diff --git a/electron-builder.yml b/electron-builder.yml index 367c947..c85b688 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -38,6 +38,13 @@ extraResources: to: arduino-cli/win32-x64/arduino-cli.exe filter: - '**/*' + # arduino-language-server + clangd (optional; fetched by + # scripts/fetch-language-server.mjs). Powers editor code intelligence via + # tinyService's /lsp bridge; the app degrades gracefully when absent. + - from: vendor/language-server + to: language-server + filter: + - '**/*' win: executableName: tinystudio nsis: diff --git a/package.json b/package.json index 094ad16..77eb5a5 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "typecheck": "npm run typecheck:node && npm run typecheck:web", "start": "electron-vite preview", "fetch:arduino-cli": "node scripts/fetch-arduino-cli.mjs", + "fetch:language-server": "node scripts/fetch-language-server.mjs", "prebuild": "node scripts/fetch-arduino-cli.mjs", "predev": "node scripts/fetch-arduino-cli.mjs current", "dev": "electron-vite dev", diff --git a/scripts/fetch-language-server.mjs b/scripts/fetch-language-server.mjs new file mode 100644 index 0000000..74ad4e2 --- /dev/null +++ b/scripts/fetch-language-server.mjs @@ -0,0 +1,161 @@ +// scripts/fetch-language-server.mjs +// +// Downloads the Arduino Language Server + clangd binaries for each platform +// tinyStudio ships and drops them under vendor/language-server//. +// These power the editor's code intelligence (completion, hover, live +// diagnostics) via tinyService's /lsp WebSocket bridge. Entirely optional: +// when the binaries are missing the app runs exactly as before, minus LSP. +// +// Uses the same artifact hosting the Arduino IDE uses: +// https://downloads.arduino.cc/arduino-language-server/nightly/arduino-language-server_ +// https://downloads.arduino.cc/tools/clangd__.tar.bz2 +// +// Idempotent: skips any platform whose binaries already exist. +// +// node scripts/fetch-language-server.mjs # all platforms +// node scripts/fetch-language-server.mjs current # just this machine + +import { execFileSync } from 'node:child_process' +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +// clangd version published on downloads.arduino.cc/tools (same one the +// Arduino IDE 2.x bundles). Bump deliberately. +const CLANGD_VERSION = '14.0.0' +const LS_BASE = 'https://downloads.arduino.cc/arduino-language-server' +const TOOLS_BASE = 'https://downloads.arduino.cc/tools' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const repoRoot = join(__dirname, '..') +const vendorRoot = join(repoRoot, 'vendor', 'language-server') + +function tarExe() { + if (process.platform === 'win32') { + const sys = join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe') + if (existsSync(sys)) return sys + } + return 'tar' +} + +// platform dir -> download suffixes + binary names. +const TARGETS = { + 'windows-x64': { suffix: 'Windows_64bit', archive: 'zip', exe: '.exe' }, + 'macos-x64': { suffix: 'macOS_64bit', archive: 'tar.gz', exe: '' }, + 'macos-arm64': { suffix: 'macOS_ARM64', archive: 'tar.gz', exe: '' }, + 'linux-x64': { suffix: 'Linux_64bit', archive: 'tar.gz', exe: '' }, + 'linux-arm64': { suffix: 'Linux_ARM64', archive: 'tar.gz', exe: '' } +} + +async function download(url, dest) { + const res = await fetch(url) + if (!res.ok) throw new Error(`Download failed (${res.status}) for ${url}`) + writeFileSync(dest, Buffer.from(await res.arrayBuffer())) +} + +/** Recursively find a file by name in a directory tree. */ +function findFile(root, name) { + for (const entry of readdirSync(root)) { + const p = join(root, entry) + if (statSync(p).isDirectory()) { + const found = findFile(p, name) + if (found) return found + } else if (entry === name) { + return p + } + } + return null +} + +async function fetchOne(platform) { + const target = TARGETS[platform] + if (!target) { + throw new Error(`Unknown platform "${platform}". Valid: ${Object.keys(TARGETS).join(', ')}`) + } + + const outDir = join(vendorRoot, platform) + const lsBin = `arduino-language-server${target.exe}` + const clangdBin = `clangd${target.exe}` + const outLs = join(outDir, lsBin) + const outClangd = join(outDir, clangdBin) + if (existsSync(outLs) && existsSync(outClangd)) { + console.log(`✓ ${platform}: already present`) + return + } + mkdirSync(outDir, { recursive: true }) + + const tmp = mkdtempSync(join(tmpdir(), 'arduino-ls-')) + try { + // ── arduino-language-server (nightly channel, like fresh IDE builds) ── + if (!existsSync(outLs)) { + const asset = `arduino-language-server_${target.suffix}.${target.archive}` + console.log(`↓ ${platform}: downloading ${asset} ...`) + await download(`${LS_BASE}/nightly/${asset}`, join(tmp, asset)) + execFileSync(tarExe(), ['-xf', asset], { cwd: tmp, stdio: 'inherit' }) + const extracted = findFile(tmp, lsBin) + if (!extracted) throw new Error(`${lsBin} not found inside ${asset}`) + copyFileSync(extracted, outLs) + if (!platform.startsWith('windows')) chmodSafe(outLs) + console.log(`✓ ${platform}: ${outLs}`) + } + + // ── clangd (bzip2 tarball; bsdtar reads .tar.bz2 natively) ── + if (!existsSync(outClangd)) { + const asset = `clangd_${CLANGD_VERSION}_${target.suffix}.tar.bz2` + console.log(`↓ ${platform}: downloading ${asset} ...`) + await download(`${TOOLS_BASE}/${asset}`, join(tmp, asset)) + execFileSync(tarExe(), ['-xf', asset], { cwd: tmp, stdio: 'inherit' }) + const extracted = findFile(tmp, clangdBin) + if (!extracted) throw new Error(`${clangdBin} not found inside ${asset}`) + copyFileSync(extracted, outClangd) + if (!platform.startsWith('windows')) chmodSafe(outClangd) + console.log(`✓ ${platform}: ${outClangd}`) + } + } finally { + rmSync(tmp, { recursive: true, force: true }) + } +} + +function chmodSafe(p) { + try { + chmodSync(p, 0o755) + } catch { + /* best effort on non-posix hosts */ + } +} + +function hostPlatform() { + const arm = process.arch === 'arm64' + if (process.platform === 'win32') return 'windows-x64' + if (process.platform === 'darwin') return arm ? 'macos-arm64' : 'macos-x64' + if (process.platform === 'linux') return arm ? 'linux-arm64' : 'linux-x64' + throw new Error(`Unsupported host platform: ${process.platform}/${process.arch}`) +} + +async function main() { + const requested = process.argv + .slice(2) + .map((a) => (a === 'current' || a === '--current' ? hostPlatform() : a)) + const platforms = requested.length ? requested : Object.keys(TARGETS) + console.log(`Fetching arduino-language-server (nightly) + clangd ${CLANGD_VERSION} for: ${platforms.join(', ')}`) + for (const p of platforms) { + await fetchOne(p) + } + console.log('Done.') +} + +main().catch((err) => { + console.error('\nfetch-language-server failed:', err.message) + process.exit(1) +}) diff --git a/src/main/ServiceManager.ts b/src/main/ServiceManager.ts index 18d23a7..ee539b3 100644 --- a/src/main/ServiceManager.ts +++ b/src/main/ServiceManager.ts @@ -1,6 +1,8 @@ import { spawn, type ChildProcess } from 'child_process' import { app, BrowserWindow } from 'electron' import { existsSync } from 'fs' +import net from 'net' +import os from 'os' import path from 'path' interface ServiceConfig { @@ -57,10 +59,16 @@ export class ServiceManager { * service's own env/config handling. JSON.stringify keeps Windows paths valid. */ private buildLauncherCode(arduinoCliPath: string): string { + const lsPaths = this.resolveLanguageServerPaths() const opts = JSON.stringify({ port: this.config.port, arduinoCliPath, - allowedOrigins: this.config.allowedOrigins + allowedOrigins: this.config.allowedOrigins, + // Language-server integration (optional — the service degrades to + // "lsp-unavailable" when the binaries aren't present). + lspServerPath: lsPaths?.lspServerPath, + clangdPath: lsPaths?.clangdPath, + cliConfigPath: this.resolveArduinoCliConfigPath() }) return [ `import('@mister-industries/tinyservice')`, @@ -135,6 +143,94 @@ export class ServiceManager { return existsSync(vendored) ? vendored : null } + /** + * Resolve the arduino-language-server + clangd binaries fetched by + * scripts/fetch-language-server.mjs into vendor/language-server// + * (dev) or bundled resources (packaged). Returns null when missing — the + * backend then reports the LSP as unavailable and the editor degrades to + * syntax-highlighting only. + */ + private resolveLanguageServerPaths(): { + lspServerPath: string + clangdPath: string + } | null { + const arch = process.arch + let platformDir: string + let exe = '' + + if (process.platform === 'win32') { + platformDir = 'windows-x64' + exe = '.exe' + } else if (process.platform === 'darwin') { + platformDir = arch === 'arm64' ? 'macos-arm64' : 'macos-x64' + } else if (process.platform === 'linux') { + platformDir = arch === 'arm64' ? 'linux-arm64' : 'linux-x64' + } else { + return null + } + + const root = app.isPackaged + ? path.join(process.resourcesPath, 'language-server', platformDir) + : path.join(app.getAppPath(), 'vendor', 'language-server', platformDir) + + const lspServerPath = path.join(root, `arduino-language-server${exe}`) + const clangdPath = path.join(root, `clangd${exe}`) + if (existsSync(lspServerPath) && existsSync(clangdPath)) { + return { lspServerPath, clangdPath } + } + return null + } + + /** + * Default arduino-cli.yaml location (the language server wants the config + * file arduino-cli itself uses). Returns undefined when the file doesn't + * exist yet — arduino-cli works on defaults without one. + */ + private resolveArduinoCliConfigPath(): string | undefined { + let candidate: string + if (process.platform === 'win32') { + candidate = path.join( + process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), + 'Arduino15', + 'arduino-cli.yaml' + ) + } else if (process.platform === 'darwin') { + candidate = path.join(os.homedir(), 'Library', 'Arduino15', 'arduino-cli.yaml') + } else { + candidate = path.join(os.homedir(), '.arduino15', 'arduino-cli.yaml') + } + return existsSync(candidate) ? candidate : undefined + } + + /** + * Find a free TCP port, starting at the preferred one. Port 3000 is a very + * popular dev-server default, so never assume it's ours — a foreign process + * on 3000 previously made the backend fail to bind and the app report + * "agent offline" with no explanation. + */ + private async findFreePort(preferred: number, attempts = 10): Promise { + for (let port = preferred; port < preferred + attempts; port++) { + const free = await new Promise((resolve) => { + const probe = net + .createServer() + .once('error', () => resolve(false)) + .once('listening', () => { + probe.close(() => resolve(true)) + }) + probe.listen(port, '127.0.0.1') + }) + if (free) return port + } + throw new Error( + `No free port found in ${preferred}-${preferred + attempts - 1} for TinyService` + ) + } + + /** ws:// URL of the running service (port may differ from the default 3000). */ + getServiceUrl(): string { + return `ws://localhost:${this.config.port}` + } + /** * Check service health via HTTP endpoint */ @@ -201,6 +297,9 @@ export class ServiceManager { } try { + // Bind to a free port — 3000 may be taken by another dev server. + this.config.port = await this.findFreePort(this.config.port) + const arduinoCliPath = this.resolveArduinoCliPath() // out/main → app root (two levels up); node_modules lives here in dev and // in the packaged app (asar disabled). diff --git a/src/main/index.ts b/src/main/index.ts index a73e27b..66fb407 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -104,6 +104,13 @@ app.whenReady().then(async () => { // IPC test ipcMain.handle('ping', () => 'pong') + // The backend may bind to a non-default port (3000 can be taken by another + // dev server); the renderer asks for the real URL instead of assuming. + ipcMain.handle('service:get-url', () => serviceManager.getServiceUrl()) + ipcMain.on('service:get-url-sync', (event) => { + event.returnValue = serviceManager.getServiceUrl() + }) + // --- Studio AI agent + settings --- ipcMain.handle('settings:status', () => getStatus()) ipcMain.handle('settings:set-key', (_, key: string) => setApiKey(key)) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 7cac8a6..79dc726 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -132,6 +132,13 @@ interface AppAPI { getExamplesDir: () => Promise } +interface ServiceAPI { + /** Real ws:// URL of the spawned tinyService backend (port may differ from 3000). */ + getUrl: () => Promise + /** Synchronous variant for construction-time use. */ + getUrlSync: () => string +} + export type AgentEvent = | { type: 'text_delta'; text: string } | { type: 'tool_use'; id: string; name: string; input: unknown } @@ -172,6 +179,7 @@ declare global { settings: SettingsAPI agent: AgentAPI app: AppAPI + service: ServiceAPI } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 9d13029..4f501e6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -55,6 +55,16 @@ const api = { getExamplesDir: (): Promise => ipcRenderer.invoke('app:get-examples-dir') }, + // Backend (tinyService) info. + service: { + // Real ws:// URL of the spawned backend — its port may differ from 3000. + getUrl: (): Promise => ipcRenderer.invoke('service:get-url'), + // Synchronous variant for construction-time use: the WebSocket service + // client is created synchronously at renderer startup, and the backend is + // already running by then (main starts it before creating the window). + getUrlSync: (): string => ipcRenderer.sendSync('service:get-url-sync') + }, + // App settings (Studio AI). The renderer never sees the API key value — // only whether one is configured. settings: { diff --git a/src/renderer/src/components/MonacoEditor.tsx b/src/renderer/src/components/MonacoEditor.tsx index 2b1c280..9067065 100644 --- a/src/renderer/src/components/MonacoEditor.tsx +++ b/src/renderer/src/components/MonacoEditor.tsx @@ -1,7 +1,10 @@ import { Editor, Monaco, OnMount } from '@monaco-editor/react' +import { useArduinoContext } from '@renderer/contexts/ArduinoContext' +import { diagnosticMatchesFile } from '@renderer/lib/compileErrors' +import { attachLspToEditor } from '@renderer/lib/lsp/monacoLsp' import { useTheme } from '@renderer/lib/ThemeProvider' import { EditorFile } from '@renderer/redux' -import { forwardRef, useImperativeHandle, useRef } from 'react' +import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react' interface MonacoEditorProps { activeFile: EditorFile @@ -18,6 +21,10 @@ export const MonacoEditor = forwardRef( const editorRef = useRef | null>(null) const monacoRef = useRef(null) const { theme } = useTheme() + const { lastCompileResult, selectedBoard, isAgentConnected } = useArduinoContext() + // Bumped when the editor mounts so effects that need editor/monaco refs + // re-run (refs alone don't trigger renders). + const [editorReady, setEditorReady] = useState(false) useImperativeHandle(ref, () => ({ focus: () => { @@ -27,6 +34,59 @@ export const MonacoEditor = forwardRef( } })) + // ── Inline compile diagnostics ──────────────────────────────────────── + // Render the last build's errors/warnings as squiggles in the gutter of + // the file they belong to (Arduino IDE parity), instead of leaving them + // only as text in the Output pane. Cleared on a successful build. + useEffect(() => { + const monaco = monacoRef.current + const editor = editorRef.current + const model = editor?.getModel() + if (!monaco || !model) return + + const filePath = activeFile.path || activeFile.id + const all = [ + ...(lastCompileResult?.errors ?? []).map((e) => ({ ...e, isError: true })), + ...(lastCompileResult?.warnings ?? []).map((w) => ({ ...w, isError: false })) + ] + const markers = all + .filter( + (d) => + d.file && + d.line && + diagnosticMatchesFile( + { file: d.file, line: d.line ?? 1, column: d.column ?? 1, severity: 'error', message: d.message }, + filePath + ) + ) + .map((d) => ({ + severity: d.isError ? monaco.MarkerSeverity.Error : monaco.MarkerSeverity.Warning, + message: d.message, + startLineNumber: d.line ?? 1, + startColumn: d.column ?? 1, + endLineNumber: d.line ?? 1, + endColumn: model.getLineMaxColumn(Math.min(d.line ?? 1, model.getLineCount())) + })) + monaco.editor.setModelMarkers(model, 'arduino-compile', markers) + }, [lastCompileResult, activeFile.path, activeFile.id, editorReady]) + + // ── Language server (code intelligence) ─────────────────────────────── + // Bridges Monaco to the Arduino Language Server (clangd) via tinyService's + // /lsp WebSocket. Desktop-only for now: the LS needs the sketch on disk, + // which mem:// (browser) sketches don't have. Degrades silently when the + // backend has no language-server binaries. + useEffect(() => { + const monaco = monacoRef.current + const editor = editorRef.current + const fqbn = selectedBoard?.config.fqbn + const filePath = activeFile.path + if (!monaco || !editor || !fqbn || !isAgentConnected) return + if (!filePath || filePath.startsWith('mem://')) return + const detach = attachLspToEditor(monaco, editor, filePath, fqbn) + return detach + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedBoard?.config.fqbn, isAgentConnected, activeFile.path, editorReady]) + function handleBeforeMount(monaco): void { // Design-system surfaces (cold charcoal dark / near-white light) with the // existing tinyForge syntax hues kept on dark; light uses deeper variants @@ -114,6 +174,7 @@ export const MonacoEditor = forwardRef( const handleEditorMounted: OnMount = (editor, monaco) => { editorRef.current = editor monacoRef.current = monaco + setEditorReady(true) // Add save command (Ctrl+S) editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { diff --git a/src/renderer/src/components/SerialMonitor.tsx b/src/renderer/src/components/SerialMonitor.tsx index 4133f01..e00a709 100644 --- a/src/renderer/src/components/SerialMonitor.tsx +++ b/src/renderer/src/components/SerialMonitor.tsx @@ -1,8 +1,8 @@ import { useArduinoContext } from '@renderer/contexts/ArduinoContext' -import { useSerial } from '@renderer/contexts/SerialContext' +import { useSerial, type SerialEol } from '@renderer/contexts/SerialContext' import { useAppDispatch } from '@renderer/redux' import { setPanelOpen } from '@renderer/redux/editorSlice' -import { ArrowDownToLine, FileText, ListX, Send, Terminal, X } from 'lucide-react' +import { ArrowDownToLine, Clock, FileText, ListX, Send, Terminal, X } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { Button } from './ui/Button' import { IconButton } from './ui/IconButton' @@ -21,7 +21,8 @@ export function SerialMonitor(): React.JSX.Element { const [activeTab, setActiveTab] = useState<'serial' | 'output'>('serial') const dispatch = useAppDispatch() const { clear } = useSerial() - const { clearLogs, isCompiling, isUploading } = useArduinoContext() + const { clearLogs, isCompiling, isUploading, lastCompileResult, lastUploadResult } = + useArduinoContext() // Auto-scroll (stick to bottom) per pane — toggled by the button in the header. const [serialAuto, setSerialAuto] = useState(true) @@ -30,20 +31,36 @@ export function SerialMonitor(): React.JSX.Element { const toggleAuto = (): void => activeTab === 'serial' ? setSerialAuto((v) => !v) : setOutputAuto((v) => !v) - // Verify/Upload jump to the Output log so the build is visible, then snap - // back to the Serial Monitor once it finishes. The short delay rides over the - // brief gap between the compile and upload phases of an Upload so we don't - // flash back to Serial mid-operation. + // Verify/Upload jump to the Output log so the build is visible. When the + // operation finishes we snap back to the Serial Monitor ONLY if it + // succeeded — a failed build keeps the Output pane (and its compiler + // errors) in front instead of yanking it away after 400 ms. If the user + // picked a tab themselves during the run, we respect that and don't + // auto-switch at all. const busy = isCompiling || isUploading + const userPinnedTab = useRef(false) useEffect(() => { if (busy) { + userPinnedTab.current = false setActiveTab('output') return } + if (userPinnedTab.current) return + const failed = + lastCompileResult?.success === false || lastUploadResult?.success === false + if (failed) return // leave the errors visible + // Short delay rides over the brief gap between the compile and upload + // phases of an Upload so we don't flash back to Serial mid-operation. const t = setTimeout(() => setActiveTab('serial'), 400) return () => clearTimeout(t) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [busy]) + const selectTab = (id: 'serial' | 'output'): void => { + if (busy) userPinnedTab.current = true + setActiveTab(id) + } + // Clear whichever pane is in front — the serial stream or the output log. const handleClear = (): void => { if (activeTab === 'serial') clear() @@ -53,7 +70,7 @@ export function SerialMonitor(): React.JSX.Element { const tab = (id: 'serial' | 'output', label: string, Icon: typeof Terminal): React.JSX.Element => ( + + + {/* Board picker (for wrong guesses / unknown clones) */} + {showPicker && ( +
+ setQuery(e.target.value)} + /> +
+ {allBoards === null ? ( +
+ Loading boards… +
+ ) : filteredBoards.length === 0 ? ( +
+ No matches. Install the board's platform in the Boards Manager first. +
+ ) : ( + filteredBoards.map((b) => ( + + )) + )} +
+
+ )} + + {/* FQBN config options (Tools-menu equivalents) */} + {!showPicker && ( +
+ {loadingDetails ? ( +
+ Loading board options… +
+ ) : detailsError ? ( +
{detailsError}
+ ) : !details || details.configOptions.length === 0 ? ( +
+ This board exposes no configurable options. +
+ ) : ( + details.configOptions.map((opt) => ( +
+ + {opt.optionLabel} + + +
+ )) + )} +
+ )} + + )} + + + ) +} diff --git a/src/renderer/src/contexts/SerialContext.tsx b/src/renderer/src/contexts/SerialContext.tsx index fdb461b..84e93b5 100644 --- a/src/renderer/src/contexts/SerialContext.tsx +++ b/src/renderer/src/contexts/SerialContext.tsx @@ -10,20 +10,74 @@ * * It accumulates the line buffer (for the Serial Monitor) and feeds the shared * serial bus (window.__tinySerial / `tinyserial`) for the Visual sketch. + * + * Monitor settings (baud + line ending) are persisted per port, so a board + * you always run at 115200 comes back at 115200 (Arduino IDE parity). */ import { useArduinoContext } from '@renderer/contexts/ArduinoContext' import { pushSerialLine } from '@renderer/lib/serialBus' -import React, { createContext, useContext, useEffect, useState } from 'react' +import React, { createContext, useContext, useEffect, useRef, useState } from 'react' + +/** One rendered line of the Serial Monitor. */ +export interface SerialLine { + text: string + /** Receive (or send) time, ms since epoch — rendered by the timestamps toggle. */ + ts: number + /** True for lines the user sent (rendered in the accent color). */ + tx?: boolean +} + +/** Line ending appended to sent data (Arduino IDE parity). */ +export type SerialEol = 'none' | 'nl' | 'cr' | 'crlf' + +const EOL_CHARS: Record = { + none: '', + nl: '\n', + cr: '\r', + crlf: '\r\n' +} + +interface PortSettings { + baud?: string + eol?: SerialEol +} + +const settingsKey = (port: string): string => `tinystudio.monitor.${port}` + +function loadPortSettings(port: string | undefined): PortSettings { + if (!port) return {} + try { + const raw = localStorage.getItem(settingsKey(port)) + return raw ? (JSON.parse(raw) as PortSettings) : {} + } catch { + return {} + } +} + +function savePortSettings(port: string | undefined, settings: PortSettings): void { + if (!port) return + try { + const merged = { ...loadPortSettings(port), ...settings } + localStorage.setItem(settingsKey(port), JSON.stringify(merged)) + } catch { + /* storage may be unavailable */ + } +} interface SerialContextValue { - lines: string[] + lines: SerialLine[] connected: boolean /** User chose to release the port (e.g. so the browser can use it) */ disconnected: boolean + /** Last port-open failure reported by the backend (busy port etc.), if any */ + lastError: string | null port?: string baud: string setBaud: (b: string) => void + /** Line ending appended to sent data */ + eol: SerialEol + setEol: (e: SerialEol) => void send: (data: string) => void clear: () => void /** Release the serial port and stay disconnected until reconnect() */ @@ -46,24 +100,55 @@ export function SerialProvider({ children }: { children: React.ReactNode }): Rea onSerialStatus } = useArduinoContext() - const [lines, setLines] = useState([]) + const [lines, setLines] = useState([]) const [connected, setConnected] = useState(false) // Manual override: when true, we release the port and don't auto-reopen, so // another app (e.g. the exported page over Web Serial) can use the board. const [disconnected, setDisconnected] = useState(false) - const [baud, setBaud] = useState('9600') + const [lastError, setLastError] = useState(null) + const [baud, setBaudState] = useState('9600') + const [eol, setEolState] = useState('nl') const port = selectedBoard?.port const baudNum = parseInt(baud, 10) + // Restore per-port monitor settings whenever the port changes. + const restoredPortRef = useRef(undefined) + useEffect(() => { + if (port === restoredPortRef.current) return + restoredPortRef.current = port + const saved = loadPortSettings(port) + if (saved.baud) setBaudState(saved.baud) + if (saved.eol) setEolState(saved.eol) + }, [port]) + + const setBaud = (b: string): void => { + setBaudState(b) + savePortSettings(port, { baud: b }) + } + const setEol = (e: SerialEol): void => { + setEolState(e) + savePortSettings(port, { eol: e }) + } + // Stream subscription lives for the whole app session. useEffect(() => { const offData = onSerialData((line) => { - setLines((prev) => [...prev.slice(-1000), line]) + setLines((prev) => [...prev.slice(-1000), { text: line, ts: Date.now() }]) pushSerialLine(line) // feed the Visual sketch }) const offStatus = onSerialStatus((s) => { - if (s.opened) setConnected(true) + if (s.opened) { + setConnected(true) + setLastError(null) + } if (s.closed) setConnected(false) + if (s.error) { + // The backend now surfaces WHY a port didn't open (busy, missing…) + // instead of silently flashing connected → disconnected. + setConnected(false) + setLastError(s.error) + setLines((prev) => [...prev.slice(-1000), { text: `⚠ ${s.error}`, ts: Date.now() }]) + } }) return () => { offData() @@ -94,12 +179,17 @@ export function SerialProvider({ children }: { children: React.ReactNode }): Rea lines, connected, disconnected, + lastError, port, baud, setBaud, + eol, + setEol, send: (data: string) => { - writeSerial(data) - setLines((prev) => [...prev.slice(-1000), `→ ${data}`]) + // Apply the chosen line ending ourselves and write raw — the backend + // appends nothing (Arduino IDE's None / NL / CR / Both behavior). + writeSerial(data + EOL_CHARS[eol], true) + setLines((prev) => [...prev.slice(-1000), { text: `→ ${data}`, ts: Date.now(), tx: true }]) }, clear: () => setLines([]), disconnect: () => setDisconnected(true), diff --git a/src/renderer/src/hooks/useArduino.ts b/src/renderer/src/hooks/useArduino.ts index 0a5ce04..d3822f8 100644 --- a/src/renderer/src/hooks/useArduino.ts +++ b/src/renderer/src/hooks/useArduino.ts @@ -2,18 +2,20 @@ * useArduino - Main hook for Arduino operations (compile, upload, board management) */ +import { parseCompileDiagnostics } from '@renderer/lib/compileErrors' import { getArduinoService } from '@renderer/services/arduino/ArduinoServiceFactory' import { ArduinoActionResult, Board, BoardConfig, + BoardDetails, CompileResult, InstallableBoard, LibraryEntry, PlatformEntry, UploadResult } from '@renderer/services/arduino/types' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { useArduinoAgent } from './useArduinoAgent' @@ -100,13 +102,17 @@ export interface UseArduinoReturn { listBoardUrls: () => Promise addBoardUrl: (url: string) => Promise removeBoardUrl: (url: string) => Promise + /** FQBN config options + programmers for a board (Tools-menu equivalents) */ + boardDetails: (fqbn: string) => Promise // Serial monitor openSerial: (port: string, baud: number) => void closeSerial: () => void - writeSerial: (data: string) => void + writeSerial: (data: string, raw?: boolean) => void onSerialData: (cb: (line: string) => void) => () => void - onSerialStatus: (cb: (status: { opened?: boolean; closed?: boolean }) => void) => () => void + onSerialStatus: ( + cb: (status: { opened?: boolean; closed?: boolean; error?: string }) => void + ) => () => void } /** @@ -126,6 +132,13 @@ export function useArduino(): UseArduinoReturn { const [uploadProgress, setUploadProgress] = useState(null) const [lastUploadResult, setLastUploadResult] = useState(null) + // Refs mirroring the last results so long-lived callbacks (the timeout + // recovery paths) read the CURRENT value instead of a stale closure. + const lastCompileResultRef = useRef(null) + lastCompileResultRef.current = lastCompileResult + const lastUploadResultRef = useRef(null) + lastUploadResultRef.current = lastUploadResult + const [logs, setLogs] = useState([]) // Services @@ -264,6 +277,29 @@ export function useArduino(): UseArduinoReturn { const result = await arduinoService.compileSketch(workspacePath, targetBoard) const buildDuration = Date.now() - buildStartTime + + // Extract file:line:col diagnostics from the compiler output so the + // editor can render them inline (squiggles) and make them clickable. + if (!result.success) { + const diagText = [result.output, ...(result.errors?.map((e) => e.message) ?? [])].join( + '\n' + ) + const parsed = parseCompileDiagnostics(diagText) + if (parsed.length > 0) { + result.errors = parsed + .filter((d) => d.severity === 'error') + .map((d) => ({ + message: d.message, + file: d.file, + line: d.line, + column: d.column, + severity: 'error' as const + })) + result.warnings = parsed + .filter((d) => d.severity === 'warning') + .map((d) => ({ message: d.message, file: d.file, line: d.line, column: d.column })) + } + } setLastCompileResult(result) if (result.success) { @@ -332,14 +368,16 @@ export function useArduino(): UseArduinoReturn { } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' - // Check if this was a timeout error after a successful operation + // Check if this was a timeout error after a successful operation. + // (Read through the ref — the state value in this closure is stale.) + const prevCompile = lastCompileResultRef.current const isTimeout = errorMessage.includes('timed out') - const isAfterSuccess = isTimeout && (lastCompileResult?.success ?? false) + const isAfterSuccess = isTimeout && (prevCompile?.success ?? false) const result: CompileResult = { success: isAfterSuccess, // If last result was successful and this is just a timeout, preserve success output: isAfterSuccess - ? (lastCompileResult?.output ?? '') + ? (prevCompile?.output ?? '') : `Compilation error: ${errorMessage}`, errors: isAfterSuccess ? [] : [{ message: errorMessage, severity: 'fatal' }] } @@ -350,7 +388,7 @@ export function useArduino(): UseArduinoReturn { addLog({ type: 'compile', message: 'Compilation completed (with timeout warning)', - details: `Build finished successfully but communication timed out.\nOriginal output:\n${lastCompileResult?.output ?? ''}` + details: `Build finished successfully but communication timed out.\nOriginal output:\n${prevCompile?.output ?? ''}` }) toast.warning('Compilation completed with timeout', { description: 'Build succeeded but communication was slow' @@ -407,6 +445,7 @@ export function useArduino(): UseArduinoReturn { details: `Target: ${targetBoard.name}\nPort: ${targetPort}\nFQBN: ${targetBoard.fqbn}\nWorkspace: ${workspacePath || 'Using compiled binary'}` }) + let offProgress: (() => void) | null = null try { // Add upload progress step logging addLog({ @@ -414,22 +453,32 @@ export function useArduino(): UseArduinoReturn { message: 'Executing upload command via Arduino CLI...' }) - // Simulate progress updates (real implementation would get these from service) - const progressTimer = setInterval(() => { - setUploadProgress((prev) => { - if (!prev || prev.percentage >= 100) return prev - return { - ...prev, - percentage: Math.min(prev.percentage + 10, 90), - stage: - prev.percentage < 30 - ? 'preparing' - : prev.percentage < 80 - ? 'uploading' - : 'verifying' + // Real progress: parse the flasher's streamed output. esptool (ESP32 + // family, incl. tinyCore) prints "Writing at 0x... (NN %)"; avrdude + // prints "Writing | ### ... | NN%". Falls back to indeterminate + // stages when the tool prints no percentages. + offProgress = arduinoService.onActionOutput('upload', (chunk) => { + const matches = chunk.match(/\((\d{1,3})\s*%\)|\s(\d{1,3})%/g) + if (matches && matches.length > 0) { + const last = matches[matches.length - 1] + const num = parseInt(last.replace(/[^0-9]/g, ''), 10) + if (!Number.isNaN(num)) { + setUploadProgress({ + percentage: Math.min(num, 99), + stage: num >= 99 ? 'verifying' : 'uploading' + }) + return } - }) - }, 200) + } + if (/Connecting|Chip is|Uploading stub|esptool/i.test(chunk)) { + setUploadProgress((prev) => prev ?? { percentage: 0, stage: 'preparing' }) + } + if (/Hash of data verified|verif/i.test(chunk)) { + setUploadProgress((prev) => + prev ? { ...prev, stage: 'verifying' } : { percentage: 99, stage: 'verifying' } + ) + } + }) // Release the serial port before flashing — esptool needs exclusive // access to the COM port, and the monitor may still be holding it, so @@ -443,7 +492,6 @@ export function useArduino(): UseArduinoReturn { await new Promise((r) => setTimeout(r, 500)) const result = await arduinoService.uploadSketch(targetPort, targetBoard, workspacePath) - clearInterval(progressTimer) setLastUploadResult(result) setUploadProgress({ percentage: 100, stage: 'complete' }) @@ -496,13 +544,15 @@ export function useArduino(): UseArduinoReturn { } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' - // Check if this was a timeout error after a successful operation + // Check if this was a timeout error after a successful operation. + // (Read through the ref — the state value in this closure is stale.) + const prevUpload = lastUploadResultRef.current const isTimeout = errorMessage.includes('timed out') - const isAfterSuccess = isTimeout && (lastUploadResult?.success ?? false) + const isAfterSuccess = isTimeout && (prevUpload?.success ?? false) const result: UploadResult = { success: isAfterSuccess, // If last result was successful and this is just a timeout, preserve success - output: isAfterSuccess ? (lastUploadResult?.output ?? '') : '', + output: isAfterSuccess ? (prevUpload?.output ?? '') : '', error: isAfterSuccess ? undefined : errorMessage } @@ -513,7 +563,7 @@ export function useArduino(): UseArduinoReturn { addLog({ type: 'upload', message: 'Upload completed (with timeout warning)', - details: `Upload finished successfully but communication timed out.\nOriginal output:\n${lastUploadResult?.output ?? ''}` + details: `Upload finished successfully but communication timed out.\nOriginal output:\n${prevUpload?.output ?? ''}` }) toast.warning('Upload completed with timeout', { description: 'Upload succeeded but communication was slow' @@ -529,6 +579,7 @@ export function useArduino(): UseArduinoReturn { return result } finally { + offProgress?.() setIsUploading(false) } }, @@ -577,19 +628,40 @@ export function useArduino(): UseArduinoReturn { }, [isAgentConnected, hasLoadedBoards]) /** - * Auto-recognize boards, but only while NONE is connected — poll every 8s to - * catch a board being plugged in, then STOP once one is found (no point - * hammering arduino-cli when a board is already there). Plugging/unplugging - * after that is picked up by the manual Refresh or the next operation. + * Event-driven board detection: the backend watches + * `arduino-cli board list --watch` and pushes the full board list on every + * plug/unplug. This replaces the old 8-second poll (which stopped polling + * once a board was found, so unplugs went unnoticed until manual refresh). + * The selection is reconciled on every event: kept if the same port is + * still present, cleared (with a heads-up) if its board was unplugged. */ useEffect(() => { - if (!isAgentConnected || boards.length > 0) return - const id = setInterval(() => { - refreshBoards() - }, 8000) - return () => clearInterval(id) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isAgentConnected, boards.length]) + const off = arduinoService.onBoardEvents((boardsList) => { + setBoards(boardsList) + setHasLoadedBoards(true) + setSelectedBoard((currentBoard) => { + if (!currentBoard) { + return boardsList[0] || null + } + // Same port still present: keep the user's choice (incl. any manual + // board override / FQBN options), but follow arduino-cli if the + // board on that port changed identity. + const samePort = boardsList.find((b) => b.port === currentBoard.port) + if (samePort) return currentBoard + if (boardsList.length === 0) { + toast.warning('Board disconnected', { + description: `${currentBoard.config.name} on ${currentBoard.port} was unplugged.` + }) + return null + } + toast.info('Board changed', { + description: `${currentBoard.port} is gone — switched to ${boardsList[0].config.name} (${boardsList[0].port}).` + }) + return boardsList[0] + }) + }) + return off + }, [arduinoService]) /** * Reset board loading state when agent disconnects @@ -632,6 +704,10 @@ export function useArduino(): UseArduinoReturn { [arduinoService] ) const listAllBoards = useCallback(() => arduinoService.listAllBoards(), [arduinoService]) + const boardDetails = useCallback( + (fqbn: string) => arduinoService.boardDetails(fqbn), + [arduinoService] + ) const listBoardUrls = useCallback(() => arduinoService.listBoardUrls(), [arduinoService]) const addBoardUrl = useCallback( (url: string) => arduinoService.addBoardUrl(url), @@ -648,13 +724,16 @@ export function useArduino(): UseArduinoReturn { [arduinoService] ) const closeSerial = useCallback(() => arduinoService.closeSerial(), [arduinoService]) - const writeSerial = useCallback((data: string) => arduinoService.writeSerial(data), [arduinoService]) + const writeSerial = useCallback( + (data: string, raw?: boolean) => arduinoService.writeSerial(data, raw), + [arduinoService] + ) const onSerialData = useCallback( (cb: (line: string) => void) => arduinoService.onSerialData(cb), [arduinoService] ) const onSerialStatus = useCallback( - (cb: (status: { opened?: boolean; closed?: boolean }) => void) => + (cb: (status: { opened?: boolean; closed?: boolean; error?: string }) => void) => arduinoService.onSerialStatus(cb), [arduinoService] ) @@ -703,6 +782,7 @@ export function useArduino(): UseArduinoReturn { listBoardUrls, addBoardUrl, removeBoardUrl, + boardDetails, // Serial monitor openSerial, diff --git a/src/renderer/src/lib/compileErrors.ts b/src/renderer/src/lib/compileErrors.ts new file mode 100644 index 0000000..0ce5a29 --- /dev/null +++ b/src/renderer/src/lib/compileErrors.ts @@ -0,0 +1,65 @@ +/** + * Parse gcc/clang-style diagnostics out of arduino-cli compile output so they + * can be shown inline in the editor (Monaco markers) and made clickable — + * instead of living only as text in the Output pane. + * + * Typical shapes: + * C:\proj\Blink\Blink.ino:12:3: error: 'digitalWrit' was not declared in this scope + * /home/geo/sketch/sketch.ino:4:1: warning: unused variable 'x' [-Wunused] + * In file included from C:\proj\lib\Foo.h:1:0, + */ +export interface ParsedDiagnostic { + /** Absolute or sketch-relative path as printed by the compiler */ + file: string + /** 1-based line */ + line: number + /** 1-based column (defaults to 1 when the compiler omits it) */ + column: number + severity: 'error' | 'warning' | 'note' + message: string +} + +// path:line:col: severity: message (col optional; windows drive letters ok) +const DIAG_RE = + /^(?(?:[A-Za-z]:)?[^:\n]+?\.(?:ino|h|hpp|c|cpp|cc|S))(?::(?\d+))(?::(?\d+))?:\s*(?fatal error|error|warning|note):\s*(?.+)$/ + +/** + * Extract all diagnostics from a compile output blob. Multi-line messages + * (the indented `expected ';' before ...` continuation lines, caret markers, + * code excerpts) are folded into the preceding diagnostic's message. + */ +export function parseCompileDiagnostics(output: string): ParsedDiagnostic[] { + const diagnostics: ParsedDiagnostic[] = [] + for (const rawLine of output.split(/\r?\n/)) { + const m = DIAG_RE.exec(rawLine.trim()) + if (!m || !m.groups) continue + const sev = m.groups.sev === 'fatal error' ? 'error' : m.groups.sev + diagnostics.push({ + file: m.groups.file.trim(), + line: parseInt(m.groups.line, 10) || 1, + column: parseInt(m.groups.col || '1', 10) || 1, + severity: sev as ParsedDiagnostic['severity'], + message: m.groups.msg.trim() + }) + } + return diagnostics +} + +/** + * Does a diagnostic's reported path refer to the given editor file? The + * compiler may print absolute paths (desktop), temp-dir paths (web build + * materializes sketches to a temp folder), or bare names, so match on + * normalized path suffixes. + */ +export function diagnosticMatchesFile(diag: ParsedDiagnostic, editorPath: string): boolean { + const norm = (p: string): string => p.replace(/\\/g, '/').toLowerCase() + const d = norm(diag.file) + const e = norm(editorPath) + if (d === e) return true + // Fall back to a file-name match: the compiler may print a temp-dir copy's + // path (web build) while the editor shows the original. Sketch folders + // rarely contain two same-named sources, so the name is a safe key. + const dName = d.slice(d.lastIndexOf('/') + 1) + const eName = e.slice(e.lastIndexOf('/') + 1) + return dName === eName +} diff --git a/src/renderer/src/lib/lsp/monacoLsp.ts b/src/renderer/src/lib/lsp/monacoLsp.ts new file mode 100644 index 0000000..9ea9c86 --- /dev/null +++ b/src/renderer/src/lib/lsp/monacoLsp.ts @@ -0,0 +1,457 @@ +/** + * Minimal Arduino Language Server integration for Monaco. + * + * tinyService exposes the Arduino Language Server (clangd under the hood) + * over a WebSocket bridge at /lsp — plain JSON-RPC, one payload per WS + * message (the service handles stdio Content-Length framing). This module is + * a deliberately small, dependency-free LSP client that wires the parts that + * matter most into Monaco: + * + * - live diagnostics (textDocument/publishDiagnostics → editor markers) + * - code completion (textDocument/completion) + * - hover docs (textDocument/hover) + * - signature help (textDocument/signatureHelp) + * + * Why not monaco-languageclient? It pins specific monaco-editor versions and + * pulls the vscode API shim; this hand-rolled client keeps the dependency + * surface at zero and works in both the desktop and browser builds (though + * the LS itself needs the sketch on disk, so it's desktop-only in practice). + * + * Everything degrades silently: if the backend closes the socket with + * "lsp-unavailable" (binaries not installed) we remember that and stop + * trying for the session. + */ + +import type { Monaco } from '@monaco-editor/react' +import { getArduinoService } from '@renderer/services/arduino/ArduinoServiceFactory' + +type MonacoEditorType = ReturnType + +// ── tiny JSON-RPC over WebSocket ─────────────────────────────────────────── + +interface PendingRequest { + resolve: (result: unknown) => void + reject: (error: Error) => void +} + +class LspConnection { + private ws: WebSocket | null = null + private nextId = 1 + private pending = new Map() + private notificationHandlers = new Map void>() + private initialized: Promise | null = null + /** Documents currently open on the server: uri → version. */ + readonly openDocs = new Map() + disposed = false + + constructor( + readonly url: string, + private readonly rootUri: string + ) {} + + /** Connect + run the LSP initialize handshake. Resolves false on failure. */ + ensureInitialized(): Promise { + if (!this.initialized) { + this.initialized = this.doInitialize().catch(() => false) + } + return this.initialized + } + + private doInitialize(): Promise { + return new Promise((resolve) => { + let settled = false + const fail = (): void => { + if (!settled) { + settled = true + resolve(false) + } + } + let ws: WebSocket + try { + ws = new WebSocket(this.url) + } catch { + fail() + return + } + this.ws = ws + ws.onmessage = (ev) => this.onMessage(ev) + ws.onclose = (ev) => { + if (ev.reason === 'lsp-unavailable') markLspUnavailable() + this.teardown() + fail() + } + ws.onerror = () => { + this.teardown() + fail() + } + ws.onopen = async () => { + try { + await this.request('initialize', { + processId: null, + rootUri: this.rootUri, + capabilities: { + textDocument: { + synchronization: { didSave: true }, + publishDiagnostics: { relatedInformation: false }, + completion: { + completionItem: { + snippetSupport: false, + documentationFormat: ['markdown', 'plaintext'] + } + }, + hover: { contentFormat: ['markdown', 'plaintext'] }, + signatureHelp: { + signatureInformation: { documentationFormat: ['markdown', 'plaintext'] } + } + }, + workspace: { configuration: false, workspaceFolders: false } + }, + initializationOptions: {}, + workspaceFolders: null + }) + this.notify('initialized', {}) + if (!settled) { + settled = true + resolve(true) + } + } catch { + fail() + } + } + }) + } + + request(method: string, params: unknown, timeoutMs = 15000): Promise { + return new Promise((resolve, reject) => { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + reject(new Error('LSP socket not open')) + return + } + const id = this.nextId++ + const timer = setTimeout(() => { + this.pending.delete(id) + reject(new Error(`LSP request ${method} timed out`)) + }, timeoutMs) + this.pending.set(id, { + resolve: (r) => { + clearTimeout(timer) + resolve(r) + }, + reject: (e) => { + clearTimeout(timer) + reject(e) + } + }) + this.ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })) + }) + } + + notify(method: string, params: unknown): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return + this.ws.send(JSON.stringify({ jsonrpc: '2.0', method, params })) + } + + onNotification(method: string, handler: (params: any) => void): void { + this.notificationHandlers.set(method, handler) + } + + private onMessage(ev: MessageEvent): void { + let msg: any + try { + msg = JSON.parse(typeof ev.data === 'string' ? ev.data : '') + } catch { + return + } + if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) { + const pending = this.pending.get(msg.id) + if (pending) { + this.pending.delete(msg.id) + if (msg.error) pending.reject(new Error(msg.error.message || 'LSP error')) + else pending.resolve(msg.result) + } + return + } + if (msg.method) { + // Server → client REQUESTS need a reply or clangd stalls; answer the + // common ones with empty results. + if (msg.id !== undefined) { + this.ws?.send(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: null })) + return + } + const handler = this.notificationHandlers.get(msg.method) + if (handler) handler(msg.params) + } + } + + private teardown(): void { + for (const [, p] of this.pending) p.reject(new Error('LSP connection closed')) + this.pending.clear() + this.openDocs.clear() + this.ws = null + this.initialized = null + } + + dispose(): void { + this.disposed = true + try { + this.notify('shutdown', {}) + this.ws?.close() + } catch { + /* already closed */ + } + this.teardown() + } +} + +// ── module state ─────────────────────────────────────────────────────────── + +/** Set once the backend reports it has no language-server binaries. */ +let lspUnavailable = false +function markLspUnavailable(): void { + if (!lspUnavailable) { + lspUnavailable = true + console.info('[lsp] Arduino Language Server not available — code intelligence disabled') + } +} + +/** One connection per LSP URL (i.e. per FQBN); reused across editor tabs. */ +const connections = new Map() +/** Providers are registered once, globally, and resolve the connection by model URI. */ +let providersRegistered = false +/** uri → connection that has the doc open (used by the global providers). */ +const docConnections = new Map() + +function pathToUri(filePath: string): string { + let p = filePath.replace(/\\/g, '/') + if (!p.startsWith('/')) p = '/' + p // windows drive paths: /C:/… + return 'file://' + p.split('/').map(encodeURIComponent).join('/').replace(/%3A/gi, ':') +} + +function dirOf(filePath: string): string { + const norm = filePath.replace(/\\/g, '/') + return norm.slice(0, norm.lastIndexOf('/')) +} + +function toMarkerSeverity(monaco: Monaco, lspSeverity?: number): number { + switch (lspSeverity) { + case 1: + return monaco.MarkerSeverity.Error + case 2: + return monaco.MarkerSeverity.Warning + case 3: + return monaco.MarkerSeverity.Info + default: + return monaco.MarkerSeverity.Hint + } +} + +function hoverContentsToString(contents: any): string { + if (!contents) return '' + if (typeof contents === 'string') return contents + if (Array.isArray(contents)) return contents.map(hoverContentsToString).join('\n\n') + if (typeof contents.value === 'string') return contents.value + return '' +} + +function registerProviders(monaco: Monaco): void { + if (providersRegistered) return + providersRegistered = true + + const connFor = (model: { uri: { toString(): string } }): LspConnection | undefined => + docConnections.get(model.uri.toString()) + + const toLspPosition = (position: { lineNumber: number; column: number }): unknown => ({ + line: position.lineNumber - 1, + character: position.column - 1 + }) + + monaco.languages.registerCompletionItemProvider('arduino', { + triggerCharacters: ['.', '>', ':', '_'], + provideCompletionItems: async (model, position) => { + const conn = connFor(model) + if (!conn) return { suggestions: [] } + try { + const result: any = await conn.request('textDocument/completion', { + textDocument: { uri: model.uri.toString() }, + position: toLspPosition(position) + }) + const items: any[] = Array.isArray(result) ? result : (result?.items ?? []) + const word = model.getWordUntilPosition(position) + const range = { + startLineNumber: position.lineNumber, + startColumn: word.startColumn, + endLineNumber: position.lineNumber, + endColumn: word.endColumn + } + return { + suggestions: items.slice(0, 200).map((item) => ({ + label: item.label, + // LSP CompletionItemKind happens to align closely with Monaco's — + // clamp to a safe fallback (Text) when out of range. + kind: + item.kind && item.kind >= 1 && item.kind <= 25 + ? item.kind + : monaco.languages.CompletionItemKind.Text, + insertText: item.insertText || item.textEdit?.newText || item.label, + detail: item.detail, + documentation: item.documentation?.value ?? item.documentation, + sortText: item.sortText, + filterText: item.filterText, + range + })) + } + } catch { + return { suggestions: [] } + } + } + }) + + monaco.languages.registerHoverProvider('arduino', { + provideHover: async (model, position) => { + const conn = connFor(model) + if (!conn) return null + try { + const result: any = await conn.request('textDocument/hover', { + textDocument: { uri: model.uri.toString() }, + position: toLspPosition(position) + }) + const text = hoverContentsToString(result?.contents) + if (!text) return null + return { contents: [{ value: text }] } + } catch { + return null + } + } + }) + + monaco.languages.registerSignatureHelpProvider('arduino', { + signatureHelpTriggerCharacters: ['(', ','], + provideSignatureHelp: async (model, position) => { + const conn = connFor(model) + if (!conn) return null + try { + const result: any = await conn.request('textDocument/signatureHelp', { + textDocument: { uri: model.uri.toString() }, + position: toLspPosition(position) + }) + if (!result?.signatures?.length) return null + return { + value: { + signatures: result.signatures.map((s: any) => ({ + label: s.label, + documentation: s.documentation?.value ?? s.documentation, + parameters: (s.parameters ?? []).map((p: any) => ({ + label: p.label, + documentation: p.documentation?.value ?? p.documentation + })) + })), + activeSignature: result.activeSignature ?? 0, + activeParameter: result.activeParameter ?? 0 + }, + dispose: () => {} + } + } catch { + return null + } + } + }) +} + +/** + * Attach the language server to a mounted editor for one file. Returns a + * detach function. Connections are cached per FQBN and shared across tabs; + * documents are opened/closed per attach. + */ +export function attachLspToEditor( + monaco: Monaco, + editor: MonacoEditorType, + filePath: string, + fqbn: string +): (() => void) | undefined { + if (lspUnavailable) return undefined + const service = getArduinoService() + const url = service.getLspUrl?.(fqbn) + if (!url) return undefined + + registerProviders(monaco) + + let conn = connections.get(url) + if (!conn || conn.disposed) { + // A different FQBN means a different LS instance; drop the old ones (the + // server compiles for exactly one board at a time). + for (const [key, old] of connections) { + if (key !== url) { + old.dispose() + connections.delete(key) + } + } + conn = new LspConnection(url, pathToUri(dirOf(filePath))) + connections.set(url, conn) + } + const connection = conn + + const model = editor.getModel() + if (!model) return undefined + const uri = model.uri.toString() + const languageId = /\.ino$/i.test(filePath) ? 'ino' : 'cpp' + + let disposed = false + let changeDebounce: ReturnType | null = null + let contentListener: { dispose(): void } | null = null + + void connection.ensureInitialized().then((ok) => { + if (!ok || disposed) return + + connection.onNotification('textDocument/publishDiagnostics', (params: any) => { + try { + const targetUri: string = params?.uri ?? '' + const markers = (params?.diagnostics ?? []).map((d: any) => ({ + severity: toMarkerSeverity(monaco, d.severity), + message: d.message, + startLineNumber: (d.range?.start?.line ?? 0) + 1, + startColumn: (d.range?.start?.character ?? 0) + 1, + endLineNumber: (d.range?.end?.line ?? 0) + 1, + endColumn: (d.range?.end?.character ?? 0) + 1 + })) + for (const m of monaco.editor.getModels()) { + if (m.uri.toString() === targetUri) { + monaco.editor.setModelMarkers(m, 'arduino-ls', markers) + } + } + } catch { + /* diagnostics are best-effort */ + } + }) + + // didOpen (or re-sync if another tab already opened it) + if (!connection.openDocs.has(uri)) { + connection.openDocs.set(uri, 1) + docConnections.set(uri, connection) + connection.notify('textDocument/didOpen', { + textDocument: { uri, languageId, version: 1, text: model.getValue() } + }) + } + + // didChange — full-document sync, debounced. + contentListener = model.onDidChangeContent(() => { + if (changeDebounce) clearTimeout(changeDebounce) + changeDebounce = setTimeout(() => { + const version = (connection.openDocs.get(uri) ?? 1) + 1 + connection.openDocs.set(uri, version) + connection.notify('textDocument/didChange', { + textDocument: { uri, version }, + contentChanges: [{ text: model.getValue() }] + }) + }, 250) + }) + }) + + return () => { + disposed = true + if (changeDebounce) clearTimeout(changeDebounce) + contentListener?.dispose() + // Keep the doc open on the server across tab switches (models persist in + // Monaco too); just stop listening. Docs close implicitly when the + // connection for a new FQBN replaces this one. + } +} diff --git a/src/renderer/src/services/arduino/WebSocketArduinoService.ts b/src/renderer/src/services/arduino/WebSocketArduinoService.ts index 2e455d0..1d990b4 100644 --- a/src/renderer/src/services/arduino/WebSocketArduinoService.ts +++ b/src/renderer/src/services/arduino/WebSocketArduinoService.ts @@ -25,6 +25,7 @@ import { ArduinoService, Board, BoardConfig, + BoardDetails, BoardInfo, CompileResult, InstallableBoard, @@ -63,7 +64,11 @@ async function collectVirtualSketch( return { files, sketchName: root.slice(root.lastIndexOf('/') + 1) } } -/** Resolve the tinyService URL, allowing a localStorage override for hosting. */ +/** + * Resolve the tinyService URL. Order: explicit localStorage override (web + * hosting), the Electron main process's answer (the backend may have bound a + * non-default port when 3000 was taken), then the default. + */ function resolveServiceUrl(): string { try { if (typeof localStorage !== 'undefined') { @@ -73,15 +78,31 @@ function resolveServiceUrl(): string { } catch { /* localStorage may be unavailable (private mode); fall back to default */ } + try { + const desktopUrl = window.api?.service?.getUrlSync?.() + if (desktopUrl) return desktopUrl + } catch { + /* not the Electron build (or preload unavailable) */ + } return DEFAULT_SERVICE_URL } -// Helper function to normalize tinyCore FQBN -function normalizeTinyCoreFqbn(fqbn: string): string { - if (fqbn.startsWith('tinyCore:')) { - return 'tinyCore:esp32:tiny_core_esp32s3_nopsram' +/** + * Convert a backend BoardInfo into the renderer's Board shape. The FQBN is + * passed through untouched: collapsing every tinyCore variant to one FQBN + * (the old behavior) forced all tinyCore boards to compile for the S3 + * no-PSRAM variant and made other variants unselectable. + */ +function toBoard(info: SharedBoardInfo): Board { + return { + port: info.port || '', + config: { + fqbn: info.fqbn, + name: info.name + }, + connected: true, + guess: (info as { guess?: boolean }).guess } - return fqbn } /** @@ -90,14 +111,20 @@ function normalizeTinyCoreFqbn(fqbn: string): string { */ export class WebSocketArduinoService implements ArduinoService { protected client: TinyServiceClient - private lastListBoardsCall = 0 - private readonly LIST_BOARDS_THROTTLE_MS = 5000 // 5 seconds + private readonly serviceUrl: string + /** Latest board list, kept in sync by the backend's board-events pushes. */ private cachedBoardList: Board[] = [] + /** Last known board list (updated by list-boards replies and board-events pushes). */ + getCachedBoards(): Board[] { + return this.cachedBoardList + } + constructor() { // Initialize the WebSocket client + this.serviceUrl = resolveServiceUrl() this.client = new TinyServiceClient({ - url: resolveServiceUrl(), + url: this.serviceUrl, autoReconnect: true, reconnectInterval: 3000, maxReconnectAttempts: 10, @@ -144,11 +171,17 @@ export class WebSocketArduinoService implements ArduinoService { } /** - * Helper method to wait for Arduino service responses + * Helper method to wait for Arduino service responses. + * + * `requestId` is the id returned by the client's send methods; the backend + * echoes it on every reply, so concurrent requests of the same action no + * longer cross-talk. When the backend doesn't echo ids (older tinyService), + * matching falls back to the action name. */ private waitForResponse( action: string, - timeout = 60000 + timeout = 60000, + requestId?: string ): Promise<{ success: boolean; output: string; error?: string }> { return new Promise((resolve, reject) => { if (!this.client) { @@ -201,7 +234,11 @@ export class WebSocketArduinoService implements ArduinoService { }) } else if ( action === 'upload' && - (output.includes('avrdude done') || output.includes('Upload complete')) + (output.includes('avrdude done') || + output.includes('Upload complete') || + // esptool (ESP32 family, incl. tinyCore) success markers + output.includes('Hash of data verified') || + output.includes('Hard resetting')) ) { console.log(`[${action}] Detected successful upload from output, resolving...`) safeResolve({ @@ -217,6 +254,11 @@ export class WebSocketArduinoService implements ArduinoService { messageUnsubscribe = this.client.onMessage((message: OutgoingMessage) => { // Only handle messages for this action if (message.action !== action) return + // When both sides carry request ids, require an exact match so + // concurrent requests of the same action don't cross-talk. + if (requestId !== undefined && message.id !== undefined && message.id !== requestId) { + return + } console.log(`[${action}] Received message:`, message.type, message.data) // Debug log @@ -279,6 +321,13 @@ export class WebSocketArduinoService implements ArduinoService { output: arr.map((x) => JSON.stringify(x)).join('\n'), error: hasError ? errorMessage : undefined }) + } else if ((message.data as { details?: unknown }).details) { + // board-details response — serialize the details object. + safeResolve({ + success: !hasError, + output: JSON.stringify((message.data as { details: unknown }).details), + error: hasError ? errorMessage : undefined + }) } else { // Default handling for other actions const completeData = message.data as CompleteData @@ -333,25 +382,10 @@ export class WebSocketArduinoService implements ArduinoService { throw new Error('Arduino client not initialized') } - // Throttle list-boards calls to once every 5 seconds - const now = Date.now() - const timeSinceLastCall = now - this.lastListBoardsCall - - if (timeSinceLastCall < this.LIST_BOARDS_THROTTLE_MS) { - console.log( - `Throttling list-boards call. ${this.LIST_BOARDS_THROTTLE_MS - timeSinceLastCall}ms until next allowed call. Returning cached result.` - ) - // Return cached result instead of making a new request - return this.cachedBoardList - } - - this.lastListBoardsCall = now - - // Request list of boards - this.client.listBoards() - - // Wait for response - const result = await this.waitForResponse('list-boards', 10000) + // No throttle/cache needed anymore: the backend serves this instantly + // from its board watcher's in-memory state (no CLI spawn per call). + const requestId = this.client.listBoards() + const result = await this.waitForResponse('list-boards', 10000, requestId) if (!result.success) { throw new Error(result.error || 'Failed to list boards') @@ -363,16 +397,7 @@ export class WebSocketArduinoService implements ArduinoService { const lines = result.output.split('\n').filter((line) => line.trim()) for (const line of lines) { try { - const boardData = JSON.parse(line) as SharedBoardInfo - const normalizedFqbn = normalizeTinyCoreFqbn(boardData.fqbn) - boards.push({ - port: boardData.port || '', - config: { - fqbn: normalizedFqbn, - name: boardData.name - }, - connected: true - }) + boards.push(toBoard(JSON.parse(line) as SharedBoardInfo)) } catch { // Skip invalid lines } @@ -402,22 +427,9 @@ export class WebSocketArduinoService implements ArduinoService { throw new Error('Arduino client not initialized') } - // Check throttle for list-boards - const now = Date.now() - const timeSinceLastCall = now - this.lastListBoardsCall - - if (timeSinceLastCall < this.LIST_BOARDS_THROTTLE_MS) { - console.log( - `Throttling getBoardInfo list-boards call. ${this.LIST_BOARDS_THROTTLE_MS - timeSinceLastCall}ms until next allowed call` - ) - throw new Error('Board list request throttled. Please wait a moment and try again.') - } - - this.lastListBoardsCall = now - // List all boards and find the one matching the port - this.client.listBoards() - const result = await this.waitForResponse('list-boards', 10000) + const requestId = this.client.listBoards() + const result = await this.waitForResponse('list-boards', 10000, requestId) if (!result.success) { throw new Error(result.error || 'Failed to get board info') @@ -429,16 +441,7 @@ export class WebSocketArduinoService implements ArduinoService { try { const boardData = JSON.parse(line) as SharedBoardInfo if (boardData.port === port) { - const normalizedFqbn = normalizeTinyCoreFqbn(boardData.fqbn) - return { - port: boardData.port || '', - config: { - fqbn: normalizedFqbn, - name: boardData.name - }, - connected: true, - description: boardData.name - } + return { ...toBoard(boardData), description: boardData.name } } } catch { // Skip invalid lines @@ -476,10 +479,10 @@ export class WebSocketArduinoService implements ArduinoService { console.log( `Starting compile operation for workspace: ${workspacePath}, FQBN: ${boardConfig.fqbn}` ) - this.client.compile(workspacePath, boardConfig.fqbn, files, sketchName) + const requestId = this.client.compile(workspacePath, boardConfig.fqbn, files, sketchName) // Wait for response with longer timeout for compilation - const result = await this.waitForResponse('compile', 120000) + const result = await this.waitForResponse('compile', 120000, requestId) return { success: result.success, @@ -524,10 +527,16 @@ export class WebSocketArduinoService implements ArduinoService { console.log( `Starting upload operation to port: ${port}, FQBN: ${boardConfig.fqbn}, workspace: ${workspacePathOrBinary}` ) - this.client.upload(workspacePathOrBinary || '', boardConfig.fqbn, port, files, sketchName) + const requestId = this.client.upload( + workspacePathOrBinary || '', + boardConfig.fqbn, + port, + files, + sketchName + ) // Wait for response with longer timeout for upload - const result = await this.waitForResponse('upload', 90000) + const result = await this.waitForResponse('upload', 90000, requestId) return { success: result.success, @@ -630,16 +639,16 @@ export class WebSocketArduinoService implements ArduinoService { async searchLibraries(query: string): Promise { if (!this.client) throw new Error('Arduino client not initialized') - this.client.libSearch(query) - const result = await this.waitForResponse('lib-search', 60000) + const requestId = this.client.libSearch(query) + const result = await this.waitForResponse('lib-search', 60000, requestId) if (!result.success) throw new Error(result.error || 'Library search failed') return this.parseLibraries(result.output) } async listLibraries(): Promise { if (!this.client) throw new Error('Arduino client not initialized') - this.client.libList() - const result = await this.waitForResponse('lib-list', 30000) + const requestId = this.client.libList() + const result = await this.waitForResponse('lib-list', 30000, requestId) if (!result.success) throw new Error(result.error || 'Library list failed') return this.parseLibraries(result.output) } @@ -649,16 +658,16 @@ export class WebSocketArduinoService implements ArduinoService { version?: string ): Promise<{ success: boolean; output: string; error?: string }> { if (!this.client) throw new Error('Arduino client not initialized') - this.client.libInstall(name, version) - return this.waitForResponse('lib-install', 300000) + const requestId = this.client.libInstall(name, version) + return this.waitForResponse('lib-install', 300000, requestId) } async uninstallLibrary( name: string ): Promise<{ success: boolean; output: string; error?: string }> { if (!this.client) throw new Error('Arduino client not initialized') - this.client.libUninstall(name) - return this.waitForResponse('lib-uninstall', 60000) + const requestId = this.client.libUninstall(name) + return this.waitForResponse('lib-uninstall', 60000, requestId) } // ── boards manager ─────────────────────────────────────────────────────────── @@ -680,60 +689,60 @@ export class WebSocketArduinoService implements ArduinoService { async searchCores(query: string): Promise { if (!this.client) throw new Error('Arduino client not initialized') - this.client.coreSearch(query) - const result = await this.waitForResponse('core-search', 60000) + const requestId = this.client.coreSearch(query) + const result = await this.waitForResponse('core-search', 60000, requestId) if (!result.success) throw new Error(result.error || 'Core search failed') return this.parseJsonLines(result.output) } async listCores(): Promise { if (!this.client) throw new Error('Arduino client not initialized') - this.client.coreList() - const result = await this.waitForResponse('core-list', 30000) + const requestId = this.client.coreList() + const result = await this.waitForResponse('core-list', 30000, requestId) if (!result.success) throw new Error(result.error || 'Core list failed') return this.parseJsonLines(result.output) } async installCore(id: string, version?: string): Promise { if (!this.client) throw new Error('Arduino client not initialized') - this.client.coreInstall(id, version) + const requestId = this.client.coreInstall(id, version) // Cores (esp32 especially) are large — allow up to 10 minutes. - return this.waitForResponse('core-install', 600000) + return this.waitForResponse('core-install', 600000, requestId) } async uninstallCore(id: string): Promise { if (!this.client) throw new Error('Arduino client not initialized') - this.client.coreUninstall(id) - return this.waitForResponse('core-uninstall', 120000) + const requestId = this.client.coreUninstall(id) + return this.waitForResponse('core-uninstall', 120000, requestId) } async listAllBoards(): Promise { if (!this.client) throw new Error('Arduino client not initialized') - this.client.boardListall() - const result = await this.waitForResponse('board-listall', 30000) + const requestId = this.client.boardListall() + const result = await this.waitForResponse('board-listall', 30000, requestId) if (!result.success) throw new Error(result.error || 'Board listall failed') return this.parseJsonLines(result.output) } async listBoardUrls(): Promise { if (!this.client) throw new Error('Arduino client not initialized') - this.client.boardUrlList() - const result = await this.waitForResponse('board-url-list', 15000) + const requestId = this.client.boardUrlList() + const result = await this.waitForResponse('board-url-list', 15000, requestId) if (!result.success) throw new Error(result.error || 'Board URL list failed') return this.parseJsonLines(result.output) } async addBoardUrl(url: string): Promise { if (!this.client) throw new Error('Arduino client not initialized') - this.client.boardUrlAdd(url) + const requestId = this.client.boardUrlAdd(url) // Adding a URL also refreshes the index, which can take a while. - return this.waitForResponse('board-url-add', 120000) + return this.waitForResponse('board-url-add', 120000, requestId) } async removeBoardUrl(url: string): Promise { if (!this.client) throw new Error('Arduino client not initialized') - this.client.boardUrlRemove(url) - return this.waitForResponse('board-url-remove', 30000) + const requestId = this.client.boardUrlRemove(url) + return this.waitForResponse('board-url-remove', 30000, requestId) } // ── serial monitor (streaming, not request/response) ───────────────────────── @@ -750,8 +759,8 @@ export class WebSocketArduinoService implements ArduinoService { if (this.client?.isConnected()) this.client.serialClose() } - writeSerial(data: string): void { - if (this.client?.isConnected()) this.client.serialWrite(data) + writeSerial(data: string, raw?: boolean): void { + if (this.client?.isConnected()) this.client.serialWrite(data, raw) } /** Subscribe to streamed serial lines. Returns an unsubscribe function. */ @@ -764,14 +773,80 @@ export class WebSocketArduinoService implements ArduinoService { }) } - /** Subscribe to serial open/close status. Returns an unsubscribe function. */ - onSerialStatus(cb: (status: { opened?: boolean; closed?: boolean }) => void): () => void { + /** + * Subscribe to serial open/close/error status. Returns an unsubscribe + * function. The backend only reports `opened` after arduino-cli confirms + * the port is live, and sends `error` with its explanation when the port + * could not be opened (busy, missing, …). + */ + onSerialStatus( + cb: (status: { opened?: boolean; closed?: boolean; error?: string }) => void + ): () => void { if (!this.client) return () => {} return this.client.onMessage((message: OutgoingMessage) => { if (message.action !== 'serial') return - const d = message.data as { opened?: boolean; closed?: boolean } + const d = message.data as { opened?: boolean; closed?: boolean; error?: string } if (message.type === 'status' && d.opened) cb({ opened: true }) if (message.type === 'complete' && d.closed) cb({ closed: true }) + if (message.type === 'error' && d.error) cb({ error: d.error }) }) } + + // ── board events (server push) ─────────────────────────────────────────────── + + /** + * Subscribe to server-pushed board events. The backend watches + * `arduino-cli board list --watch` and broadcasts the full board list on + * every plug/unplug — no client polling. Returns an unsubscribe function. + */ + onBoardEvents(cb: (boards: Board[]) => void): () => void { + if (!this.client) return () => {} + return this.client.onMessage((message: OutgoingMessage) => { + if (message.action !== 'board-events' || message.type !== 'status') return + const data = message.data as { boards?: SharedBoardInfo[] } + if (!Array.isArray(data.boards)) return + const boards = data.boards.map(toBoard) + this.cachedBoardList = boards + cb(boards) + }) + } + + /** + * Subscribe to streamed output of a request/response action ("upload", + * "compile", …). Used e.g. to derive real upload progress from esptool / + * avrdude output. Returns an unsubscribe function. + */ + onActionOutput(action: string, cb: (output: string) => void): () => void { + if (!this.client) return () => {} + return this.client.onMessage((message: OutgoingMessage) => { + if (message.action === action && message.type === 'output') { + cb((message.data as { output: string }).output) + } + }) + } + + // ── board details (FQBN config options / programmers) ──────────────────────── + + async boardDetails(fqbn: string): Promise { + if (!this.client) throw new Error('Arduino client not initialized') + const requestId = this.client.boardDetails(fqbn) + const result = await this.waitForResponse('board-details', 20000, requestId) + if (!result.success) throw new Error(result.error || 'board-details failed') + return JSON.parse(result.output) as BoardDetails + } + + // ── language server ────────────────────────────────────────────────────────── + + /** + * WebSocket URL of the backend's LSP bridge for a given FQBN. The bridge + * lives on the same host/port as the main service socket, path /lsp. + */ + getLspUrl(fqbn: string): string | null { + try { + const base = this.serviceUrl.replace(/\/+$/, '') + return `${base}/lsp?fqbn=${encodeURIComponent(fqbn)}` + } catch { + return null + } + } } diff --git a/src/renderer/src/services/arduino/types.ts b/src/renderer/src/services/arduino/types.ts index b8c711a..e159a12 100644 --- a/src/renderer/src/services/arduino/types.ts +++ b/src/renderer/src/services/arduino/types.ts @@ -36,6 +36,12 @@ export interface Board { protocol?: string /** Whether the board is currently connected */ connected: boolean + /** + * True when the board identity was guessed from USB VID/PID rather than + * matched by arduino-cli — the user should be able to override it via the + * "choose board for this port" picker. + */ + guess?: boolean /** Additional board metadata */ metadata?: { vendorId?: string @@ -188,6 +194,31 @@ export interface ArduinoActionResult { error?: string } +/** One selectable value of an FQBN config option */ +export interface BoardConfigOptionValue { + value: string + valueLabel: string + selected?: boolean +} + +/** + * One FQBN config option of a board (PSRAM, CPU frequency, partition scheme…). + * Selected values are appended to the FQBN as `base:option=value,option2=value2`. + */ +export interface BoardConfigOption { + option: string + optionLabel: string + values: BoardConfigOptionValue[] +} + +/** Result of board-details: identity + Tools-menu equivalents */ +export interface BoardDetails { + fqbn: string + name: string + configOptions: BoardConfigOption[] + programmers: { id: string; name: string }[] +} + /** * Arduino service interface * Abstraction over the tinyService WebSocket backend. Implemented identically in @@ -276,26 +307,59 @@ export interface ArduinoService { /** Remove an additional board-manager URL */ removeBoardUrl(url: string): Promise + /** Fetch FQBN config options + programmers for a board (arduino-cli board details) */ + boardDetails(fqbn: string): Promise + /** Open the serial monitor on a port at a baud rate */ openSerial(port: string, baud: number): void /** Close the serial monitor */ closeSerial(): void - /** Send a line to the serial port */ - writeSerial(data: string): void + /** + * Send data to the serial port. With `raw: true` the payload is written + * exactly as provided (caller applies its own line ending); otherwise the + * backend appends "\n" (legacy behavior). + */ + writeSerial(data: string, raw?: boolean): void /** Subscribe to streamed serial lines; returns an unsubscribe function */ onSerialData(cb: (line: string) => void): () => void - /** Subscribe to serial open/close status; returns an unsubscribe function */ - onSerialStatus(cb: (status: { opened?: boolean; closed?: boolean }) => void): () => void + /** + * Subscribe to serial open/close/error status; returns an unsubscribe + * function. `error` carries the backend's explanation when a port could + * not be opened (busy, missing, …). + */ + onSerialStatus( + cb: (status: { opened?: boolean; closed?: boolean; error?: string }) => void + ): () => void + + /** + * Subscribe to server-pushed board events (event-driven detection via + * `arduino-cli board list --watch`). Fired with the full current board list + * whenever a device is plugged/unplugged. Returns an unsubscribe function. + */ + onBoardEvents(cb: (boards: Board[]) => void): () => void + + /** + * Subscribe to streamed output lines of a request/response action + * ("upload", "compile", …) — e.g. to derive real upload progress from + * esptool/avrdude output. Returns an unsubscribe function. + */ + onActionOutput(action: string, cb: (output: string) => void): () => void /** Whether the tinyService backend is currently connected */ isConnected(): boolean /** Subscribe to backend connect/disconnect transitions; returns an unsubscribe function */ onConnectionChange(cb: (connected: boolean) => void): () => void + + /** + * WebSocket URL of the backend's LSP bridge for a given FQBN, or null when + * unsupported in this environment. + */ + getLspUrl(fqbn: string): string | null } /**