mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat: pnpm workspace, deep links, and web repos page (#476)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@@ -190,6 +190,22 @@ jobs:
|
|||||||
/tmp/sprout-relay.log
|
/tmp/sprout-relay.log
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
|
|
||||||
|
web:
|
||||||
|
name: Web
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
- uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Web lint and format
|
||||||
|
run: just web-check
|
||||||
|
- name: Web build
|
||||||
|
run: just web-build
|
||||||
|
|
||||||
mobile:
|
mobile:
|
||||||
name: Mobile
|
name: Mobile
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -26,6 +26,9 @@ playwright-report/
|
|||||||
test-results/
|
test-results/
|
||||||
blob-report/
|
blob-report/
|
||||||
|
|
||||||
|
# Node modules (pnpm workspace hoists to root)
|
||||||
|
node_modules/
|
||||||
|
|
||||||
# Root npm lockfiles are accidental; desktop uses pnpm in /desktop.
|
# Root npm lockfiles are accidental; desktop uses pnpm in /desktop.
|
||||||
/package-lock.json
|
/package-lock.json
|
||||||
|
|
||||||
@@ -39,4 +42,4 @@ typesense-data/
|
|||||||
# Hermit (toolchain manager cache)
|
# Hermit (toolchain manager cache)
|
||||||
.hermit/
|
.hermit/
|
||||||
doc/
|
doc/
|
||||||
repos/
|
/repos/
|
||||||
|
|||||||
37
biome.json
Normal file
37
biome.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": false
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space"
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"suspicious": {
|
||||||
|
"noUnknownAtRules": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assist": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "double"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"css": {
|
||||||
|
"parser": {
|
||||||
|
"tailwindDirectives": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +1,3 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.11/schema.json",
|
"extends": ["../biome.json"]
|
||||||
"vcs": {
|
|
||||||
"enabled": true,
|
|
||||||
"clientKind": "git",
|
|
||||||
"useIgnoreFile": true
|
|
||||||
},
|
|
||||||
"files": {
|
|
||||||
"ignoreUnknown": false
|
|
||||||
},
|
|
||||||
"formatter": {
|
|
||||||
"enabled": true,
|
|
||||||
"indentStyle": "space"
|
|
||||||
},
|
|
||||||
"linter": {
|
|
||||||
"enabled": true,
|
|
||||||
"rules": {
|
|
||||||
"recommended": true,
|
|
||||||
"suspicious": {
|
|
||||||
"noUnknownAtRules": "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"assist": {
|
|
||||||
"enabled": false
|
|
||||||
},
|
|
||||||
"javascript": {
|
|
||||||
"formatter": {
|
|
||||||
"quoteStyle": "double"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"css": {
|
|
||||||
"parser": {
|
|
||||||
"tailwindDirectives": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.33.0",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
@@ -37,9 +36,9 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-router": "^1.168.10",
|
"@tanstack/react-router": "^1.168.10",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "~2.10",
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "~2.5",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||||
"@tiptap/core": "^3.22.3",
|
"@tiptap/core": "^3.22.3",
|
||||||
@@ -68,12 +67,11 @@
|
|||||||
"yaml": "^2.8.3"
|
"yaml": "^2.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.6",
|
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@tanstack/router-plugin": "^1.167.12",
|
"@tanstack/router-plugin": "^1.167.12",
|
||||||
"@tanstack/virtual-file-routes": "^1.161.7",
|
"@tanstack/virtual-file-routes": "^1.161.7",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "~2.10",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
|||||||
122
desktop/src-tauri/Cargo.lock
generated
122
desktop/src-tauri/Cargo.lock
generated
@@ -879,6 +879,26 @@ version = "0.10.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-random"
|
||||||
|
version = "0.1.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
|
||||||
|
dependencies = [
|
||||||
|
"const-random-macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-random-macro"
|
||||||
|
version = "0.1.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"once_cell",
|
||||||
|
"tiny-keccak",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "constant_time_eq"
|
name = "constant_time_eq"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -1071,6 +1091,12 @@ version = "0.8.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crunchy"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -1347,6 +1373,15 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dlv-list"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
|
||||||
|
dependencies = [
|
||||||
|
"const-random",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dom_query"
|
name = "dom_query"
|
||||||
version = "0.27.0"
|
version = "0.27.0"
|
||||||
@@ -2115,6 +2150,12 @@ version = "0.12.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
@@ -2320,7 +2361,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-registry",
|
"windows-registry 0.6.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3513,6 +3554,16 @@ dependencies = [
|
|||||||
"audiopus_sys",
|
"audiopus_sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ordered-multimap"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
|
||||||
|
dependencies = [
|
||||||
|
"dlv-list",
|
||||||
|
"hashbrown 0.14.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ordered-stream"
|
name = "ordered-stream"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -4578,6 +4629,16 @@ dependencies = [
|
|||||||
"windowfunctions",
|
"windowfunctions",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rust-ini"
|
||||||
|
version = "0.21.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"ordered-multimap",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.2"
|
version = "2.1.2"
|
||||||
@@ -5306,11 +5367,13 @@ dependencies = [
|
|||||||
"tar",
|
"tar",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-deep-link",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-global-shortcut",
|
"tauri-plugin-global-shortcut",
|
||||||
"tauri-plugin-notification",
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-process",
|
"tauri-plugin-process",
|
||||||
|
"tauri-plugin-single-instance",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
"tauri-plugin-websocket",
|
"tauri-plugin-websocket",
|
||||||
"tauri-plugin-window-state",
|
"tauri-plugin-window-state",
|
||||||
@@ -5869,6 +5932,27 @@ dependencies = [
|
|||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-deep-link"
|
||||||
|
version = "2.4.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa"
|
||||||
|
dependencies = [
|
||||||
|
"dunce",
|
||||||
|
"plist",
|
||||||
|
"rust-ini",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-utils",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
|
"windows-registry 0.5.3",
|
||||||
|
"windows-result 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-dialog"
|
name = "tauri-plugin-dialog"
|
||||||
version = "2.7.0"
|
version = "2.7.0"
|
||||||
@@ -5977,6 +6061,22 @@ dependencies = [
|
|||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-single-instance"
|
||||||
|
version = "2.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin-deep-link",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
"zbus",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-updater"
|
name = "tauri-plugin-updater"
|
||||||
version = "2.10.1"
|
version = "2.10.1"
|
||||||
@@ -6262,6 +6362,15 @@ dependencies = [
|
|||||||
"time-core",
|
"time-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tiny-keccak"
|
||||||
|
version = "2.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||||
|
dependencies = [
|
||||||
|
"crunchy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -7380,6 +7489,17 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-registry"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link 0.1.3",
|
||||||
|
"windows-result 0.3.4",
|
||||||
|
"windows-strings 0.4.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-registry"
|
name = "windows-registry"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
|
|||||||
atomic-write-file = "0.3"
|
atomic-write-file = "0.3"
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-deep-link = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
|
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
|
||||||
tauri-plugin-window-state = "2"
|
tauri-plugin-window-state = "2"
|
||||||
tauri-plugin-websocket = "2"
|
tauri-plugin-websocket = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ use std::sync::{
|
|||||||
};
|
};
|
||||||
use tauri::{Emitter, Manager, RunEvent};
|
use tauri::{Emitter, Manager, RunEvent};
|
||||||
use tauri_plugin_window_state::StateFlags;
|
use tauri_plugin_window_state::StateFlags;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> {
|
fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> {
|
||||||
let state = app.state::<AppState>();
|
let state = app.state::<AppState>();
|
||||||
@@ -151,9 +152,76 @@ fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle an incoming `sprout://` deep link URL.
|
||||||
|
///
|
||||||
|
/// Currently supports:
|
||||||
|
/// - `sprout://connect?relay=<ws(s)://...>` — emits `deep-link-connect` to the frontend
|
||||||
|
fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) {
|
||||||
|
let url = match Url::parse(url_str) {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("sprout-desktop: invalid deep link URL {url_str:?}: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if url.scheme() != "sprout" {
|
||||||
|
eprintln!("sprout-desktop: ignoring non-sprout deep link: {url_str}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match url.host_str() {
|
||||||
|
Some("connect") => {
|
||||||
|
let relay = url
|
||||||
|
.query_pairs()
|
||||||
|
.find(|(k, _)| k == "relay")
|
||||||
|
.map(|(_, v)| v.into_owned());
|
||||||
|
let Some(relay_url) = relay else {
|
||||||
|
eprintln!("sprout-desktop: connect deep link missing relay param: {url_str}");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Validate the relay URL is ws:// or wss://
|
||||||
|
match Url::parse(&relay_url) {
|
||||||
|
Ok(parsed) if parsed.scheme() == "ws" || parsed.scheme() == "wss" => {}
|
||||||
|
Ok(parsed) => {
|
||||||
|
eprintln!(
|
||||||
|
"sprout-desktop: rejecting non-websocket relay URL scheme {:?}: {relay_url}",
|
||||||
|
parsed.scheme()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("sprout-desktop: invalid relay URL {relay_url:?}: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = app.emit("deep-link-connect", relay_url);
|
||||||
|
}
|
||||||
|
Some(action) => {
|
||||||
|
eprintln!("sprout-desktop: unknown deep link action: {action}");
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
eprintln!("sprout-desktop: deep link missing action: {url_str}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let builder = tauri::Builder::default()
|
let builder = tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
|
||||||
|
// Focus the existing window when a duplicate instance launches.
|
||||||
|
if let Some(w) = app.get_webview_window("main") {
|
||||||
|
let _ = w.set_focus();
|
||||||
|
}
|
||||||
|
// Forward any deep link URLs from the duplicate launch.
|
||||||
|
for arg in &argv {
|
||||||
|
if arg.starts_with("sprout://") {
|
||||||
|
handle_deep_link_url(app, arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.plugin(tauri_plugin_deep_link::init())
|
||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(
|
.plugin(
|
||||||
@@ -342,6 +410,20 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle deep link URLs received while the app is running (macOS)
|
||||||
|
// and on cold start. The single-instance plugin handles forwarding
|
||||||
|
// from duplicate launches on Windows/Linux.
|
||||||
|
#[cfg(desktop)]
|
||||||
|
{
|
||||||
|
use tauri_plugin_deep_link::DeepLinkExt;
|
||||||
|
let dl_handle = app.handle().clone();
|
||||||
|
app.deep_link().on_open_url(move |event| {
|
||||||
|
for url in event.urls() {
|
||||||
|
handle_deep_link_url(&dl_handle, url.as_str());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Keep launch-time agent restoration off the synchronous setup path
|
// Keep launch-time agent restoration off the synchronous setup path
|
||||||
// so the frontend can mount and reveal the window promptly.
|
// so the frontend can mount and reveal the window promptly.
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
|||||||
@@ -36,6 +36,11 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"updater": {
|
"updater": {
|
||||||
"endpoints": []
|
"endpoints": []
|
||||||
|
},
|
||||||
|
"deep-link": {
|
||||||
|
"desktop": {
|
||||||
|
"schemes": ["sprout"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { RouterProvider } from "@tanstack/react-router";
|
import { RouterProvider } from "@tanstack/react-router";
|
||||||
import { useCallback, useLayoutEffect } from "react";
|
import { useCallback, useEffect, useLayoutEffect } from "react";
|
||||||
|
|
||||||
import { router } from "@/app/router";
|
import { router } from "@/app/router";
|
||||||
import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider";
|
import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider";
|
||||||
@@ -10,6 +10,7 @@ import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow";
|
|||||||
import { useWorkspaceInit } from "@/features/workspaces/useWorkspaceInit";
|
import { useWorkspaceInit } from "@/features/workspaces/useWorkspaceInit";
|
||||||
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
|
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
|
||||||
import { WelcomeSetup } from "@/features/workspaces/ui/WelcomeSetup";
|
import { WelcomeSetup } from "@/features/workspaces/ui/WelcomeSetup";
|
||||||
|
import { listenForDeepLinks } from "@/shared/deep-link";
|
||||||
|
|
||||||
function AppLoadingGate() {
|
function AppLoadingGate() {
|
||||||
return (
|
return (
|
||||||
@@ -56,7 +57,15 @@ export function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { activeWorkspace, reinitKey } = useWorkspaces();
|
const { activeWorkspace, reinitKey, addWorkspace, switchWorkspace } =
|
||||||
|
useWorkspaces();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unlisten = listenForDeepLinks({ addWorkspace, switchWorkspace });
|
||||||
|
return () => {
|
||||||
|
void unlisten.then((fn) => fn());
|
||||||
|
};
|
||||||
|
}, [addWorkspace, switchWorkspace]);
|
||||||
const workspace = useWorkspaceInit(activeWorkspace);
|
const workspace = useWorkspaceInit(activeWorkspace);
|
||||||
|
|
||||||
// Composite key: changes when workspace ID changes OR when
|
// Composite key: changes when workspace ID changes OR when
|
||||||
|
|||||||
35
desktop/src/shared/deep-link.ts
Normal file
35
desktop/src/shared/deep-link.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import type { Workspace } from "@/features/workspaces/types";
|
||||||
|
import {
|
||||||
|
deriveWorkspaceName,
|
||||||
|
normalizeRelayUrl,
|
||||||
|
} from "@/features/workspaces/workspaceStorage";
|
||||||
|
|
||||||
|
export interface DeepLinkDeps {
|
||||||
|
addWorkspace: (workspace: Workspace) => string;
|
||||||
|
switchWorkspace: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register listeners for deep-link events emitted by the Rust backend.
|
||||||
|
*
|
||||||
|
* When a `sprout://connect?relay=<url>` link is opened, the handler
|
||||||
|
* adds a workspace for the relay (deduplicating by URL) and switches
|
||||||
|
* to it. Returns an unlisten function to tear down all listeners.
|
||||||
|
*/
|
||||||
|
export function listenForDeepLinks(deps: DeepLinkDeps): Promise<UnlistenFn> {
|
||||||
|
return listen<string>("deep-link-connect", (event) => {
|
||||||
|
const relayUrl = normalizeRelayUrl(event.payload);
|
||||||
|
const name = deriveWorkspaceName(relayUrl);
|
||||||
|
const id = deps.addWorkspace({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name,
|
||||||
|
relayUrl,
|
||||||
|
addedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
deps.switchWorkspace(id);
|
||||||
|
toast.success(`Connected to ${name}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
23
justfile
23
justfile
@@ -58,13 +58,13 @@ fmt-check:
|
|||||||
clippy:
|
clippy:
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
|
||||||
# Install desktop JS dependencies
|
# Install JS dependencies (pnpm workspace — installs all packages from root)
|
||||||
desktop-install:
|
desktop-install:
|
||||||
cd {{desktop_dir}} && pnpm install
|
pnpm install
|
||||||
|
|
||||||
# Install desktop JS dependencies reproducibly for CI
|
# Install JS dependencies reproducibly for CI (pnpm workspace)
|
||||||
desktop-install-ci:
|
desktop-install-ci:
|
||||||
cd {{desktop_dir}} && pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Run desktop lint and format checks
|
# Run desktop lint and format checks
|
||||||
desktop-check:
|
desktop-check:
|
||||||
@@ -109,7 +109,8 @@ desktop-release-build target="aarch64-apple-darwin":
|
|||||||
touch "desktop/src-tauri/binaries/sprout-acp-$TARGET"
|
touch "desktop/src-tauri/binaries/sprout-acp-$TARGET"
|
||||||
touch "desktop/src-tauri/binaries/sprout-mcp-server-$TARGET"
|
touch "desktop/src-tauri/binaries/sprout-mcp-server-$TARGET"
|
||||||
touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
|
touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
|
||||||
cd {{desktop_dir}} && pnpm install && pnpm tauri build --target {{target}}
|
pnpm install
|
||||||
|
cd {{desktop_dir}} && pnpm tauri build --target {{target}}
|
||||||
|
|
||||||
# Run desktop checks suitable for CI / pre-push
|
# Run desktop checks suitable for CI / pre-push
|
||||||
desktop-ci: desktop-check desktop-tauri-fmt-check desktop-build desktop-tauri-check
|
desktop-ci: desktop-check desktop-tauri-fmt-check desktop-build desktop-tauri-check
|
||||||
@@ -175,9 +176,7 @@ dev *ARGS: _ensure-sidecar-stubs
|
|||||||
staging *ARGS: _ensure-sidecar-stubs
|
staging *ARGS: _ensure-sidecar-stubs
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd {{desktop_dir}}
|
|
||||||
pnpm install
|
pnpm install
|
||||||
cd ..
|
|
||||||
cargo build --release -p sprout-acp -p sprout-mcp
|
cargo build --release -p sprout-acp -p sprout-mcp
|
||||||
cd {{desktop_dir}}
|
cd {{desktop_dir}}
|
||||||
source ../scripts/instance-env.sh
|
source ../scripts/instance-env.sh
|
||||||
@@ -205,9 +204,7 @@ desktop-app *ARGS:
|
|||||||
web:
|
web:
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd {{web_dir}}
|
|
||||||
[[ -d node_modules ]] || pnpm install
|
[[ -d node_modules ]] || pnpm install
|
||||||
cd ..
|
|
||||||
source scripts/instance-env.sh
|
source scripts/instance-env.sh
|
||||||
export VITE_PORT=$((SPROUT_VITE_PORT + 100))
|
export VITE_PORT=$((SPROUT_VITE_PORT + 100))
|
||||||
export VITE_RELAY_URL="${SPROUT_RELAY_URL}"
|
export VITE_RELAY_URL="${SPROUT_RELAY_URL}"
|
||||||
@@ -215,13 +212,13 @@ web:
|
|||||||
cd {{web_dir}}
|
cd {{web_dir}}
|
||||||
pnpm exec vite --port "${VITE_PORT}" --strictPort
|
pnpm exec vite --port "${VITE_PORT}" --strictPort
|
||||||
|
|
||||||
# Install web JS dependencies
|
# Install web JS dependencies (pnpm workspace — installs all packages from root)
|
||||||
web-install:
|
web-install:
|
||||||
cd {{web_dir}} && pnpm install
|
pnpm install
|
||||||
|
|
||||||
# Install web JS dependencies reproducibly for CI
|
# Install web JS dependencies reproducibly for CI (pnpm workspace)
|
||||||
web-install-ci:
|
web-install-ci:
|
||||||
cd {{web_dir}} && pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Run web lint and format checks
|
# Run web lint and format checks
|
||||||
web-check:
|
web-check:
|
||||||
|
|||||||
11
package.json
Normal file
11
package.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "sprout-workspace",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "pnpm@10.33.0",
|
||||||
|
"scripts": {
|
||||||
|
"check": "pnpm -r check"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.4.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
1228
desktop/pnpm-lock.yaml → pnpm-lock.yaml
generated
1228
desktop/pnpm-lock.yaml → pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- "desktop"
|
||||||
|
- "web"
|
||||||
@@ -1,37 +1,3 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
"extends": ["../biome.json"]
|
||||||
"vcs": {
|
|
||||||
"enabled": true,
|
|
||||||
"clientKind": "git",
|
|
||||||
"useIgnoreFile": true
|
|
||||||
},
|
|
||||||
"files": {
|
|
||||||
"ignoreUnknown": false
|
|
||||||
},
|
|
||||||
"formatter": {
|
|
||||||
"enabled": true,
|
|
||||||
"indentStyle": "space"
|
|
||||||
},
|
|
||||||
"linter": {
|
|
||||||
"enabled": true,
|
|
||||||
"rules": {
|
|
||||||
"recommended": true,
|
|
||||||
"suspicious": {
|
|
||||||
"noUnknownAtRules": "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"assist": {
|
|
||||||
"enabled": false
|
|
||||||
},
|
|
||||||
"javascript": {
|
|
||||||
"formatter": {
|
|
||||||
"quoteStyle": "double"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"css": {
|
|
||||||
"parser": {
|
|
||||||
"tailwindDirectives": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.33.0",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
@@ -24,13 +23,13 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
|
"nostr-tools": "^2.23.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0"
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.6",
|
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@tanstack/router-plugin": "^1.167.12",
|
"@tanstack/router-plugin": "^1.167.12",
|
||||||
"@tanstack/virtual-file-routes": "^1.161.7",
|
"@tanstack/virtual-file-routes": "^1.161.7",
|
||||||
|
|||||||
2586
web/pnpm-lock.yaml
generated
2586
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,14 @@
|
|||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from "./routes/root";
|
import { Route as rootRouteImport } from "./routes/root";
|
||||||
|
import { Route as reposRouteImport } from "./routes/repos";
|
||||||
import { Route as indexRouteImport } from "./routes/index";
|
import { Route as indexRouteImport } from "./routes/index";
|
||||||
|
|
||||||
|
const reposRoute = reposRouteImport.update({
|
||||||
|
id: "/repos",
|
||||||
|
path: "/repos",
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any);
|
||||||
const indexRoute = indexRouteImport.update({
|
const indexRoute = indexRouteImport.update({
|
||||||
id: "/",
|
id: "/",
|
||||||
path: "/",
|
path: "/",
|
||||||
@@ -15,28 +21,39 @@ const indexRoute = indexRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
"/": typeof indexRoute;
|
"/": typeof indexRoute;
|
||||||
|
"/repos": typeof reposRoute;
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
"/": typeof indexRoute;
|
"/": typeof indexRoute;
|
||||||
|
"/repos": typeof reposRoute;
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport;
|
__root__: typeof rootRouteImport;
|
||||||
"/": typeof indexRoute;
|
"/": typeof indexRoute;
|
||||||
|
"/repos": typeof reposRoute;
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath;
|
fileRoutesByFullPath: FileRoutesByFullPath;
|
||||||
fullPaths: "/";
|
fullPaths: "/" | "/repos";
|
||||||
fileRoutesByTo: FileRoutesByTo;
|
fileRoutesByTo: FileRoutesByTo;
|
||||||
to: "/";
|
to: "/" | "/repos";
|
||||||
id: "__root__" | "/";
|
id: "__root__" | "/" | "/repos";
|
||||||
fileRoutesById: FileRoutesById;
|
fileRoutesById: FileRoutesById;
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
indexRoute: typeof indexRoute;
|
indexRoute: typeof indexRoute;
|
||||||
|
reposRoute: typeof reposRoute;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "@tanstack/react-router" {
|
declare module "@tanstack/react-router" {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
"/repos": {
|
||||||
|
id: "/repos";
|
||||||
|
path: "/repos";
|
||||||
|
fullPath: "/repos";
|
||||||
|
preLoaderRoute: typeof reposRouteImport;
|
||||||
|
parentRoute: typeof rootRouteImport;
|
||||||
|
};
|
||||||
"/": {
|
"/": {
|
||||||
id: "/";
|
id: "/";
|
||||||
path: "/";
|
path: "/";
|
||||||
@@ -49,6 +66,7 @@ declare module "@tanstack/react-router" {
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
indexRoute: indexRoute,
|
indexRoute: indexRoute,
|
||||||
|
reposRoute: reposRoute,
|
||||||
};
|
};
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
import { index, rootRoute } from "@tanstack/virtual-file-routes";
|
import { index, route, rootRoute } from "@tanstack/virtual-file-routes";
|
||||||
|
|
||||||
export const routes = rootRoute("root.tsx", [index("index.tsx")]);
|
export const routes = rootRoute("root.tsx", [
|
||||||
|
index("index.tsx"),
|
||||||
|
route("/repos", "repos.tsx"),
|
||||||
|
]);
|
||||||
|
|||||||
6
web/src/app/routes/repos.tsx
Normal file
6
web/src/app/routes/repos.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { ReposPage } from "@/features/repos/ui/ReposPage";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/repos")({
|
||||||
|
component: ReposPage,
|
||||||
|
});
|
||||||
@@ -1,15 +1,34 @@
|
|||||||
import { Outlet, createRootRoute } from "@tanstack/react-router";
|
import { Link, Outlet, createRootRoute } from "@tanstack/react-router";
|
||||||
import { ThemeToggle } from "@/shared/theme/ThemeToggle";
|
import { ThemeToggle } from "@/shared/theme/ThemeToggle";
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className="text-sm text-muted-foreground transition-colors hover:text-foreground [&.active]:font-medium [&.active]:text-foreground"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh flex-col">
|
<div className="flex min-h-dvh flex-col">
|
||||||
<header className="flex h-12 items-center justify-between border-b px-4">
|
<header className="flex h-12 items-center justify-between border-b px-4">
|
||||||
<span className="text-sm font-semibold tracking-tight">Sprout</span>
|
<nav className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="text-sm font-semibold tracking-tight text-foreground"
|
||||||
|
>
|
||||||
|
Sprout
|
||||||
|
</Link>
|
||||||
|
<NavLink to="/repos">Repos</NavLink>
|
||||||
|
</nav>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</header>
|
</header>
|
||||||
<main className="flex flex-1 flex-col">
|
<main className="flex flex-1 flex-col">
|
||||||
|
|||||||
130
web/src/features/repos/ui/RepoCard.tsx
Normal file
130
web/src/features/repos/ui/RepoCard.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { Check, Copy, ExternalLink, GitBranch } from "lucide-react";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { relayWsUrl } from "@/shared/lib/relay-url";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/ui/card";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||||
|
import type { Repo } from "../use-repos";
|
||||||
|
|
||||||
|
function truncateHex(hex: string): string {
|
||||||
|
if (hex.length <= 12) return hex;
|
||||||
|
return `${hex.slice(0, 8)}...${hex.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(unix: number): string {
|
||||||
|
return new Date(unix * 1000).toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyButton({ value, label }: { value: string; label: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(() => {
|
||||||
|
navigator.clipboard.writeText(value).then(
|
||||||
|
() => {
|
||||||
|
setCopied(true);
|
||||||
|
toast.success("Copied to clipboard");
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
toast.error("Failed to copy to clipboard");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={handleCopy}
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-3.5 w-3.5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Copy</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepoCard({ repo }: { repo: Repo }) {
|
||||||
|
const relayUrl = relayWsUrl();
|
||||||
|
const deepLink = `sprout://connect?relay=${encodeURIComponent(relayUrl)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-col">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<GitBranch className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<CardTitle className="text-base">{repo.name}</CardTitle>
|
||||||
|
{repo.description && (
|
||||||
|
<CardDescription className="mt-1 line-clamp-2">
|
||||||
|
{repo.description}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex-1 space-y-3 text-sm">
|
||||||
|
{repo.cloneUrls.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
Clone
|
||||||
|
</span>
|
||||||
|
{repo.cloneUrls.map((url) => (
|
||||||
|
<div key={url} className="flex items-center gap-1">
|
||||||
|
<code className="min-w-0 flex-1 truncate rounded bg-muted px-2 py-1 font-mono text-xs text-muted-foreground">
|
||||||
|
{url}
|
||||||
|
</code>
|
||||||
|
<CopyButton value={url} label={`Copy clone URL: ${url}`} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="cursor-default font-mono">
|
||||||
|
{truncateHex(repo.owner)}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{repo.owner}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<span>{formatDate(repo.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="gap-2 border-t pt-4">
|
||||||
|
<Button asChild size="sm" className="flex-1">
|
||||||
|
<a href={deepLink} aria-label={`Open ${repo.name} in Sprout`}>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
Open in Sprout
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<CopyButton value={relayUrl} label="Copy relay URL" />
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
web/src/features/repos/ui/ReposPage.tsx
Normal file
103
web/src/features/repos/ui/ReposPage.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { GitBranch } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
|
||||||
|
import { useRepos } from "../use-repos";
|
||||||
|
import { RepoCard } from "./RepoCard";
|
||||||
|
|
||||||
|
function RepoCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-col">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="mt-0.5 h-4 w-4 shrink-0 animate-pulse rounded bg-muted" />
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<div className="h-5 w-2/3 animate-pulse rounded bg-muted" />
|
||||||
|
<div className="h-4 w-full animate-pulse rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="h-3 w-12 animate-pulse rounded bg-muted" />
|
||||||
|
<div className="h-7 w-full animate-pulse rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="h-3 w-20 animate-pulse rounded bg-muted" />
|
||||||
|
<div className="h-3 w-16 animate-pulse rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<div className="flex gap-2 border-t p-6 pt-4">
|
||||||
|
<div className="h-8 flex-1 animate-pulse rounded-md bg-muted" />
|
||||||
|
<div className="h-8 w-8 animate-pulse rounded-md bg-muted" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||||
|
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-muted">
|
||||||
|
<GitBranch className="h-7 w-7 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-lg font-semibold">No repositories yet</h2>
|
||||||
|
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||||
|
Repositories published to this relay will appear here. Push a git repo
|
||||||
|
using the Sprout desktop app to get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReposPage() {
|
||||||
|
const { data: repos, isLoading, error } = useRepos();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
toast.error("Failed to load repositories", {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-4 py-8">
|
||||||
|
<h1 className="mb-6 text-2xl font-semibold tracking-tight">
|
||||||
|
Repositories
|
||||||
|
</h1>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{["a", "b", "c", "d", "e", "f"].map((key) => (
|
||||||
|
<RepoCardSkeleton key={key} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!repos || repos.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-4 py-8">
|
||||||
|
<h1 className="mb-6 text-2xl font-semibold tracking-tight">
|
||||||
|
Repositories
|
||||||
|
</h1>
|
||||||
|
<EmptyState />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-4 py-8">
|
||||||
|
<h1 className="mb-6 text-2xl font-semibold tracking-tight">
|
||||||
|
Repositories
|
||||||
|
</h1>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{repos.map((repo) => (
|
||||||
|
<RepoCard key={repo.id} repo={repo} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
web/src/features/repos/use-repos.ts
Normal file
71
web/src/features/repos/use-repos.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { queryEvents, type NostrEvent } from "@/shared/lib/nostr-client";
|
||||||
|
import { relayWsUrl } from "@/shared/lib/relay-url";
|
||||||
|
|
||||||
|
export interface Repo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
cloneUrls: string[];
|
||||||
|
webUrl: string | null;
|
||||||
|
owner: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the first value for a given tag name from a Nostr event. */
|
||||||
|
function getTag(event: NostrEvent, name: string): string | undefined {
|
||||||
|
return event.tags.find((t) => t[0] === name)?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract all values for a given tag name from a Nostr event. */
|
||||||
|
function getAllTags(event: NostrEvent, name: string): string[] {
|
||||||
|
return event.tags.filter((t) => t[0] === name).map((t) => t[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventToRepo(event: NostrEvent): Repo {
|
||||||
|
const d = getTag(event, "d") ?? event.id;
|
||||||
|
const name = getTag(event, "name") || d;
|
||||||
|
const description = getTag(event, "description") || event.content || "";
|
||||||
|
const cloneUrls = getAllTags(event, "clone");
|
||||||
|
const webUrl = getTag(event, "web") ?? null;
|
||||||
|
const owner = getAllTags(event, "p")[0] ?? event.pubkey;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: d,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
cloneUrls,
|
||||||
|
webUrl,
|
||||||
|
owner,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deduplicate NIP-33 parameterized replaceable events, keeping the latest per (pubkey, kind, d-tag). */
|
||||||
|
function dedup(events: NostrEvent[]): NostrEvent[] {
|
||||||
|
const best = new Map<string, NostrEvent>();
|
||||||
|
for (const e of events) {
|
||||||
|
const d = getTag(e, "d") ?? "";
|
||||||
|
const key = `${e.pubkey}:${e.kind}:${d}`;
|
||||||
|
const prev = best.get(key);
|
||||||
|
if (!prev || e.created_at > prev.created_at) {
|
||||||
|
best.set(key, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...best.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRepos(): Promise<Repo[]> {
|
||||||
|
const events = await queryEvents(relayWsUrl(), { kinds: [30617] });
|
||||||
|
return dedup(events)
|
||||||
|
.map(eventToRepo)
|
||||||
|
.sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRepos() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["repos"],
|
||||||
|
queryFn: fetchRepos,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
158
web/src/shared/lib/nostr-client.ts
Normal file
158
web/src/shared/lib/nostr-client.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Minimal Nostr client with NIP-01 queries and NIP-42 AUTH.
|
||||||
|
*
|
||||||
|
* Generates an ephemeral keypair per session to authenticate with the relay.
|
||||||
|
* This is sufficient for read-only public queries (e.g. repo listings).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { generateSecretKey, finalizeEvent } from "nostr-tools/pure";
|
||||||
|
import { makeAuthEvent } from "nostr-tools/nip42";
|
||||||
|
|
||||||
|
export interface NostrFilter {
|
||||||
|
ids?: string[];
|
||||||
|
authors?: string[];
|
||||||
|
kinds?: number[];
|
||||||
|
since?: number;
|
||||||
|
until?: number;
|
||||||
|
limit?: number;
|
||||||
|
[tag: `#${string}`]: string[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NostrEvent {
|
||||||
|
id: string;
|
||||||
|
pubkey: string;
|
||||||
|
kind: number;
|
||||||
|
tags: string[][];
|
||||||
|
content: string;
|
||||||
|
created_at: number;
|
||||||
|
sig: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUERY_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
/** Lazily-generated ephemeral keypair for NIP-42 AUTH. */
|
||||||
|
let _secretKey: Uint8Array | null = null;
|
||||||
|
function getEphemeralKey(): Uint8Array {
|
||||||
|
if (!_secretKey) {
|
||||||
|
_secretKey = generateSecretKey();
|
||||||
|
}
|
||||||
|
return _secretKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a WebSocket to `wsUrl`, authenticate via NIP-42 if challenged,
|
||||||
|
* send a REQ with the given filter, collect EVENTs until EOSE, then
|
||||||
|
* close and return them.
|
||||||
|
*/
|
||||||
|
export function queryEvents(
|
||||||
|
wsUrl: string,
|
||||||
|
filter: NostrFilter,
|
||||||
|
): Promise<NostrEvent[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const events: NostrEvent[] = [];
|
||||||
|
const subId = `q-${Date.now().toString(36)}`;
|
||||||
|
let settled = false;
|
||||||
|
let reqSent = false;
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
ws.close();
|
||||||
|
reject(new Error(`Relay query timed out after ${QUERY_TIMEOUT_MS}ms`));
|
||||||
|
}
|
||||||
|
}, QUERY_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendReq = () => {
|
||||||
|
if (!reqSent) {
|
||||||
|
reqSent = true;
|
||||||
|
ws.send(JSON.stringify(["REQ", subId, filter]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.addEventListener("open", () => {
|
||||||
|
// Wait briefly for an AUTH challenge before sending REQ.
|
||||||
|
// Sprout relays always send AUTH, but other relays may not.
|
||||||
|
setTimeout(() => sendReq(), 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener("message", (msg) => {
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(String(msg.data));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(data)) return;
|
||||||
|
|
||||||
|
const [type] = data;
|
||||||
|
|
||||||
|
if (type === "AUTH" && typeof data[1] === "string") {
|
||||||
|
// NIP-42: relay sent an AUTH challenge — sign and respond.
|
||||||
|
const challenge = data[1];
|
||||||
|
const sk = getEphemeralKey();
|
||||||
|
const template = makeAuthEvent(wsUrl, challenge);
|
||||||
|
const signed = finalizeEvent(template, sk);
|
||||||
|
ws.send(JSON.stringify(["AUTH", signed]));
|
||||||
|
// After AUTH, send our REQ.
|
||||||
|
sendReq();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "OK") {
|
||||||
|
// AUTH response accepted (or rejected). If we haven't sent REQ yet, do so now.
|
||||||
|
sendReq();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "EVENT" && data[1] === subId && data[2]) {
|
||||||
|
events.push(data[2] as NostrEvent);
|
||||||
|
} else if (type === "EOSE" && data[1] === subId) {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
resolve(events);
|
||||||
|
}
|
||||||
|
} else if (type === "CLOSED" && data[1] === subId) {
|
||||||
|
// Subscription was rejected (e.g. auth failed).
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
const reason =
|
||||||
|
typeof data[2] === "string"
|
||||||
|
? data[2]
|
||||||
|
: "subscription closed by relay";
|
||||||
|
reject(new Error(reason));
|
||||||
|
}
|
||||||
|
} else if (type === "NOTICE") {
|
||||||
|
// Informational notice from relay — ignore for now.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener("error", () => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("WebSocket connection failed"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener("close", () => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(events);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
20
web/src/shared/lib/relay-url.ts
Normal file
20
web/src/shared/lib/relay-url.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/** Convert a WebSocket relay URL to its HTTP equivalent. */
|
||||||
|
export function relayHttpUrl(wsUrl: string): string {
|
||||||
|
if (wsUrl.startsWith("wss://")) {
|
||||||
|
return `https://${wsUrl.slice(6)}`;
|
||||||
|
}
|
||||||
|
if (wsUrl.startsWith("ws://")) {
|
||||||
|
return `http://${wsUrl.slice(5)}`;
|
||||||
|
}
|
||||||
|
return wsUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the relay WebSocket URL from environment or fall back to localhost. */
|
||||||
|
export function relayWsUrl(): string {
|
||||||
|
return import.meta.env.VITE_RELAY_URL || "ws://localhost:3000";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTTP base URL for the relay (derived from the WS URL). */
|
||||||
|
export function relayHttpBaseUrl(): string {
|
||||||
|
return relayHttpUrl(relayWsUrl());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user