All three are typed end to end: Rust types derive
specta::Type, and tauri-specta generates src/bindings.ts.
Commands
A command is a thin adapter insrc-tauri/src/commands/<domain>.rs. It resolves the project from the calling window, calls the shared implementation (usually in ide_host::ops), and returns IdeResult<T>:
src-tauri/src/commands/app.rs
collect_commands! list of commands::builder() (src-tauri/src/commands/mod.rs). The module’s conventions:
- every command returns
IdeResult<T>(it rejects withIdeErrorin JS); - project-scoped commands resolve the project from the calling window;
- streaming results use a
Channel<T>argument; cancellable requests take a UI-allocatedRequestIdand are cancelled viacancel_request.
src-tauri/src/lib.rs checks project trust and, for a remote window, routes it to the server.
Calling a command
The frontend importscommands from @/platform/ipc (which re-exports the bindings) and unwraps the result with call:
{ status: "ok", data } | { status: "error", error }. call turns that into a promise that rejects with IpcError, which carries the error’s kind and data. Helpers: isCancelled(e), isNoServer(e), unavailableText(e).
Errors
IdeError (crates/ide-core/src/error.rs) is the single error type that crosses IPC:
ErrorKind is one of notFound, alreadyExists, permissionDenied, invalidInput, outOfSync (document version mismatch; the UI must resync the full text), cancelled, timedOut, unavailable (a required tool such as a language server, git or a debugger is missing), io, protocol, internal.
Crates keep rich internal errors and convert at their public edge; the UI switches on kind and shows message. Don’t leak anyhow or crate-specific errors through a command.
Generated bindings
src/bindings.ts is generated by tauri-specta from the registered commands plus IdeEvent. Never edit it.
pnpm gen:bindingsregenerates it without launching the app (cargo test -p openidea --test export_bindings).- Every debug launch also re-exports it, rewriting the file only when it changed.
- CI regenerates it in the Rust job and fails if the committed file differs. After changing a command signature or an IPC type, commit
src/bindings.ts.
number.
Text coordinates
Everything that crosses IPC uses the same coordinates (crates/ide-core/src/text.rs):
- Lines are 0-based.
- Columns and offsets are UTF-16 code units, matching JavaScript strings, CodeMirror and the LSP default encoding.
Pos { line, col };Range { start, end }is half-open.
ide-text. Convert at the edge of your crate: a tree-sitter byte offset or an LSP position in another encoding must never reach a command’s result.
Streaming with channels
High-volume or incremental results go through aChannel<T> argument, not the event bus. Find in Files:
src-tauri/src/commands/search.rs
Channel, set onmessage, and pass it as the argument:
src/platform/textSearch.ts
Done value) so the UI knows when it ended.
Cancellable requests
The UI allocates the id; the backend registers a token for it:crates/ide-host/src/ops/search.rs
- Ids are shared by all windows of the backend, so each page starts counting at a random point.
- A cancel that arrives before the command began is remembered for 120 seconds, and the request then starts out cancelled (
crates/ide-host/src/requests.rs). - Long work checks the
CancellationTokenand returnsErrorKind::Cancelled(or a partial result) promptly. - Tasks the backend starts itself (a language server’s work-done progress) are reported as
IdeEvent::Taskwith aTaskIdand cancelled withcancel_task, notcancel_request.
Events
IdeEvent (crates/ide-core/src/event.rs) is one enum of everything the backend tells the UI unasked, serialized as { type: "<camelCase name>", ... }. Feature crates emit through an EventSink:
src-tauri/src/events.rs implements it as TauriSink, which forwards to one window (plus its auxiliary windows) or all windows as the single Tauri event ide://event. Crates take a SharedSink and never depend on Tauri; tests use NullSink.
The UI subscribes by type, and the handler gets the narrowed variant:
src/features/project-trust/index.tsx
onIdeEvent returns an unsubscribe function; return it from your feature’s disposer. Listening is window-scoped.
Threads
Long work never blocks the UI thread or a Tauri command thread. Make the commandasync and move CPU-bound or blocking work onto spawn_blocking or rayon; honour the CancellationToken. Synchronous commands run on the main thread, so keep them trivial.
Remote windows
You don’t write anything special for remote development. In a remote window,src/platform/remote.ts routes the commands the server answers through remote_invoke; each Channel argument is replaced by { "$channel": id } and its values come back tagged with the id. Events arrive as event notifications and are re-emitted locally. What you must do is keep the implementation in ide-host::ops and register it with the server’s Dispatcher (the d.add("search_start", ...) lists in crates/ide-host/src/rpc.rs and the ops modules) so the server can run it. See Remote development.
Checklist: adding a command
- Put the logic in the owning crate; convert errors to
IdeErrorat its edge; use IPC coordinates. - Add the shared implementation to
ide-host::ops::<domain>if it touches a project; register it with the remote dispatcher if remote windows need it. - Add the
#[tauri::command] #[specta::specta]adapter insrc-tauri/src/commands/<domain>.rsand list it incollect_commands!. - If it executes anything, leave it out of the restricted-mode allowlist (it is denied in untrusted projects by default).
pnpm gen:bindings, then call it withcall(commands.yourCommand(...)).