Compare commits
13 Commits
55133751cb
...
feat/realt
| Author | SHA1 | Date | |
|---|---|---|---|
| 90c2c1e3e0 | |||
| b93ca7dcb6 | |||
| fb3bc6e93a | |||
| dc7678d184 | |||
| ee19de5f94 | |||
| 455d304885 | |||
| 91260c8e30 | |||
| 68c5024334 | |||
| b4e9d25b9f | |||
| a432ac976b | |||
| 3109ac4c22 | |||
| 7c2e5ca9a9 | |||
| d384465f2e |
83
.gitea/workflows/release.yml
Normal file
83
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 17
|
||||||
|
|
||||||
|
- uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
- uses: nttld/setup-ndk@v1
|
||||||
|
id: ndk
|
||||||
|
with:
|
||||||
|
ndk-version: r26d
|
||||||
|
|
||||||
|
- name: System dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y jq libgtk-3-dev libwebkit2gtk-4.1-dev \
|
||||||
|
libsoup-3.0-dev libayatana-appindicator3-dev librsvg2-dev
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
# Desktop: deb + rpm + AppImage (bundle.targets = "all")
|
||||||
|
- name: Build Linux desktop
|
||||||
|
run: npm run tauri build
|
||||||
|
|
||||||
|
- name: Build Android APK
|
||||||
|
env:
|
||||||
|
NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
|
||||||
|
run: |
|
||||||
|
npm run tauri android init
|
||||||
|
npm run tauri android build -- --apk
|
||||||
|
|
||||||
|
# ponytail: sign the finished APK with apksigner instead of patching
|
||||||
|
# gen/android/app/build.gradle.kts — keeps gen/ throwaway, no committed generated code.
|
||||||
|
- name: Sign APK
|
||||||
|
env:
|
||||||
|
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE }}
|
||||||
|
KS_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
echo "$KEYSTORE_B64" | base64 -d > /tmp/ks.jks
|
||||||
|
BT="$ANDROID_HOME/build-tools/$(ls "$ANDROID_HOME/build-tools" | sort -V | tail -1)"
|
||||||
|
IN=src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release-unsigned.apk
|
||||||
|
"$BT/zipalign" -p -f 4 "$IN" /tmp/aligned.apk
|
||||||
|
"$BT/apksigner" sign --ks /tmp/ks.jks --ks-pass "env:KS_PASS" \
|
||||||
|
--out "rustification-${GITHUB_REF_NAME}.apk" /tmp/aligned.apk
|
||||||
|
|
||||||
|
- name: Publish release
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
ID=$(curl -sSf -X POST "$API/releases" \
|
||||||
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\"}" | jq -r .id)
|
||||||
|
for f in src-tauri/target/release/bundle/deb/*.deb \
|
||||||
|
src-tauri/target/release/bundle/rpm/*.rpm \
|
||||||
|
src-tauri/target/release/bundle/appimage/*.AppImage \
|
||||||
|
rustification-${GITHUB_REF_NAME}.apk; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
curl -sSf -X POST "$API/releases/$ID/assets?name=$(basename "$f")" \
|
||||||
|
-H "Authorization: token $TOKEN" -F "attachment=@$f"
|
||||||
|
done
|
||||||
5088
Cargo.lock
generated
Normal file
5088
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
123
README.md
Normal file
123
README.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# rustification-client
|
||||||
|
|
||||||
|
Desktop chat client for Rustification. Built with Tauri v2 (Rust backend) and React + TypeScript (frontend).
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| Desktop shell | [Tauri](https://tauri.app) v2 |
|
||||||
|
| Frontend | React 18 + TypeScript, Vite |
|
||||||
|
| HTTP | reqwest (Rust) via Tauri commands |
|
||||||
|
| Persistence | tauri-plugin-store (JWT token, user ID) |
|
||||||
|
| Build (Linux) | nix-shell |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Login / Register** — authenticates against the rustification-server, stores JWT via tauri-plugin-store
|
||||||
|
- **Workspace list** — browse workspaces the current user belongs to
|
||||||
|
- **Chat page** — conversation list, message history, and real-time polling for new messages
|
||||||
|
- **Message input** — send messages to any conversation
|
||||||
|
- **Member panel** — view conversation members
|
||||||
|
- **Settings page** — user preferences
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [Rust](https://rustup.rs/) + Cargo
|
||||||
|
- [Node.js](https://nodejs.org/) 18+ and npm
|
||||||
|
- Tauri v2 system dependencies (see below)
|
||||||
|
- A running [rustification-server](../rustification-server)
|
||||||
|
|
||||||
|
### System dependencies (Linux)
|
||||||
|
|
||||||
|
The project ships a `shell.nix`. If you use Nix, enter the dev shell first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix-shell
|
||||||
|
```
|
||||||
|
|
||||||
|
This provides: `cargo`, `rustc`, `pkg-config`, `glib`, `gtk3`, `webkitgtk_4_1`, `libsoup_3`, `openssl`, `librsvg`.
|
||||||
|
|
||||||
|
Without Nix, install the equivalent packages via your system package manager. On Debian/Ubuntu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev \
|
||||||
|
libayatana-appindicator3-dev librsvg2-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install frontend dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start in development mode (hot-reload frontend + Tauri window)
|
||||||
|
# Must be run inside nix-shell on NixOS / Nix systems
|
||||||
|
npm run tauri dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The client defaults to `http://localhost:3000/api/v1` as the server base URL (configurable at runtime via Settings).
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Must be run inside nix-shell on NixOS / Nix systems
|
||||||
|
npm run tauri build
|
||||||
|
```
|
||||||
|
|
||||||
|
Output bundles (`.deb`, `.AppImage`, etc.) are written to `src-tauri/target/release/bundle/`.
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/ # React frontend
|
||||||
|
├── main.tsx # Entry point
|
||||||
|
├── App.tsx # Router + AuthContext provider
|
||||||
|
├── theme.ts # MUI / global theme
|
||||||
|
├── styles.css
|
||||||
|
├── contexts/
|
||||||
|
│ └── AuthContext.tsx # Auth state (token, user ID, login/logout)
|
||||||
|
├── pages/
|
||||||
|
│ ├── LoginPage.tsx
|
||||||
|
│ ├── WorkspacePage.tsx
|
||||||
|
│ ├── ChatPage.tsx
|
||||||
|
│ └── SettingsPage.tsx
|
||||||
|
└── components/
|
||||||
|
├── ConversationList.tsx
|
||||||
|
├── MessageList.tsx
|
||||||
|
├── MessageInput.tsx
|
||||||
|
├── MemberPanel.tsx
|
||||||
|
└── Toast.tsx
|
||||||
|
|
||||||
|
src-tauri/ # Tauri Rust backend
|
||||||
|
├── Cargo.toml
|
||||||
|
└── src/
|
||||||
|
├── lib.rs # Tauri builder, plugin + command registration
|
||||||
|
├── main.rs
|
||||||
|
├── state.rs # AppState: reqwest client, token, base URL
|
||||||
|
├── models.rs # Shared response types
|
||||||
|
└── commands/
|
||||||
|
├── user.rs # login, register, refresh_session, logout
|
||||||
|
├── workspace.rs # list_workspaces
|
||||||
|
├── conversation.rs # list_conversations
|
||||||
|
└── message.rs # list_messages, send_message
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tauri commands
|
||||||
|
|
||||||
|
The frontend calls these via `invoke()`:
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `login` | Authenticates and stores JWT + user ID |
|
||||||
|
| `register` | Registers a new account |
|
||||||
|
| `refresh_session` | Refreshes the JWT using the stored refresh token |
|
||||||
|
| `logout` | Clears stored credentials |
|
||||||
|
| `list_workspaces` | Returns workspaces for the current user |
|
||||||
|
| `list_conversations` | Returns conversations for a workspace |
|
||||||
|
| `list_messages` | Returns messages for a conversation |
|
||||||
|
| `send_message` | Posts a new message |
|
||||||
|
|
||||||
|
## Server connection
|
||||||
|
|
||||||
|
The default base URL is `http://localhost:3000/api/v1`. To point the client at a different server, update the URL in Settings — it is persisted in the Tauri store across sessions.
|
||||||
@@ -16,3 +16,7 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
uuid = { version = "1.24.0", features = ["v4"] }
|
||||||
|
futures-util = "0.3.34"
|
||||||
|
tokio-tungstenite = { version = "0.30.0", features = ["rustls-tls-webpki-roots"] }
|
||||||
|
rand = "0.10.2"
|
||||||
|
|||||||
2292
src-tauri/gen/schemas/windows-schema.json
Normal file
2292
src-tauri/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
src-tauri/icons/icon.ico
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
BIN
src-tauri/icons/icon.png
Normal file
BIN
src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
17
src-tauri/src/commands/conversation.rs
Normal file
17
src-tauri/src/commands/conversation.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use crate::models::Conversation;
|
||||||
|
use crate::state::{AppState, BASE_URL};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_conversations(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
workspace_id: String,
|
||||||
|
) -> Result<Vec<Conversation>, String> {
|
||||||
|
state
|
||||||
|
.authed_get(&format!("{}/conversations?workspace_id={}", BASE_URL, workspace_id))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
66
src-tauri/src/commands/message.rs
Normal file
66
src-tauri/src/commands/message.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
use crate::models::Message;
|
||||||
|
use crate::state::{AppState, BASE_URL};
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct History {
|
||||||
|
cursor: i64,
|
||||||
|
messages: Vec<Message>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_messages(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
conversation_id: String,
|
||||||
|
) -> Result<Vec<Message>, String> {
|
||||||
|
let history: History = state
|
||||||
|
.authed_get(&format!(
|
||||||
|
"{}/conversations/{}/messages",
|
||||||
|
BASE_URL, conversation_id
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Loading the history *is* a resync: adopt the cursor it came with, so the
|
||||||
|
// next realtime signal asks for a delta from the right place.
|
||||||
|
state.cursors.set(&conversation_id, history.cursor);
|
||||||
|
Ok(history.messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn send_message(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
conversation_id: String,
|
||||||
|
content: String,
|
||||||
|
) -> Result<Message, String> {
|
||||||
|
let user_id = state
|
||||||
|
.user_id
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| "Not logged in".to_string())?;
|
||||||
|
|
||||||
|
// The id comes from here, not from the server. Without it the optimistically
|
||||||
|
// rendered message cannot be matched against the copy that arrives over the
|
||||||
|
// realtime channel, and the same message shows up twice. It also makes a
|
||||||
|
// retry after an unclear network error a no-op instead of a duplicate.
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"id": uuid::Uuid::new_v4().to_string(),
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"content": content,
|
||||||
|
});
|
||||||
|
|
||||||
|
state
|
||||||
|
.authed_post(&format!("{}/messages", BASE_URL))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
4
src-tauri/src/commands/mod.rs
Normal file
4
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod conversation;
|
||||||
|
pub mod message;
|
||||||
|
pub mod user;
|
||||||
|
pub mod workspace;
|
||||||
48
src-tauri/src/commands/user.rs
Normal file
48
src-tauri/src/commands/user.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
use crate::models::{AuthTokenResponse, User};
|
||||||
|
use crate::state::{AppState, BASE_URL};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn login(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
email: String,
|
||||||
|
) -> Result<User, String> {
|
||||||
|
let users: Vec<User> = state
|
||||||
|
.client
|
||||||
|
.get(format!("{}/users", BASE_URL))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let user = users
|
||||||
|
.into_iter()
|
||||||
|
.find(|u| u.email == email)
|
||||||
|
.ok_or_else(|| "User not found".to_string())?;
|
||||||
|
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"user_id": user.id,
|
||||||
|
"kind": "refresh_token",
|
||||||
|
"expires_at": "2027-01-01T00:00:00Z"
|
||||||
|
});
|
||||||
|
|
||||||
|
let token_resp: AuthTokenResponse = state
|
||||||
|
.client
|
||||||
|
.post(format!("{}/auth-tokens", BASE_URL))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
*state.token.lock().unwrap() = Some(token_resp.token);
|
||||||
|
*state.user_id.lock().unwrap() = Some(user.id.clone());
|
||||||
|
|
||||||
|
crate::realtime::spawn(app);
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
14
src-tauri/src/commands/workspace.rs
Normal file
14
src-tauri/src/commands/workspace.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
use crate::models::Workspace;
|
||||||
|
use crate::state::{AppState, BASE_URL};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_workspaces(state: tauri::State<'_, AppState>) -> Result<Vec<Workspace>, String> {
|
||||||
|
state
|
||||||
|
.authed_get(&format!("{}/workspaces", BASE_URL))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
@@ -1,11 +1,21 @@
|
|||||||
|
mod commands;
|
||||||
mod models;
|
mod models;
|
||||||
|
mod realtime;
|
||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
use state::AppState;
|
use state::AppState;
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.manage(AppState::new())
|
.manage(AppState::new())
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::user::login,
|
||||||
|
commands::workspace::list_workspaces,
|
||||||
|
commands::conversation::list_conversations,
|
||||||
|
commands::message::list_messages,
|
||||||
|
commands::message::send_message,
|
||||||
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,12 +28,16 @@ pub struct Conversation {
|
|||||||
pub created_by: String,
|
pub created_by: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct Message {
|
pub struct Message {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub conversation_id: String,
|
pub conversation_id: String,
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
/// Sort key within the conversation. Display order follows this, never
|
||||||
|
/// `created_at` — that is transaction start time on the server, so it is
|
||||||
|
/// neither unique nor commit-ordered.
|
||||||
|
pub seq: i64,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
219
src-tauri/src/realtime.rs
Normal file
219
src-tauri/src/realtime.rs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
//! The realtime connection, owned by Rust rather than by the WebView.
|
||||||
|
//!
|
||||||
|
//! Two reasons it lives here. The handshake can carry a real
|
||||||
|
//! `Authorization: Bearer` header — the only channel that proxy defaults do not
|
||||||
|
//! write to their access logs — and `tungstenite` surfaces the full HTTP
|
||||||
|
//! response on a rejected handshake, whereas the browser API is required by spec
|
||||||
|
//! to collapse every failure into close code 1006. "Token expired" and "server
|
||||||
|
//! down" would be indistinguishable in the WebView.
|
||||||
|
//!
|
||||||
|
//! The socket only ever says "conversation X is at seq N". This module then
|
||||||
|
//! pulls the actual entities over REST and hands them to the frontend, so the
|
||||||
|
//! frontend keeps no cursor logic at all.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||||
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
|
||||||
|
use crate::models::Message as ChatMessage;
|
||||||
|
use crate::state::{AppState, BASE_URL};
|
||||||
|
|
||||||
|
pub const EVENT_CHANGED: &str = "realtime://changed";
|
||||||
|
pub const EVENT_RESYNC: &str = "realtime://resync";
|
||||||
|
// TODO: a connection-state event per server once the UI shows it — ticket 10
|
||||||
|
// wants "connected / reconnecting / signed out" visible per server. Left out
|
||||||
|
// until something listens, rather than emitting into the void.
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
enum Inbound {
|
||||||
|
Changed { conversation: String, seq: i64 },
|
||||||
|
Resync,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Clone)]
|
||||||
|
pub struct Changed {
|
||||||
|
pub conversation_id: String,
|
||||||
|
pub messages: Vec<ChatMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
enum Changes {
|
||||||
|
Delta {
|
||||||
|
cursor: i64,
|
||||||
|
messages: Vec<ChatMessage>,
|
||||||
|
},
|
||||||
|
Resync {
|
||||||
|
cursor: i64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cursor per conversation. Lives next to the connection and the token, so the
|
||||||
|
/// frontend never has to know it exists.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Cursors(Mutex<HashMap<String, i64>>);
|
||||||
|
|
||||||
|
impl Cursors {
|
||||||
|
fn get(&self, conversation: &str) -> i64 {
|
||||||
|
*self.0.lock().unwrap().get(conversation).unwrap_or(&0)
|
||||||
|
}
|
||||||
|
pub fn set(&self, conversation: &str, seq: i64) {
|
||||||
|
self.0.lock().unwrap().insert(conversation.to_string(), seq);
|
||||||
|
}
|
||||||
|
fn clear(&self) {
|
||||||
|
self.0.lock().unwrap().clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close codes the server may send. 4002 and 4003 are terminal: reconnecting
|
||||||
|
/// after being displaced would just displace somebody else, and two devices
|
||||||
|
/// would push each other around in circles.
|
||||||
|
const CLOSE_TOKEN_EXPIRED: u16 = 4001;
|
||||||
|
const CLOSE_DISPLACED: u16 = 4002;
|
||||||
|
const CLOSE_UNAUTHORIZED: u16 = 4003;
|
||||||
|
|
||||||
|
pub fn spawn(app: AppHandle) {
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
// TODO: one connection per configured server once the server list
|
||||||
|
// replaces BASE_URL. The structure below is per-connection already; what
|
||||||
|
// is missing is the list and the per-server auth UI.
|
||||||
|
let mut attempt: u32 = 0;
|
||||||
|
loop {
|
||||||
|
match connect_once(&app).await {
|
||||||
|
Ok(terminal) => {
|
||||||
|
if terminal {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attempt = 0;
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("realtime: {e}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full jitter. The random part is not optional: without it every
|
||||||
|
// client comes back at the same instant after a server restart.
|
||||||
|
let ceiling = (500u64 << attempt.min(6)).min(30_000);
|
||||||
|
let wait = rand::random_range(0..=ceiling);
|
||||||
|
tokio::time::sleep(Duration::from_millis(wait)).await;
|
||||||
|
attempt = attempt.saturating_add(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns Ok(true) when the server told us to stay down.
|
||||||
|
async fn connect_once(app: &AppHandle) -> Result<bool, String> {
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
let token = state
|
||||||
|
.token
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| "not signed in".to_string())?;
|
||||||
|
|
||||||
|
let url = BASE_URL.replacen("http", "ws", 1) + "/ws";
|
||||||
|
let mut request = url.into_client_request().map_err(|e| e.to_string())?;
|
||||||
|
request.headers_mut().insert(
|
||||||
|
"Authorization",
|
||||||
|
format!("Bearer {token}")
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| "bad token".to_string())?,
|
||||||
|
);
|
||||||
|
|
||||||
|
let (socket, _) = tokio_tungstenite::connect_async(request)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let (mut sink, mut stream) = socket.split();
|
||||||
|
|
||||||
|
// The client drives the heartbeat: it sits behind the NAT whose path needs
|
||||||
|
// keeping open, and it can slow down on battery without the server caring.
|
||||||
|
let mut ping = tokio::time::interval(Duration::from_secs(15));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = ping.tick() => {
|
||||||
|
if sink.send(Message::Ping(Vec::new().into())).await.is_err() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
received = stream.next() => {
|
||||||
|
let Some(received) = received else { return Ok(false) };
|
||||||
|
let message = received.map_err(|e| e.to_string())?;
|
||||||
|
match message {
|
||||||
|
Message::Text(text) => match serde_json::from_str::<Inbound>(&text) {
|
||||||
|
Ok(Inbound::Changed { conversation, seq }) => {
|
||||||
|
// The server already collapses bursts per connection,
|
||||||
|
// so there is nothing left to debounce here.
|
||||||
|
if seq > state.cursors.get(&conversation) {
|
||||||
|
if let Err(e) = backfill(app, &conversation).await {
|
||||||
|
eprintln!("realtime: backfill failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Inbound::Resync) => {
|
||||||
|
state.cursors.clear();
|
||||||
|
let _ = app.emit(EVENT_RESYNC, ());
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("realtime: undecodable frame: {e}"),
|
||||||
|
},
|
||||||
|
Message::Close(frame) => {
|
||||||
|
let code = frame.map(|f| u16::from(f.code)).unwrap_or(0);
|
||||||
|
// TOKEN_EXPIRED counts as terminal only because there is
|
||||||
|
// no refresh flow yet — reconnecting with the same dead
|
||||||
|
// token would just spin against 401.
|
||||||
|
// TODO: refresh over REST and push the new token back
|
||||||
|
// through the socket as a `reauth` frame (the server
|
||||||
|
// already accepts one), then treat 4001 as recoverable.
|
||||||
|
return Ok(matches!(
|
||||||
|
code,
|
||||||
|
CLOSE_DISPLACED | CLOSE_UNAUTHORIZED | CLOSE_TOKEN_EXPIRED
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn backfill(app: &AppHandle, conversation: &str) -> Result<(), String> {
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
let since = state.cursors.get(conversation);
|
||||||
|
|
||||||
|
let response: Changes = state
|
||||||
|
.authed_get(&format!(
|
||||||
|
"{BASE_URL}/conversations/{conversation}/changes?since={since}"
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Changes::Delta { cursor, messages } => {
|
||||||
|
state.cursors.set(conversation, cursor);
|
||||||
|
app.emit(
|
||||||
|
EVENT_CHANGED,
|
||||||
|
Changed {
|
||||||
|
conversation_id: conversation.to_string(),
|
||||||
|
messages,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Changes::Resync { cursor } => {
|
||||||
|
// Not an error — this is the normal answer on a first connection or
|
||||||
|
// after a long absence. The frontend drops its cache and reloads.
|
||||||
|
state.cursors.set(conversation, cursor);
|
||||||
|
app.emit(EVENT_RESYNC, ()).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ pub struct AppState {
|
|||||||
pub client: reqwest::Client,
|
pub client: reqwest::Client,
|
||||||
pub token: Mutex<Option<String>>,
|
pub token: Mutex<Option<String>>,
|
||||||
pub user_id: Mutex<Option<String>>,
|
pub user_id: Mutex<Option<String>>,
|
||||||
|
/// Cursor per conversation, kept here rather than in the frontend so the UI
|
||||||
|
/// has no realtime bookkeeping at all.
|
||||||
|
pub cursors: crate::realtime::Cursors,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -14,6 +17,24 @@ impl AppState {
|
|||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
token: Mutex::new(None),
|
token: Mutex::new(None),
|
||||||
user_id: Mutex::new(None),
|
user_id: Mutex::new(None),
|
||||||
|
cursors: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn authed_get(&self, url: &str) -> reqwest::RequestBuilder {
|
||||||
|
let builder = self.client.get(url);
|
||||||
|
// ponytail: omit header when no token — server may not require auth yet
|
||||||
|
match self.token.lock().unwrap().clone() {
|
||||||
|
Some(t) => builder.bearer_auth(t),
|
||||||
|
None => builder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn authed_post(&self, url: &str) -> reqwest::RequestBuilder {
|
||||||
|
let builder = self.client.post(url);
|
||||||
|
match self.token.lock().unwrap().clone() {
|
||||||
|
Some(t) => builder.bearer_auth(t),
|
||||||
|
None => builder,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/App.tsx
Normal file
14
src/App.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import LoginPage from "./pages/LoginPage";
|
||||||
|
import WorkspacePage from "./pages/WorkspacePage";
|
||||||
|
import ChatPage from "./pages/ChatPage";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<LoginPage />} />
|
||||||
|
<Route path="/workspaces" element={<WorkspacePage />} />
|
||||||
|
<Route path="/workspaces/:workspaceId" element={<ChatPage />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/components/ConversationList.tsx
Normal file
47
src/components/ConversationList.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
interface Conversation {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
kind: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
workspaceId: string;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
selectedId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConversationList({ workspaceId, onSelect, selectedId }: Props) {
|
||||||
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invoke<Conversation[]>("list_conversations", { workspaceId })
|
||||||
|
.then(setConversations)
|
||||||
|
.catch((e) => setError(String(e)));
|
||||||
|
}, [workspaceId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: 220, borderRight: "1px solid #ccc", overflowY: "auto" }}>
|
||||||
|
<div style={{ padding: "12px 16px", fontWeight: "bold", borderBottom: "1px solid #ccc" }}>
|
||||||
|
Conversations
|
||||||
|
</div>
|
||||||
|
{error && <p style={{ color: "red", padding: 8 }}>{error}</p>}
|
||||||
|
{conversations.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => onSelect(c.id)}
|
||||||
|
style={{
|
||||||
|
padding: "10px 16px",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: selectedId === c.id ? "#e8f0fe" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.name ?? `(${c.kind})`}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/components/MessageInput.tsx
Normal file
39
src/components/MessageInput.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
conversationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageInput({ conversationId }: Props) {
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
if (!content.trim()) return;
|
||||||
|
try {
|
||||||
|
await invoke("send_message", { conversationId, content });
|
||||||
|
setContent("");
|
||||||
|
setError(null);
|
||||||
|
// Nothing to trigger: the message comes back over the realtime channel.
|
||||||
|
// TODO: render it optimistically instead of waiting for the round trip —
|
||||||
|
// the client already supplies the id, which is what makes reconciling it
|
||||||
|
// possible without duplicates.
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 16, borderTop: "1px solid #ccc" }}>
|
||||||
|
{error && <p style={{ color: "red", margin: "0 0 8px" }}>{error}</p>}
|
||||||
|
<input
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSend()}
|
||||||
|
placeholder="Type a message and press Enter…"
|
||||||
|
style={{ width: "100%", padding: 10, boxSizing: "border-box", fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/components/MessageList.tsx
Normal file
86
src/components/MessageList.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
content: string;
|
||||||
|
/** Sort key. Never sort by created_at — see the Rust model for why. */
|
||||||
|
seq: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Changed {
|
||||||
|
conversation_id: string;
|
||||||
|
messages: Message[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
conversationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageList({ conversationId }: Props) {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const prevLengthRef = useRef(0);
|
||||||
|
|
||||||
|
// Full load. Also the resync path: the Rust side adopts the cursor that comes
|
||||||
|
// with the history, so the next realtime signal asks for a delta from the
|
||||||
|
// right place.
|
||||||
|
const load = () =>
|
||||||
|
invoke<Message[]>("list_messages", { conversationId })
|
||||||
|
.then(setMessages)
|
||||||
|
.catch((e) => setError(String(e)));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMessages([]);
|
||||||
|
load();
|
||||||
|
return () => { prevLengthRef.current = 0; };
|
||||||
|
}, [conversationId]);
|
||||||
|
|
||||||
|
// Realtime. Rust owns the connection and the cursor and hands over resolved
|
||||||
|
// entities, so there is nothing to compute here — replace by id, sort by seq.
|
||||||
|
useEffect(() => {
|
||||||
|
const changed = listen<Changed>("realtime://changed", (event) => {
|
||||||
|
if (event.payload.conversation_id !== conversationId) return;
|
||||||
|
setMessages((current) => {
|
||||||
|
const byId = new Map(current.map((m) => [m.id, m]));
|
||||||
|
for (const message of event.payload.messages) byId.set(message.id, message);
|
||||||
|
return [...byId.values()].sort((a, b) => a.seq - b.seq);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The server could not build a delta — throw the cache away and reload.
|
||||||
|
const resync = listen("realtime://resync", load);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
changed.then((un) => un());
|
||||||
|
resync.then((un) => un());
|
||||||
|
};
|
||||||
|
}, [conversationId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length > prevLengthRef.current) {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
prevLengthRef.current = messages.length;
|
||||||
|
}
|
||||||
|
}, [messages.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, overflowY: "auto", padding: 16 }}>
|
||||||
|
{error && <p style={{ color: "red" }}>{error}</p>}
|
||||||
|
{messages.map((m) => (
|
||||||
|
<div key={m.id} style={{ marginBottom: 12 }}>
|
||||||
|
<strong style={{ marginRight: 8 }}>{m.user_id}</strong>
|
||||||
|
<span style={{ color: "#888", fontSize: 12, marginRight: 8 }}>
|
||||||
|
{new Date(m.created_at).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
<div>{m.content}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./App";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<div>Loading…</div>
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
34
src/pages/ChatPage.tsx
Normal file
34
src/pages/ChatPage.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import ConversationList from "../components/ConversationList";
|
||||||
|
import MessageList from "../components/MessageList";
|
||||||
|
import MessageInput from "../components/MessageInput";
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
const { workspaceId } = useParams<{ workspaceId: string }>();
|
||||||
|
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", height: "100vh" }}>
|
||||||
|
<ConversationList
|
||||||
|
workspaceId={workspaceId!}
|
||||||
|
onSelect={setConversationId}
|
||||||
|
selectedId={conversationId}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
|
||||||
|
{conversationId ? (
|
||||||
|
<>
|
||||||
|
<MessageList conversationId={conversationId} />
|
||||||
|
{/* No onSent callback: the sent message arrives back through the
|
||||||
|
realtime channel like any other. */}
|
||||||
|
<MessageInput conversationId={conversationId} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#888" }}>
|
||||||
|
Select a conversation
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/pages/LoginPage.tsx
Normal file
41
src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await invoke("login", { email });
|
||||||
|
navigate("/workspaces");
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 400, margin: "100px auto", padding: 24 }}>
|
||||||
|
<h1>Rustification</h1>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleLogin()}
|
||||||
|
placeholder="Email address"
|
||||||
|
style={{ display: "block", width: "100%", marginBottom: 12, padding: 8, boxSizing: "border-box" }}
|
||||||
|
/>
|
||||||
|
<button onClick={handleLogin} disabled={loading || !email}>
|
||||||
|
{loading ? "Logging in…" : "Login"}
|
||||||
|
</button>
|
||||||
|
{error && <p style={{ color: "red" }}>{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/pages/WorkspacePage.tsx
Normal file
39
src/pages/WorkspacePage.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
interface Workspace {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkspacePage() {
|
||||||
|
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invoke<Workspace[]>("list_workspaces")
|
||||||
|
.then(setWorkspaces)
|
||||||
|
.catch((e) => setError(String(e)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 600, margin: "60px auto", padding: 24 }}>
|
||||||
|
<h1>Workspaces</h1>
|
||||||
|
{error && <p style={{ color: "red" }}>{error}</p>}
|
||||||
|
{workspaces.map((ws) => (
|
||||||
|
<div
|
||||||
|
key={ws.id}
|
||||||
|
onClick={() => navigate(`/workspaces/${ws.id}`)}
|
||||||
|
style={{ padding: 16, border: "1px solid #ccc", marginBottom: 8, cursor: "pointer", borderRadius: 4 }}
|
||||||
|
>
|
||||||
|
<strong>{ws.name}</strong>
|
||||||
|
<span style={{ marginLeft: 8, color: "#888" }}>/{ws.slug}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{workspaces.length === 0 && !error && <p>No workspaces found.</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user