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:
Wes
2026-05-04 17:09:25 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 32e97b34d1
commit 4d0cd58d35
27 changed files with 1517 additions and 3276 deletions
+16
View File
@@ -190,6 +190,22 @@ jobs:
/tmp/sprout-relay.log
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:
name: Mobile
runs-on: ubuntu-latest
+4 -1
View File
@@ -26,6 +26,9 @@ playwright-report/
test-results/
blob-report/
# Node modules (pnpm workspace hoists to root)
node_modules/
# Root npm lockfiles are accidental; desktop uses pnpm in /desktop.
/package-lock.json
@@ -39,4 +42,4 @@ typesense-data/
# Hermit (toolchain manager cache)
.hermit/
doc/
repos/
/repos/
+37
View 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 -35
View File
@@ -1,37 +1,3 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.11/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
}
}
"extends": ["../biome.json"]
}
+3 -5
View File
@@ -3,7 +3,6 @@
"private": true,
"version": "0.1.0",
"type": "module",
"packageManager": "pnpm@10.33.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -37,9 +36,9 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.21",
"@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-opener": "^2",
"@tauri-apps/plugin-opener": "~2.5",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.0",
"@tiptap/core": "^3.22.3",
@@ -68,12 +67,11 @@
"yaml": "^2.8.3"
},
"devDependencies": {
"@biomejs/biome": "^2.4.6",
"@noble/hashes": "^2.0.1",
"@playwright/test": "^1.58.2",
"@tanstack/router-plugin": "^1.167.12",
"@tanstack/virtual-file-routes": "^1.161.7",
"@tauri-apps/cli": "^2",
"@tauri-apps/cli": "~2.10",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
+121 -1
View File
@@ -879,6 +879,26 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "constant_time_eq"
version = "0.3.1"
@@ -1071,6 +1091,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -1347,6 +1373,15 @@ dependencies = [
"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]]
name = "dom_query"
version = "0.27.0"
@@ -2115,6 +2150,12 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -2320,7 +2361,7 @@ dependencies = [
"tokio",
"tower-service",
"tracing",
"windows-registry",
"windows-registry 0.6.1",
]
[[package]]
@@ -3513,6 +3554,16 @@ dependencies = [
"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]]
name = "ordered-stream"
version = "0.2.0"
@@ -4578,6 +4629,16 @@ dependencies = [
"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]]
name = "rustc-hash"
version = "2.1.2"
@@ -5306,11 +5367,13 @@ dependencies = [
"tar",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-global-shortcut",
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tauri-plugin-websocket",
"tauri-plugin-window-state",
@@ -5869,6 +5932,27 @@ dependencies = [
"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]]
name = "tauri-plugin-dialog"
version = "2.7.0"
@@ -5977,6 +6061,22 @@ dependencies = [
"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]]
name = "tauri-plugin-updater"
version = "2.10.1"
@@ -6262,6 +6362,15 @@ dependencies = [
"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]]
name = "tinystr"
version = "0.8.3"
@@ -7380,6 +7489,17 @@ dependencies = [
"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]]
name = "windows-registry"
version = "0.6.1"
+2
View File
@@ -27,7 +27,9 @@ windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
atomic-write-file = "0.3"
dirs = "6"
tauri = { version = "2", features = [] }
tauri-plugin-deep-link = "2"
tauri-plugin-opener = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
tauri-plugin-window-state = "2"
tauri-plugin-websocket = "2"
tauri-plugin-dialog = "2"
+82
View File
@@ -31,6 +31,7 @@ use std::sync::{
};
use tauri::{Emitter, Manager, RunEvent};
use tauri_plugin_window_state::StateFlags;
use url::Url;
fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> {
let state = app.state::<AppState>();
@@ -151,9 +152,76 @@ fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> {
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)]
pub fn run() {
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_opener::init())
.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
// so the frontend can mount and reveal the window promptly.
tauri::async_runtime::spawn_blocking(move || {
+5
View File
@@ -36,6 +36,11 @@
"plugins": {
"updater": {
"endpoints": []
},
"deep-link": {
"desktop": {
"schemes": ["sprout"]
}
}
},
"bundle": {
+11 -2
View File
@@ -1,7 +1,7 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useQueryClient } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
import { useCallback, useLayoutEffect } from "react";
import { useCallback, useEffect, useLayoutEffect } from "react";
import { router } from "@/app/router";
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 { useWorkspaces } from "@/features/workspaces/useWorkspaces";
import { WelcomeSetup } from "@/features/workspaces/ui/WelcomeSetup";
import { listenForDeepLinks } from "@/shared/deep-link";
function AppLoadingGate() {
return (
@@ -56,7 +57,15 @@ export function App() {
}, []);
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);
// Composite key: changes when workspace ID changes OR when
+35
View 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}`);
});
}
+10 -13
View File
@@ -58,13 +58,13 @@ fmt-check:
clippy:
cargo clippy --workspace --all-targets -- -D warnings
# Install desktop JS dependencies
# Install JS dependencies (pnpm workspace — installs all packages from root)
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:
cd {{desktop_dir}} && pnpm install --frozen-lockfile
pnpm install --frozen-lockfile
# Run desktop lint and format checks
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-mcp-server-$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
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
#!/usr/bin/env bash
set -euo pipefail
cd {{desktop_dir}}
pnpm install
cd ..
cargo build --release -p sprout-acp -p sprout-mcp
cd {{desktop_dir}}
source ../scripts/instance-env.sh
@@ -205,9 +204,7 @@ desktop-app *ARGS:
web:
#!/usr/bin/env bash
set -euo pipefail
cd {{web_dir}}
[[ -d node_modules ]] || pnpm install
cd ..
source scripts/instance-env.sh
export VITE_PORT=$((SPROUT_VITE_PORT + 100))
export VITE_RELAY_URL="${SPROUT_RELAY_URL}"
@@ -215,13 +212,13 @@ web:
cd {{web_dir}}
pnpm exec vite --port "${VITE_PORT}" --strictPort
# Install web JS dependencies
# Install web JS dependencies (pnpm workspace — installs all packages from root)
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:
cd {{web_dir}} && pnpm install --frozen-lockfile
pnpm install --frozen-lockfile
# Run web lint and format checks
web-check:
+11
View 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"
}
}
+639 -589
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
packages:
- "desktop"
- "web"
+1 -35
View File
@@ -1,37 +1,3 @@
{
"$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
}
}
"extends": ["../biome.json"]
}
+1 -2
View File
@@ -3,7 +3,6 @@
"private": true,
"version": "0.1.0",
"type": "module",
"packageManager": "pnpm@10.33.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -24,13 +23,13 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"nostr-tools": "^2.23.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@biomejs/biome": "^2.4.6",
"@playwright/test": "^1.58.2",
"@tanstack/router-plugin": "^1.167.12",
"@tanstack/virtual-file-routes": "^1.161.7",
-2586
View File
File diff suppressed because it is too large Load Diff
+21 -3
View File
@@ -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.
import { Route as rootRouteImport } from "./routes/root";
import { Route as reposRouteImport } from "./routes/repos";
import { Route as indexRouteImport } from "./routes/index";
const reposRoute = reposRouteImport.update({
id: "/repos",
path: "/repos",
getParentRoute: () => rootRouteImport,
} as any);
const indexRoute = indexRouteImport.update({
id: "/",
path: "/",
@@ -15,28 +21,39 @@ const indexRoute = indexRouteImport.update({
export interface FileRoutesByFullPath {
"/": typeof indexRoute;
"/repos": typeof reposRoute;
}
export interface FileRoutesByTo {
"/": typeof indexRoute;
"/repos": typeof reposRoute;
}
export interface FileRoutesById {
__root__: typeof rootRouteImport;
"/": typeof indexRoute;
"/repos": typeof reposRoute;
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath;
fullPaths: "/";
fullPaths: "/" | "/repos";
fileRoutesByTo: FileRoutesByTo;
to: "/";
id: "__root__" | "/";
to: "/" | "/repos";
id: "__root__" | "/" | "/repos";
fileRoutesById: FileRoutesById;
}
export interface RootRouteChildren {
indexRoute: typeof indexRoute;
reposRoute: typeof reposRoute;
}
declare module "@tanstack/react-router" {
interface FileRoutesByPath {
"/repos": {
id: "/repos";
path: "/repos";
fullPath: "/repos";
preLoaderRoute: typeof reposRouteImport;
parentRoute: typeof rootRouteImport;
};
"/": {
id: "/";
path: "/";
@@ -49,6 +66,7 @@ declare module "@tanstack/react-router" {
const rootRouteChildren: RootRouteChildren = {
indexRoute: indexRoute,
reposRoute: reposRoute,
};
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
+5 -2
View File
@@ -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
View 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,
});
+21 -2
View File
@@ -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";
export const Route = createRootRoute({
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() {
return (
<div className="flex min-h-dvh flex-col">
<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 />
</header>
<main className="flex flex-1 flex-col">
+130
View 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
View 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
View 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
View 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
View 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());
}