10 KiB
radicle-desktop
Tauri desktop app for Radicle. Svelte 5 frontend communicates with a Rust
backend via Tauri IPC commands. TypeScript types are generated from Rust
structs using ts-rs (crates/radicle-types/).
Plan mode
- Propose the minimal MVP first — no tests, no docs, no speculative extras
- Tests are added only when explicitly requested after MVP review
- Keep plans concise: short bullet points listing what changes and where
- Skip obvious details and boilerplate explanations
Tech stack
- Svelte 5 with runes syntax (
$state,$derived,$props,$effect) - TypeScript, Vite, Vitest, Playwright
- Rust backend: Tauri commands in
crates/radicle-tauri/ - Types generated by ts-rs:
crates/radicle-types/→crates/radicle-types/bindings/ - Path aliases:
@app(src),@bindings(crates/radicle-types/bindings),@tests
Commands
Development
npm run tauri dev # full Tauri dev build with hot reload
npm run start:http # Vite dev server backed by test-http-api (no Tauri runtime)
Checks and linting (tsc, svelte-check, eslint, prettier)
npm run check
npm run format # auto-fix prettier issues
Unit tests
npm run test:unit
E2E tests
npm run test:e2e -- --project webkit
npm run test:e2e -- --project webkit tests/e2e/<file>.spec.ts
SKIP_SETUP=true skips fixture creation for faster iteration.
Only use it when you are solely editing .spec.ts files and fixtures
already exist from a previous full run. Any change to app code, the Rust
backend, or test fixtures requires a full run.
SKIP_SETUP=true npm run test:e2e -- --project webkit
Rust backend (crates/)
scripts/check-rs # cargo fmt --check, clippy --workspace, check, test
Regenerate TypeScript bindings
Run after changing any type annotated with #[derive(TS)] in crates/radicle-types/:
npm run generate-types
Changes across layers
Adding or changing a Tauri command touches these layers in order:
crates/radicle-types/src/— add/update Rust types with#[derive(TS)]and#[ts(export)], runnpm run generate-typesto update@bindingscrates/radicle-types/src/traits/— add the method to the relevant port trait; default implementation goes here, not in the command handlercrates/radicle-tauri/src/commands/— add the#[tauri::command]handler (thin wrapper:ctx.method()), register incrates/radicle-tauri/src/lib.rscrates/test-http-api/src/— mirror the same route so E2E tests keep workingsrc/— call viainvoke<T>("command_name", args), import types from@bindings
Pre-push checklist
These mirror what CI runs on every PR. Run them all before shipping a feature.
npm run check # tsc, svelte-check, eslint, prettier,
# cargo fmt, cargo clippy, cargo test
npm run test:unit
npm run test:e2e -- --project webkit
If backend types changed, regenerate and commit the bindings first:
npm run generate-types
E2E prerequisite — run once after cloning or when Radicle binaries are updated:
./scripts/install-binaries
npm run build:http
Backend architecture
The Rust backend follows a hexagonal (ports & adapters) architecture split across three crates:
crates/
├── radicle-types/ # domain types, ports (traits), adapters, ts-rs bindings
├── radicle-tauri/ # Tauri driver — thin IPC wrappers around port methods
└── test-http-api/ # Axum HTTP driver — same ports, used by E2E tests
Both driver crates depend on radicle-types and share no logic between them.
radicle-types — ports and adapters
Ports are Rust traits in src/traits/. Each trait has a full default
implementation that calls self.profile() to access the Radicle SDK directly.
AppState (the single concrete type shared by drivers) holds a
radicle::Profile and derives all behaviour from these default implementations:
pub struct AppState { pub profile: radicle::Profile }
impl Repo for AppState {} // all methods come from the trait defaults
impl Issues for AppState {}
impl Patches for AppState {}
// ...
Current ports: Profile, Repo, Cobs, Thread, Issues, IssuesMut,
Patches, PatchesMut, Jobs.
Domain modules in src/domain/ go one step further: they define their own
storage port + a generic Service<T> that wraps it, keeping domain logic
separate from the Radicle SDK. Currently implemented:
domain/inbox/—InboxStorageport +Service<I: InboxStorage>domain/patch/—PatchStorageport +Service<I: PatchStorage>
Outbound adapter in src/outbound/sqlite.rs: a single Sqlite struct that
implements both InboxStorage and PatchStorage by querying the Radicle SQLite
COB cache and notifications DB (read-only, thread-safe).
radicle-tauri — Tauri driver
Commands in src/commands/ are thin wrappers: they receive
tauri::State<AppState>, call the matching port method (e.g. ctx.list_repos()),
and return the result. No business logic lives here.
Note: the domain Service types (InboxService, PatchService) are instantiated
in startup.rs but not yet wired into AppState — the Tauri commands currently
reach the same data through the trait default implementations. Completing this
wiring is future work.
test-http-api — HTTP driver
Mirrors radicle-tauri as an Axum HTTP server. Used by Playwright E2E tests
(and npm run start:http) so the frontend can run without the Tauri runtime.
Context implements all the same port traits as AppState.
Radicle ecosystem (sibling repos)
crates/radicle-tauri depends on crates from sibling repositories. Read
source from these paths when working on Rust code.
heartwood (../heartwood)
Core Radicle protocol implementation. Key crates used by radicle-tauri:
radicle— standard library (storage, identity, COBs, git, node)radicle-cob— collaborative objects (issues, patches as CRDTs)radicle-crypto— Ed25519 signing, SSH key handlingradicle-core— fundamental types (RepoId, etc.)
Key files:
../heartwood/HACKING.md— development guide, environment variables../heartwood/ARCHITECTURE.md— high-level architecture../heartwood/crates/radicle/src/lib.rs— main library entry point
radicle-git (../radicle-git)
Git library wrappers. Key crate:
radicle-surf— code browsing (files, diffs, commits, branches, tags). This is what radicle-tauri uses to serve repository content.
Key file: ../radicle-git/radicle-surf/src/lib.rs
radicle-job (../radicle-job)
Decentralized job execution (CI/CD) on the Radicle network.
Key file: ../radicle-job/README.md
RIPs — protocol specs (../rips)
../rips/0001-heartwood.md— protocol overview../rips/0002-identity.md— identity system (DIDs, Ed25519)../rips/0003-storage-layout.md— git storage layout
Radicle documentation (../radicle.dev)
Read these when you need domain context for UI work:
../radicle.dev/_guides/user.md— end-to-end user workflows (init, clone, seed, issues, patches, code review, private repos)../radicle.dev/_guides/protocol.md— protocol internals (gossip, replication, identity documents, COB data model, trust)../radicle.dev/_guides/seeder.md— seed node operation (seeding policies, httpd setup, DNS-SD)../radicle.dev/_posts/2025-07-23-using-radicle-ci-for-development.md— CI integration../radicle.dev/_posts/2025-08-12-canonical-references.md— canonical refs design
Domain glossary
- RID — Repository ID (
rad:z3gqc...) - DID — Decentralized Identifier, user identity (
did:key:z6Mk...) - NID — Node ID, public key suffix of a DID
- COB — Collaborative Object (issue, patch, or identity as a Git DAG)
- Delegate — authorized repo maintainer; signatures determine canonical state
- Patch — pull-request equivalent with immutable revisions and reviews
- Seed — hosting/replicating a repo; seed nodes are always-on servers
- Canonical refs — branches/tags resolved by delegate quorum
Code conventions
- Prefer
undefinedovernull - Do not add comments unless explicitly asked. When writing comments, use proper English sentences
- Ask before adding new dependencies
Svelte components
- Script order (enforced by prettier):
<script lang="ts" module>,<script lang="ts">,<style>, markup - Props use
$props():const { foo, bar = undefined }: Props = $props() - CSS: scoped styles with design tokens (
var(--color-text-primary),var(--txt-body-m-regular),var(--border-radius-sm)); use:global()for styling slotted or{@html}content - Loading states:
{#await promise}blocks for inline async; localloadingboolean withtry/catchfor imperative fetches
TypeScript
- Import backend types from
@bindings/*— these are generated by ts-rs, never hand-write interfaces that duplicate them - Use ES private fields (
#field), not the TypeScriptprivatekeyword - Call Tauri commands via
invoke<T>("command_name", { arg })from@app/lib/invoke
Rust backend
- Business logic belongs in trait default implementations (
crates/radicle-types/src/traits/), not in command handlers - Tauri command signature:
pub fn cmd(ctx: tauri::State<AppState>, ...) -> Result<T, Error> - Commands are registered in
crates/radicle-tauri/src/lib.rsviatauri::generate_handler![...] - Mirror every new command in
crates/test-http-api/src/so E2E tests continue to work - All serialized types live in
crates/radicle-types/src/; annotate new types with#[derive(Serialize, TS)],#[serde(rename_all = "camelCase")],#[ts(export)], and#[ts(export_to = "<dir>/")] - Optional fields:
#[serde(skip_serializing_if = "Option::is_none")] - Error type:
radicle_types::error::Error
Commit messages
- Imperative mood: "Add feature" not "Added feature"
- Capitalize subject, no trailing period, max 50 chars
Do NOT
- Do not use
npm test— no default test script exists - Do not use
yarnorpnpm— use npm - Do not use
npx vitestornpx playwright test— use thenpm runscripts - Do not mix legacy Svelte syntax (
export let,$:) with runes in the same component