mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add multi-workspace support to desktop app (#409)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
export type Workspace = {
|
||||
id: string;
|
||||
name: string;
|
||||
relayUrl: string;
|
||||
token?: string;
|
||||
nsec?: string;
|
||||
pubkey?: string;
|
||||
addedAt: string;
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { Workspace } from "@/features/workspaces/types";
|
||||
import {
|
||||
deriveWorkspaceName,
|
||||
normalizeRelayUrl,
|
||||
} from "@/features/workspaces/workspaceStorage";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
|
||||
type AddWorkspaceDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (workspace: Workspace) => void;
|
||||
};
|
||||
|
||||
export function AddWorkspaceDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: AddWorkspaceDialogProps) {
|
||||
const [name, setName] = React.useState("");
|
||||
const [relayUrl, setRelayUrl] = React.useState("");
|
||||
const [token, setToken] = React.useState("");
|
||||
const [nsec, setNsec] = React.useState("");
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
onOpenChange(false);
|
||||
setName("");
|
||||
setRelayUrl("");
|
||||
setToken("");
|
||||
setNsec("");
|
||||
}, [onOpenChange]);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!relayUrl.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspace: Workspace = {
|
||||
id: crypto.randomUUID(),
|
||||
name: name.trim() || deriveWorkspaceName(relayUrl.trim()),
|
||||
relayUrl: normalizeRelayUrl(relayUrl.trim()),
|
||||
token: token.trim() || undefined,
|
||||
nsec: nsec.trim() || undefined,
|
||||
addedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
onSubmit(workspace);
|
||||
handleClose();
|
||||
},
|
||||
[name, relayUrl, token, nsec, onSubmit, handleClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Workspace</DialogTitle>
|
||||
<DialogDescription>
|
||||
Connect to another Sprout relay. Each workspace has its own
|
||||
channels, messages, and identity.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="ws-relay-url"
|
||||
>
|
||||
Relay URL
|
||||
</label>
|
||||
<Input
|
||||
autoFocus
|
||||
id="ws-relay-url"
|
||||
onChange={(e) => setRelayUrl(e.target.value)}
|
||||
placeholder="wss://relay.example.com"
|
||||
type="text"
|
||||
value={relayUrl}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="ws-name"
|
||||
>
|
||||
Name
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
id="ws-name"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Workspace"
|
||||
type="text"
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="ws-token"
|
||||
>
|
||||
API Token
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
id="ws-token"
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="sprout_..."
|
||||
type="password"
|
||||
value={token}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="ws-nsec"
|
||||
>
|
||||
Private Key (nsec)
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">
|
||||
(optional — uses current identity if blank)
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
id="ws-nsec"
|
||||
onChange={(e) => setNsec(e.target.value)}
|
||||
placeholder="nsec1..."
|
||||
type="password"
|
||||
value={nsec}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button onClick={handleClose} type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!relayUrl.trim()} type="submit">
|
||||
Add Workspace
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { Workspace } from "@/features/workspaces/types";
|
||||
import { normalizeRelayUrl } from "@/features/workspaces/workspaceStorage";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
|
||||
type EditWorkspaceDialogProps = {
|
||||
workspace: Workspace | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (
|
||||
id: string,
|
||||
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">>,
|
||||
) => void;
|
||||
onRemove?: (id: string) => void;
|
||||
canRemove?: boolean;
|
||||
};
|
||||
|
||||
export function EditWorkspaceDialog({
|
||||
workspace,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
onRemove,
|
||||
canRemove,
|
||||
}: EditWorkspaceDialogProps) {
|
||||
const [name, setName] = React.useState("");
|
||||
const [relayUrl, setRelayUrl] = React.useState("");
|
||||
const [token, setToken] = React.useState("");
|
||||
|
||||
// Sync form state when the dialog opens with a workspace
|
||||
React.useEffect(() => {
|
||||
if (workspace && open) {
|
||||
setName(workspace.name);
|
||||
setRelayUrl(workspace.relayUrl);
|
||||
setToken(workspace.token ?? "");
|
||||
}
|
||||
}, [workspace, open]);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!workspace || !relayUrl.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">> =
|
||||
{};
|
||||
|
||||
const trimmedName = name.trim();
|
||||
if (trimmedName && trimmedName !== workspace.name) {
|
||||
updates.name = trimmedName;
|
||||
}
|
||||
|
||||
const normalizedUrl = normalizeRelayUrl(relayUrl.trim());
|
||||
if (normalizedUrl !== workspace.relayUrl) {
|
||||
updates.relayUrl = normalizedUrl;
|
||||
}
|
||||
|
||||
const trimmedToken = token.trim() || undefined;
|
||||
if (trimmedToken !== workspace.token) {
|
||||
updates.token = trimmedToken;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
onSave(workspace.id, updates);
|
||||
}
|
||||
|
||||
handleClose();
|
||||
},
|
||||
[workspace, name, relayUrl, token, onSave, handleClose],
|
||||
);
|
||||
|
||||
const handleRemove = React.useCallback(() => {
|
||||
if (workspace && onRemove) {
|
||||
onRemove(workspace.id);
|
||||
handleClose();
|
||||
}
|
||||
}, [workspace, onRemove, handleClose]);
|
||||
|
||||
if (!workspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Workspace</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update this workspace's name or relay URL.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="edit-ws-name"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
autoFocus
|
||||
id="edit-ws-name"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Workspace"
|
||||
type="text"
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="edit-ws-relay-url"
|
||||
>
|
||||
Relay URL
|
||||
</label>
|
||||
<Input
|
||||
id="edit-ws-relay-url"
|
||||
onChange={(e) => setRelayUrl(e.target.value)}
|
||||
placeholder="wss://relay.example.com"
|
||||
type="text"
|
||||
value={relayUrl}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="edit-ws-token"
|
||||
>
|
||||
API Token
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
id="edit-ws-token"
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="sprout_..."
|
||||
type="password"
|
||||
value={token}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div>
|
||||
{canRemove && onRemove ? (
|
||||
<Button
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={handleRemove}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Remove Workspace
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleClose} type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!name.trim() || !relayUrl.trim()} type="submit">
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { getIdentity, getNsec } from "@/shared/api/tauri";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
|
||||
import type { Workspace } from "../types";
|
||||
import {
|
||||
deriveWorkspaceName,
|
||||
normalizeRelayUrl,
|
||||
saveActiveWorkspaceId,
|
||||
saveWorkspaces,
|
||||
} from "../workspaceStorage";
|
||||
|
||||
const LOCAL_RELAY_URL = "ws://localhost:3000";
|
||||
|
||||
type WelcomeSetupProps = {
|
||||
defaultRelayUrl: string;
|
||||
onComplete: () => void;
|
||||
};
|
||||
|
||||
export function WelcomeSetup({
|
||||
defaultRelayUrl,
|
||||
onComplete,
|
||||
}: WelcomeSetupProps) {
|
||||
const isInternalBuild = defaultRelayUrl !== LOCAL_RELAY_URL;
|
||||
const [relayUrl, setRelayUrl] = React.useState(defaultRelayUrl);
|
||||
const [isConnecting, setIsConnecting] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const handleConnect = React.useCallback(async () => {
|
||||
const trimmedUrl = relayUrl.trim();
|
||||
if (!trimmedUrl) {
|
||||
setError("Please enter a relay URL.");
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedUrl = normalizeRelayUrl(trimmedUrl);
|
||||
setIsConnecting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [identity, nsec] = await Promise.all([getIdentity(), getNsec()]);
|
||||
|
||||
const workspace: Workspace = {
|
||||
id: crypto.randomUUID(),
|
||||
name: deriveWorkspaceName(normalizedUrl),
|
||||
relayUrl: normalizedUrl,
|
||||
nsec,
|
||||
pubkey: identity.pubkey,
|
||||
addedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
saveWorkspaces([workspace]);
|
||||
saveActiveWorkspaceId(workspace.id);
|
||||
|
||||
// The reload triggered by onComplete() will re-run useWorkspaceInit,
|
||||
// which calls applyWorkspace with the saved config. No need to apply here.
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to connect. Try again.",
|
||||
);
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [relayUrl, onComplete]);
|
||||
|
||||
const workspaceName = React.useMemo(
|
||||
() => deriveWorkspaceName(relayUrl.trim() || LOCAL_RELAY_URL),
|
||||
[relayUrl],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-[radial-gradient(circle_at_top,hsl(var(--primary)/0.14),transparent_48%),linear-gradient(180deg,hsl(var(--background)),hsl(var(--muted)/0.55))] px-4 py-8">
|
||||
<div className="w-full max-w-sm rounded-[28px] border border-border/70 bg-background/92 p-8 shadow-2xl backdrop-blur">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Sprout
|
||||
</p>
|
||||
<h1 className="mt-3 text-2xl font-semibold tracking-tight text-foreground">
|
||||
Welcome
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
{isInternalBuild
|
||||
? "Connect to your workspace to get started."
|
||||
: "Running a local relay? Connect now. Or enter a custom relay URL."}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
{!isInternalBuild ? (
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
htmlFor="relay-url"
|
||||
>
|
||||
Relay URL
|
||||
</label>
|
||||
<Input
|
||||
id="relay-url"
|
||||
onChange={(e) => {
|
||||
setRelayUrl(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="ws://localhost:3000"
|
||||
type="url"
|
||||
value={relayUrl}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={isConnecting || !relayUrl.trim()}
|
||||
onClick={handleConnect}
|
||||
size="default"
|
||||
type="button"
|
||||
>
|
||||
{isConnecting
|
||||
? "Connecting..."
|
||||
: isInternalBuild
|
||||
? `Connect to ${workspaceName}`
|
||||
: "Connect"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Check, ChevronDown, MoreHorizontal, Plus } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import type { Workspace } from "@/features/workspaces/types";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/shared/ui/sidebar";
|
||||
|
||||
import { EditWorkspaceDialog } from "./EditWorkspaceDialog";
|
||||
|
||||
type WorkspaceSwitcherProps = {
|
||||
activeWorkspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
onSwitchWorkspace: (id: string) => void;
|
||||
onAddWorkspace: () => void;
|
||||
onUpdateWorkspace: (
|
||||
id: string,
|
||||
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">>,
|
||||
) => void;
|
||||
onRemoveWorkspace: (id: string) => void;
|
||||
};
|
||||
|
||||
export function WorkspaceSwitcher({
|
||||
activeWorkspace,
|
||||
workspaces,
|
||||
onSwitchWorkspace,
|
||||
onAddWorkspace,
|
||||
onUpdateWorkspace,
|
||||
onRemoveWorkspace,
|
||||
}: WorkspaceSwitcherProps) {
|
||||
const [editingWorkspace, setEditingWorkspace] =
|
||||
React.useState<Workspace | null>(null);
|
||||
const [dropdownOpen, setDropdownOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
className="h-auto gap-2 rounded-xl px-2.5 py-2 data-[state=open]:bg-sidebar-accent"
|
||||
data-testid="workspace-switcher"
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-primary/15 text-xs leading-none">
|
||||
🌱
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{activeWorkspace?.name ?? "No workspace"}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-sidebar-foreground/50" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-[--radix-dropdown-menu-trigger-width] min-w-[220px]"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
side="bottom"
|
||||
sideOffset={4}
|
||||
>
|
||||
{workspaces.map((workspace) => (
|
||||
<DropdownMenuItem
|
||||
key={workspace.id}
|
||||
className="group flex items-center gap-2 pr-1"
|
||||
onSelect={() => {
|
||||
onSwitchWorkspace(workspace.id);
|
||||
}}
|
||||
>
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
{activeWorkspace?.id === workspace.id ? (
|
||||
<Check className="h-3.5 w-3.5 text-primary" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{workspace.name}
|
||||
</span>
|
||||
<button
|
||||
aria-label={`Edit ${workspace.name}`}
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 hover:bg-accent group-hover:opacity-100 group-focus:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setDropdownOpen(false);
|
||||
setEditingWorkspace(workspace);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onAddWorkspace}>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Add Workspace</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
<EditWorkspaceDialog
|
||||
canRemove={workspaces.length > 1}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingWorkspace(null);
|
||||
}}
|
||||
onRemove={onRemoveWorkspace}
|
||||
onSave={onUpdateWorkspace}
|
||||
open={editingWorkspace !== null}
|
||||
workspace={editingWorkspace}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { applyWorkspace, getDefaultRelayUrl } from "@/shared/api/tauri";
|
||||
|
||||
import {
|
||||
loadActiveWorkspaceId,
|
||||
loadWorkspaces,
|
||||
saveActiveWorkspaceId,
|
||||
} from "./workspaceStorage";
|
||||
|
||||
type WorkspaceInitResult =
|
||||
| { isReady: true; needsSetup: false }
|
||||
| { isReady: false; needsSetup: true; defaultRelayUrl: string }
|
||||
| { isReady: false; needsSetup: false };
|
||||
|
||||
/**
|
||||
* Runs once on mount. Loads the active workspace from localStorage
|
||||
* and calls the Tauri backend to apply the workspace config
|
||||
* (keys, relay URL, token).
|
||||
*
|
||||
* Returns a discriminated union — only render the app after the
|
||||
* workspace is applied. When `needsSetup` is true, the caller
|
||||
* should show a first-run welcome screen.
|
||||
*/
|
||||
export function useWorkspaceInit(): WorkspaceInitResult {
|
||||
const [result, setResult] = useState<WorkspaceInitResult>({
|
||||
isReady: false,
|
||||
needsSetup: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function init() {
|
||||
const workspaces = loadWorkspaces();
|
||||
|
||||
if (workspaces.length === 0) {
|
||||
// No workspaces at all — fetch the build default relay URL
|
||||
// so the welcome screen can pre-fill it.
|
||||
try {
|
||||
const defaultRelayUrl = await getDefaultRelayUrl();
|
||||
if (!cancelled) {
|
||||
setResult({ isReady: false, needsSetup: true, defaultRelayUrl });
|
||||
}
|
||||
} catch {
|
||||
// If we can't get the default, fall back to localhost
|
||||
if (!cancelled) {
|
||||
setResult({
|
||||
isReady: false,
|
||||
needsSetup: true,
|
||||
defaultRelayUrl: "ws://localhost:3000",
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine active workspace
|
||||
let activeId = loadActiveWorkspaceId();
|
||||
if (!activeId || !workspaces.find((w) => w.id === activeId)) {
|
||||
activeId = workspaces[0].id;
|
||||
saveActiveWorkspaceId(activeId);
|
||||
}
|
||||
|
||||
const active = workspaces.find((w) => w.id === activeId);
|
||||
if (!active) {
|
||||
if (!cancelled) {
|
||||
setResult({ isReady: true, needsSetup: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply workspace config to the Tauri backend
|
||||
try {
|
||||
await applyWorkspace(active.relayUrl, active.nsec, active.token);
|
||||
} catch (error) {
|
||||
console.error("Failed to apply workspace to backend:", error);
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setResult({ isReady: true, needsSetup: false });
|
||||
}
|
||||
}
|
||||
|
||||
void init();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { Workspace } from "./types";
|
||||
import {
|
||||
loadActiveWorkspaceId,
|
||||
loadWorkspaces,
|
||||
saveActiveWorkspaceId,
|
||||
saveWorkspaces,
|
||||
} from "./workspaceStorage";
|
||||
|
||||
export type UseWorkspacesReturn = {
|
||||
workspaces: Workspace[];
|
||||
activeWorkspace: Workspace | null;
|
||||
/** Add a workspace, deduplicating by relayUrl. Returns the final ID in the list. */
|
||||
addWorkspace: (workspace: Workspace) => string;
|
||||
removeWorkspace: (id: string) => void;
|
||||
switchWorkspace: (id: string) => void;
|
||||
updateWorkspace: (
|
||||
id: string,
|
||||
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">>,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function useWorkspaces(): UseWorkspacesReturn {
|
||||
const [workspaces, setWorkspacesState] =
|
||||
useState<Workspace[]>(loadWorkspaces);
|
||||
const [activeId, setActiveId] = useState<string | null>(
|
||||
loadActiveWorkspaceId,
|
||||
);
|
||||
const workspacesRef = useRef(workspaces);
|
||||
workspacesRef.current = workspaces;
|
||||
|
||||
const activeWorkspace = useMemo(
|
||||
() => workspaces.find((w) => w.id === activeId) ?? workspaces[0] ?? null,
|
||||
[workspaces, activeId],
|
||||
);
|
||||
|
||||
const addWorkspace = useCallback((workspace: Workspace): string => {
|
||||
const existing = workspacesRef.current.find(
|
||||
(w) => w.relayUrl === workspace.relayUrl,
|
||||
);
|
||||
const resolvedId = existing?.id ?? workspace.id;
|
||||
setWorkspacesState((prev) => {
|
||||
const dup = prev.find((w) => w.relayUrl === workspace.relayUrl);
|
||||
let next: Workspace[];
|
||||
if (dup) {
|
||||
next = prev.map((w) =>
|
||||
w.id === dup.id
|
||||
? {
|
||||
...w,
|
||||
name: workspace.name || w.name,
|
||||
token: workspace.token ?? w.token,
|
||||
nsec: workspace.nsec ?? w.nsec,
|
||||
pubkey: workspace.pubkey ?? w.pubkey,
|
||||
}
|
||||
: w,
|
||||
);
|
||||
} else {
|
||||
next = [...prev, workspace];
|
||||
}
|
||||
saveWorkspaces(next);
|
||||
return next;
|
||||
});
|
||||
return resolvedId;
|
||||
}, []);
|
||||
|
||||
const removeWorkspace = useCallback(
|
||||
(id: string) => {
|
||||
setWorkspacesState((prev) => {
|
||||
// Never allow removing the last workspace
|
||||
if (prev.length <= 1) {
|
||||
return prev;
|
||||
}
|
||||
const next = prev.filter((w) => w.id !== id);
|
||||
saveWorkspaces(next);
|
||||
|
||||
// If removing the active workspace, switch to first remaining
|
||||
if (activeId === id && next.length > 0) {
|
||||
setActiveId(next[0].id);
|
||||
saveActiveWorkspaceId(next[0].id);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[activeId],
|
||||
);
|
||||
|
||||
const switchWorkspace = useCallback(
|
||||
(id: string) => {
|
||||
if (id === activeId) {
|
||||
return;
|
||||
}
|
||||
saveActiveWorkspaceId(id);
|
||||
window.location.reload();
|
||||
},
|
||||
[activeId],
|
||||
);
|
||||
|
||||
const updateWorkspace = useCallback(
|
||||
(
|
||||
id: string,
|
||||
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">>,
|
||||
) => {
|
||||
setWorkspacesState((prev) => {
|
||||
// Prevent duplicate relay URLs across workspaces
|
||||
if (
|
||||
updates.relayUrl &&
|
||||
prev.some((w) => w.id !== id && w.relayUrl === updates.relayUrl)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
const next = prev.map((w) => (w.id === id ? { ...w, ...updates } : w));
|
||||
saveWorkspaces(next);
|
||||
return next;
|
||||
});
|
||||
// If the active workspace's relay URL or token changed, reload to reconnect
|
||||
if (
|
||||
id === activeId &&
|
||||
(updates.relayUrl || updates.token !== undefined)
|
||||
) {
|
||||
window.location.reload();
|
||||
}
|
||||
},
|
||||
[activeId],
|
||||
);
|
||||
|
||||
return {
|
||||
workspaces,
|
||||
activeWorkspace,
|
||||
addWorkspace,
|
||||
removeWorkspace,
|
||||
switchWorkspace,
|
||||
updateWorkspace,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Workspace } from "./types";
|
||||
|
||||
const WORKSPACES_KEY = "sprout-workspaces";
|
||||
const ACTIVE_WORKSPACE_KEY = "sprout-active-workspace-id";
|
||||
|
||||
export function loadWorkspaces(): Workspace[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(WORKSPACES_KEY);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed as Workspace[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveWorkspaces(workspaces: Workspace[]): void {
|
||||
localStorage.setItem(WORKSPACES_KEY, JSON.stringify(workspaces));
|
||||
}
|
||||
|
||||
export function loadActiveWorkspaceId(): string | null {
|
||||
return localStorage.getItem(ACTIVE_WORKSPACE_KEY);
|
||||
}
|
||||
|
||||
export function saveActiveWorkspaceId(id: string): void {
|
||||
localStorage.setItem(ACTIVE_WORKSPACE_KEY, id);
|
||||
}
|
||||
|
||||
export function normalizeRelayUrl(url: string): string {
|
||||
if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
|
||||
return `wss://${url}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function deriveWorkspaceName(relayUrl: string): string {
|
||||
try {
|
||||
const url = new URL(
|
||||
relayUrl.replace("ws://", "http://").replace("wss://", "https://"),
|
||||
);
|
||||
const host = url.hostname;
|
||||
if (host === "localhost" || host === "127.0.0.1") {
|
||||
return "Local Dev";
|
||||
}
|
||||
const parts = host.split(".");
|
||||
// Detect staging environments (e.g. sprout-oss.stage.blox.sqprod.co)
|
||||
if (parts.some((p) => p === "stage" || p === "staging")) {
|
||||
return "Sprout (staging)";
|
||||
}
|
||||
// Use the first subdomain segment or the domain itself
|
||||
if (parts.length >= 2) {
|
||||
return parts[0] === "relay" ? parts[1] : parts[0];
|
||||
}
|
||||
return host;
|
||||
} catch {
|
||||
return "Workspace";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user