Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
radicle-desktop CLAUDE.md

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:

  1. crates/radicle-types/src/ — add/update Rust types with #[derive(TS)] and #[ts(export)], run npm run generate-types to update @bindings
  2. crates/radicle-types/src/traits/ — add the method to the relevant port trait; default implementation goes here, not in the command handler
  3. crates/radicle-tauri/src/commands/ — add the #[tauri::command] handler (thin wrapper: ctx.method()), register in crates/radicle-tauri/src/lib.rs
  4. crates/test-http-api/src/ — mirror the same route so E2E tests keep working
  5. src/ — call via invoke<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/InboxStorage port + Service<I: InboxStorage>
  • domain/patch/PatchStorage port + 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 handling
  • radicle-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 undefined over null
  • 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; local loading boolean with try/catch for 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 TypeScript private keyword
  • 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.rs via tauri::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 yarn or pnpm — use npm
  • Do not use npx vitest or npx playwright test — use the npm run scripts
  • Do not mix legacy Svelte syntax (export let, $:) with runes in the same component
# 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

```sh
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)

```sh
npm run check
npm run format          # auto-fix prettier issues
```

### Unit tests

```sh
npm run test:unit
```

### E2E tests

```sh
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.

```sh
SKIP_SETUP=true npm run test:e2e -- --project webkit
```

### Rust backend (`crates/`)

```sh
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/`:

```sh
npm run generate-types
```

### Changes across layers

Adding or changing a Tauri command touches these layers in order:

1. `crates/radicle-types/src/` — add/update Rust types with `#[derive(TS)]` and
   `#[ts(export)]`, run `npm run generate-types` to update `@bindings`
2. `crates/radicle-types/src/traits/` — add the method to the relevant port trait;
   default implementation goes here, not in the command handler
3. `crates/radicle-tauri/src/commands/` — add the `#[tauri::command]` handler
   (thin wrapper: `ctx.method()`), register in `crates/radicle-tauri/src/lib.rs`
4. `crates/test-http-api/src/` — mirror the same route so E2E tests keep working
5. `src/` — call via `invoke<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.

```sh
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:

```sh
npm run generate-types
```

**E2E prerequisite** — run once after cloning or when Radicle binaries are updated:

```sh
./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:

```rust
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/` — `InboxStorage` port + `Service<I: InboxStorage>`
- `domain/patch/` — `PatchStorage` port + `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 handling
- `radicle-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 `undefined` over `null`
- 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;
  local `loading` boolean with `try/catch` for 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 TypeScript `private` keyword
- 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.rs` via `tauri::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 `yarn` or `pnpm` — use npm
- Do not use `npx vitest` or `npx playwright test` — use the `npm run` scripts
- Do not mix legacy Svelte syntax (`export let`, `$:`) with runes in the same component