mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 16:07:15 +02:00
chore: cleanup, a11y and state migrations
This commit is contained in:
@@ -109,83 +109,84 @@ export default function CommandLauncher({
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>Command Launcher</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<Command
|
||||
label="Command Launcher"
|
||||
className="grimoire-command-content"
|
||||
shouldFilter={false}
|
||||
>
|
||||
<div className="command-launcher-wrapper">
|
||||
<Command.Input
|
||||
value={input}
|
||||
onValueChange={setInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="command-input"
|
||||
/>
|
||||
<Command
|
||||
label="Command Launcher"
|
||||
className="grimoire-command-content"
|
||||
shouldFilter={false}
|
||||
>
|
||||
<div className="command-launcher-wrapper">
|
||||
<Command.Input
|
||||
value={input}
|
||||
onValueChange={setInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="command-input"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Command.List className="command-list">
|
||||
<Command.Empty className="command-empty">
|
||||
{commandName
|
||||
? `No command found: ${commandName}`
|
||||
: "Start typing..."}
|
||||
</Command.Empty>
|
||||
<Command.List className="command-list">
|
||||
<Command.Empty className="command-empty">
|
||||
{commandName
|
||||
? `No command found: ${commandName}`
|
||||
: "Start typing..."}
|
||||
</Command.Empty>
|
||||
|
||||
{categories.map((category) => (
|
||||
<Command.Group
|
||||
key={category}
|
||||
heading={category}
|
||||
className="command-group"
|
||||
>
|
||||
{filteredCommands
|
||||
.filter(([_, cmd]) => cmd.category === category)
|
||||
.map(([name, cmd]) => {
|
||||
const isExactMatch = name === commandName;
|
||||
return (
|
||||
<Command.Item
|
||||
key={name}
|
||||
value={name}
|
||||
onSelect={() => handleSelect(name)}
|
||||
className="command-item"
|
||||
data-exact-match={isExactMatch}
|
||||
>
|
||||
<div className="command-item-content">
|
||||
<div className="command-item-name">
|
||||
<span className="command-name">{name}</span>
|
||||
{cmd.synopsis !== name && (
|
||||
<span className="command-args">
|
||||
{cmd.synopsis.replace(name, "").trim()}
|
||||
</span>
|
||||
)}
|
||||
{isExactMatch && (
|
||||
<span className="command-match-indicator">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
{categories.map((category) => (
|
||||
<Command.Group
|
||||
key={category}
|
||||
heading={category}
|
||||
className="command-group"
|
||||
>
|
||||
{filteredCommands
|
||||
.filter(([_, cmd]) => cmd.category === category)
|
||||
.map(([name, cmd]) => {
|
||||
const isExactMatch = name === commandName;
|
||||
return (
|
||||
<Command.Item
|
||||
key={name}
|
||||
value={name}
|
||||
onSelect={() => handleSelect(name)}
|
||||
className="command-item"
|
||||
data-exact-match={isExactMatch}
|
||||
>
|
||||
<div className="command-item-content">
|
||||
<div className="command-item-name">
|
||||
<span className="command-name">{name}</span>
|
||||
{cmd.synopsis !== name && (
|
||||
<span className="command-args">
|
||||
{cmd.synopsis.replace(name, "").trim()}
|
||||
</span>
|
||||
)}
|
||||
{isExactMatch && (
|
||||
<span className="command-match-indicator">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="command-item-description">
|
||||
{cmd.description.split(".")[0]}
|
||||
</div>
|
||||
</div>
|
||||
<div className="command-item-description">
|
||||
{cmd.description.split(".")[0]}
|
||||
</div>
|
||||
</div>
|
||||
</Command.Item>
|
||||
);
|
||||
})}
|
||||
</Command.Group>
|
||||
))}
|
||||
</Command.List>
|
||||
</Command.Item>
|
||||
);
|
||||
})}
|
||||
</Command.Group>
|
||||
))}
|
||||
</Command.List>
|
||||
|
||||
<div className="command-footer">
|
||||
<div>
|
||||
<kbd>↑↓</kbd> navigate
|
||||
<kbd>↵</kbd> execute
|
||||
<kbd>esc</kbd> close
|
||||
<div className="command-footer">
|
||||
<div>
|
||||
<kbd>↑↓</kbd> navigate
|
||||
<kbd>↵</kbd> execute
|
||||
<kbd>esc</kbd> close
|
||||
</div>
|
||||
{recognizedCommand && (
|
||||
<div className="command-footer-status">Ready to execute</div>
|
||||
)}
|
||||
</div>
|
||||
{recognizedCommand && (
|
||||
<div className="command-footer-status">Ready to execute</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -335,7 +335,12 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
|
||||
// 8. Generic Tags - NEW (a-z, A-Z filters excluding e, p, t, d)
|
||||
const genericTags = Object.entries(filter)
|
||||
.filter(([key]) => key.startsWith("#") && key.length === 2 && !["#e", "#p", "#t", "#d"].includes(key))
|
||||
.filter(
|
||||
([key]) =>
|
||||
key.startsWith("#") &&
|
||||
key.length === 2 &&
|
||||
!["#e", "#p", "#t", "#d"].includes(key),
|
||||
)
|
||||
.map(([key, values]) => ({ letter: key[1], values: values as string[] }));
|
||||
|
||||
if (genericTags.length > 0) {
|
||||
|
||||
180
src/components/ErrorBoundary.tsx
Normal file
180
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Component, ReactNode, ErrorInfo } from "react";
|
||||
import { AlertTriangle, RefreshCw, Home } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
||||
level?: "app" | "window";
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
const { onError, level = "window" } = this.props;
|
||||
|
||||
// Log to console for development
|
||||
console.error(`[ErrorBoundary:${level}] Caught error:`, error, errorInfo);
|
||||
|
||||
// Call custom error handler if provided
|
||||
if (onError) {
|
||||
onError(error, errorInfo);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo,
|
||||
});
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
});
|
||||
};
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { hasError, error, errorInfo } = this.state;
|
||||
const { children, fallback, level = "window" } = this.props;
|
||||
|
||||
if (hasError) {
|
||||
// Use custom fallback if provided
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// App-level error: full screen error
|
||||
if (level === "app") {
|
||||
return (
|
||||
<div className="h-screen w-screen flex items-center justify-center bg-background p-8">
|
||||
<div className="max-w-2xl w-full border border-destructive bg-card p-8 space-y-6">
|
||||
<div className="flex items-center gap-3 text-destructive">
|
||||
<AlertTriangle className="h-8 w-8" />
|
||||
<h1 className="text-2xl font-bold">Application Error</h1>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
Grimoire encountered a critical error and cannot continue. You
|
||||
can try reloading the application or clearing your browser
|
||||
data.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="bg-muted p-4 font-mono text-xs overflow-auto max-h-48">
|
||||
<div className="text-destructive font-semibold mb-2">
|
||||
{error.name}: {error.message}
|
||||
</div>
|
||||
{error.stack && (
|
||||
<pre className="text-muted-foreground whitespace-pre-wrap">
|
||||
{error.stack}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorInfo?.componentStack && (
|
||||
<details className="text-muted-foreground">
|
||||
<summary className="cursor-pointer hover:text-foreground">
|
||||
Component Stack
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs bg-muted p-4 overflow-auto max-h-48">
|
||||
{errorInfo.componentStack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={this.handleReload}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Reload Application
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.clear();
|
||||
this.handleReload();
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-border hover:bg-accent"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
Reset & Reload
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If this problem persists, please report it on GitHub with the
|
||||
error details above.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Window-level error: inline error display
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center p-4 bg-background">
|
||||
<div className="max-w-lg w-full border border-destructive/50 bg-card p-6 space-y-4">
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">Window Error</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This window encountered an error. You can try reopening it or
|
||||
continue using other windows.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="bg-muted p-3 font-mono text-xs">
|
||||
<div className="text-destructive font-semibold">
|
||||
{error.name}: {error.message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground hover:bg-primary/90 text-sm"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,7 @@ export default function Home() {
|
||||
onClick={() => setCommandLauncherOpen(true)}
|
||||
className="p-1 text-muted-foreground hover:text-accent transition-colors cursor-crosshair"
|
||||
title="Launch command (Cmd+K)"
|
||||
aria-label="Launch command palette"
|
||||
>
|
||||
<Terminal className="size-4" />
|
||||
</button>
|
||||
|
||||
@@ -11,10 +11,15 @@ export function TabBar() {
|
||||
createWorkspace();
|
||||
};
|
||||
|
||||
// Sort workspaces by number
|
||||
const sortedWorkspaces = Object.values(workspaces).sort(
|
||||
(a, b) => a.number - b.number,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-8 border-t border-border bg-background flex items-center px-2 gap-1 overflow-x-auto">
|
||||
<div className="flex items-center gap-1 flex-nowrap">
|
||||
{Object.values(workspaces).map((ws) => (
|
||||
{sortedWorkspaces.map((ws) => (
|
||||
<button
|
||||
key={ws.id}
|
||||
onClick={() => setActiveWorkspace(ws.id)}
|
||||
@@ -25,7 +30,7 @@ export function TabBar() {
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{ws.label}
|
||||
{ws.label && ws.label.trim() ? `${ws.number} ${ws.label}` : ws.number}
|
||||
</button>
|
||||
))}
|
||||
<Button
|
||||
@@ -33,6 +38,7 @@ export function TabBar() {
|
||||
size="icon"
|
||||
className="h-6 w-6 ml-1 flex-shrink-0"
|
||||
onClick={handleNewTab}
|
||||
aria-label="Create new workspace"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
@@ -69,8 +69,9 @@ export function WinViewer() {
|
||||
const continuer = isLast ? " " : "│ ";
|
||||
|
||||
// Workspace header
|
||||
const wsDisplay = ws.label ? `${ws.number} "${ws.label}"` : `${ws.number}`;
|
||||
lines.push(
|
||||
`${prefix} ${isActive ? "●" : "○"} workspace: "${ws.label}" (${wsId})`,
|
||||
`${prefix} ${isActive ? "●" : "○"} workspace: ${wsDisplay} (${wsId})`,
|
||||
);
|
||||
|
||||
// Window IDs
|
||||
|
||||
@@ -1,20 +1,44 @@
|
||||
import { Component, ReactNode } from "react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Component, ReactNode, Suspense, lazy } from "react";
|
||||
import { AlertCircle, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { WindowInstance } from "@/types/app";
|
||||
import { NipRenderer } from "./NipRenderer";
|
||||
import ManPage from "./ManPage";
|
||||
import ReqViewer from "./ReqViewer";
|
||||
import { EventDetailViewer } from "./EventDetailViewer";
|
||||
import { ProfileViewer } from "./ProfileViewer";
|
||||
import EncodeViewer from "./EncodeViewer";
|
||||
import DecodeViewer from "./DecodeViewer";
|
||||
import { RelayViewer } from "./RelayViewer";
|
||||
import KindRenderer from "./KindRenderer";
|
||||
import KindsViewer from "./KindsViewer";
|
||||
import NipsViewer from "./NipsViewer";
|
||||
import { DebugViewer } from "./DebugViewer";
|
||||
import ConnViewer from "./ConnViewer";
|
||||
|
||||
// Lazy load all viewer components for better code splitting
|
||||
const NipRenderer = lazy(() =>
|
||||
import("./NipRenderer").then((m) => ({ default: m.NipRenderer })),
|
||||
);
|
||||
const ManPage = lazy(() => import("./ManPage"));
|
||||
const ReqViewer = lazy(() => import("./ReqViewer"));
|
||||
const EventDetailViewer = lazy(() =>
|
||||
import("./EventDetailViewer").then((m) => ({ default: m.EventDetailViewer })),
|
||||
);
|
||||
const ProfileViewer = lazy(() =>
|
||||
import("./ProfileViewer").then((m) => ({ default: m.ProfileViewer })),
|
||||
);
|
||||
const EncodeViewer = lazy(() => import("./EncodeViewer"));
|
||||
const DecodeViewer = lazy(() => import("./DecodeViewer"));
|
||||
const RelayViewer = lazy(() =>
|
||||
import("./RelayViewer").then((m) => ({ default: m.RelayViewer })),
|
||||
);
|
||||
const KindRenderer = lazy(() => import("./KindRenderer"));
|
||||
const KindsViewer = lazy(() => import("./KindsViewer"));
|
||||
const NipsViewer = lazy(() => import("./NipsViewer"));
|
||||
const DebugViewer = lazy(() =>
|
||||
import("./DebugViewer").then((m) => ({ default: m.DebugViewer })),
|
||||
);
|
||||
const ConnViewer = lazy(() => import("./ConnViewer"));
|
||||
|
||||
// Loading fallback component
|
||||
function ViewerLoading() {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<p className="text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface WindowRendererProps {
|
||||
window: WindowInstance;
|
||||
@@ -168,7 +192,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
|
||||
|
||||
return (
|
||||
<WindowErrorBoundary windowTitle={window.title} onClose={onClose}>
|
||||
<div className="h-full w-full overflow-auto">{content}</div>
|
||||
<Suspense fallback={<ViewerLoading />}>
|
||||
<div className="h-full w-full overflow-auto">{content}</div>
|
||||
</Suspense>
|
||||
</WindowErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { WindowInstance } from "@/types/app";
|
||||
import { WindowToolbar } from "./WindowToolbar";
|
||||
import { WindowRenderer } from "./WindowRenderer";
|
||||
import { useDynamicWindowTitle } from "./DynamicWindowTitle";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
|
||||
interface WindowTileProps {
|
||||
id: string;
|
||||
@@ -47,7 +48,9 @@ export function WindowTile({
|
||||
|
||||
return (
|
||||
<MosaicWindow path={path} title={title} renderToolbar={renderToolbar}>
|
||||
<WindowRenderer window={window} onClose={() => onClose(id)} />
|
||||
<ErrorBoundary level="window">
|
||||
<WindowRenderer window={window} onClose={() => onClose(id)} />
|
||||
</ErrorBoundary>
|
||||
</MosaicWindow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,8 +21,7 @@ export function WindowToolbar({
|
||||
if (!window) return;
|
||||
|
||||
// Get command string (existing or reconstructed)
|
||||
const commandString =
|
||||
window.commandString || reconstructCommand(window);
|
||||
const commandString = window.commandString || reconstructCommand(window);
|
||||
|
||||
// Set edit mode state
|
||||
setEditMode({
|
||||
@@ -43,6 +42,7 @@ export function WindowToolbar({
|
||||
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||
onClick={handleEdit}
|
||||
title="Edit command"
|
||||
aria-label="Edit command"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</button>
|
||||
@@ -52,6 +52,7 @@ export function WindowToolbar({
|
||||
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||
onClick={onClose}
|
||||
title="Close window"
|
||||
aria-label="Close window"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
|
||||
@@ -79,7 +79,8 @@ export function Mention({ node }: MentionNodeProps) {
|
||||
if (!options.showEventEmbeds) {
|
||||
return (
|
||||
<span className="text-muted-foreground font-mono text-sm">
|
||||
{node.encoded || `naddr:${pointer.identifier || pointer.pubkey.slice(0, 8)}...`}
|
||||
{node.encoded ||
|
||||
`naddr:${pointer.identifier || pointer.pubkey.slice(0, 8)}...`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function UserMenu() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="link">
|
||||
<Button size="sm" variant="link" aria-label={account ? "User menu" : "Log in"}>
|
||||
{account ? (
|
||||
<UserAvatar pubkey={account.pubkey} />
|
||||
) : (
|
||||
|
||||
127
src/core/logic.test.ts
Normal file
127
src/core/logic.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { findLowestAvailableWorkspaceNumber } from "./logic";
|
||||
|
||||
describe("findLowestAvailableWorkspaceNumber", () => {
|
||||
describe("basic number assignment", () => {
|
||||
it("should return 1 when no workspaces exist", () => {
|
||||
const result = findLowestAvailableWorkspaceNumber({});
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it("should return 2 when only workspace 1 exists", () => {
|
||||
const workspaces = {
|
||||
id1: { number: 1 },
|
||||
};
|
||||
const result = findLowestAvailableWorkspaceNumber(workspaces);
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
|
||||
it("should return 4 when workspaces 1, 2, 3 exist", () => {
|
||||
const workspaces = {
|
||||
id1: { number: 1 },
|
||||
id2: { number: 2 },
|
||||
id3: { number: 3 },
|
||||
};
|
||||
const result = findLowestAvailableWorkspaceNumber(workspaces);
|
||||
expect(result).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gap detection", () => {
|
||||
it("should return 2 when workspaces 1, 3, 4 exist", () => {
|
||||
const workspaces = {
|
||||
id1: { number: 1 },
|
||||
id3: { number: 3 },
|
||||
id4: { number: 4 },
|
||||
};
|
||||
const result = findLowestAvailableWorkspaceNumber(workspaces);
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
|
||||
it("should return 1 when workspaces 2, 3, 4 exist", () => {
|
||||
const workspaces = {
|
||||
id2: { number: 2 },
|
||||
id3: { number: 3 },
|
||||
id4: { number: 4 },
|
||||
};
|
||||
const result = findLowestAvailableWorkspaceNumber(workspaces);
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it("should return 3 when workspaces 1, 2, 4, 5 exist", () => {
|
||||
const workspaces = {
|
||||
id1: { number: 1 },
|
||||
id2: { number: 2 },
|
||||
id4: { number: 4 },
|
||||
id5: { number: 5 },
|
||||
};
|
||||
const result = findLowestAvailableWorkspaceNumber(workspaces);
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
|
||||
it("should return 2 when workspaces 1, 3 exist", () => {
|
||||
const workspaces = {
|
||||
id1: { number: 1 },
|
||||
id3: { number: 3 },
|
||||
};
|
||||
const result = findLowestAvailableWorkspaceNumber(workspaces);
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
|
||||
it("should return first gap when multiple gaps exist", () => {
|
||||
const workspaces = {
|
||||
id1: { number: 1 },
|
||||
id5: { number: 5 },
|
||||
id10: { number: 10 },
|
||||
};
|
||||
const result = findLowestAvailableWorkspaceNumber(workspaces);
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("large numbers", () => {
|
||||
it("should return 3 when workspaces 1, 2, 100 exist", () => {
|
||||
const workspaces = {
|
||||
id1: { number: 1 },
|
||||
id2: { number: 2 },
|
||||
id100: { number: 100 },
|
||||
};
|
||||
const result = findLowestAvailableWorkspaceNumber(workspaces);
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
|
||||
it("should handle large sequential numbers correctly", () => {
|
||||
const workspaces = {
|
||||
id100: { number: 100 },
|
||||
id101: { number: 101 },
|
||||
id102: { number: 102 },
|
||||
};
|
||||
const result = findLowestAvailableWorkspaceNumber(workspaces);
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unordered workspaces", () => {
|
||||
it("should handle workspaces in random order", () => {
|
||||
const workspaces = {
|
||||
id5: { number: 5 },
|
||||
id1: { number: 1 },
|
||||
id3: { number: 3 },
|
||||
id7: { number: 7 },
|
||||
};
|
||||
const result = findLowestAvailableWorkspaceNumber(workspaces);
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
|
||||
it("should find lowest gap regardless of insertion order", () => {
|
||||
const workspaces = {
|
||||
id10: { number: 10 },
|
||||
id2: { number: 2 },
|
||||
id8: { number: 8 },
|
||||
id1: { number: 1 },
|
||||
};
|
||||
const result = findLowestAvailableWorkspaceNumber(workspaces);
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,37 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import type { MosaicNode } from "react-mosaic-component";
|
||||
import { GrimoireState, WindowInstance, UserRelays } from "@/types/app";
|
||||
|
||||
/**
|
||||
* Finds the lowest available workspace number.
|
||||
* - If workspaces have numbers [1, 2, 4], returns 3
|
||||
* - If workspaces have numbers [1, 2, 3], returns 4
|
||||
* - If workspaces have numbers [2, 3, 4], returns 1
|
||||
*/
|
||||
export const findLowestAvailableWorkspaceNumber = (
|
||||
workspaces: Record<string, { number: number }>,
|
||||
): number => {
|
||||
// Get all workspace numbers as a Set for O(1) lookup
|
||||
const numbers = new Set(Object.values(workspaces).map((ws) => ws.number));
|
||||
|
||||
// If no workspaces exist, start at 1
|
||||
if (numbers.size === 0) return 1;
|
||||
|
||||
// Find first gap starting from 1
|
||||
let candidate = 1;
|
||||
while (numbers.has(candidate)) {
|
||||
candidate++;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new, empty workspace.
|
||||
*/
|
||||
export const createWorkspace = (
|
||||
state: GrimoireState,
|
||||
label: string,
|
||||
number: number,
|
||||
label?: string,
|
||||
): GrimoireState => {
|
||||
const newId = uuidv4();
|
||||
return {
|
||||
@@ -17,6 +42,7 @@ export const createWorkspace = (
|
||||
...state.workspaces,
|
||||
[newId]: {
|
||||
id: newId,
|
||||
number,
|
||||
label,
|
||||
layout: null,
|
||||
windowIds: [],
|
||||
@@ -302,7 +328,9 @@ export const deleteWorkspace = (
|
||||
export const updateWindow = (
|
||||
state: GrimoireState,
|
||||
windowId: string,
|
||||
updates: Partial<Pick<WindowInstance, "props" | "title" | "commandString" | "appId">>,
|
||||
updates: Partial<
|
||||
Pick<WindowInstance, "props" | "title" | "commandString" | "appId">
|
||||
>,
|
||||
): GrimoireState => {
|
||||
const window = state.windows[windowId];
|
||||
if (!window) {
|
||||
|
||||
@@ -1,32 +1,79 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
||||
import { GrimoireState, AppId, WindowInstance } from "@/types/app";
|
||||
import { useLocale } from "@/hooks/useLocale";
|
||||
import * as Logic from "./logic";
|
||||
import {
|
||||
CURRENT_VERSION,
|
||||
validateState,
|
||||
migrateState,
|
||||
} from "@/lib/migrations";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// Initial State Definition - Empty canvas on first load
|
||||
const initialState: GrimoireState = {
|
||||
__version: CURRENT_VERSION,
|
||||
windows: {},
|
||||
activeWorkspaceId: "default",
|
||||
workspaces: {
|
||||
default: {
|
||||
id: "default",
|
||||
label: "1",
|
||||
number: 1,
|
||||
windowIds: [],
|
||||
layout: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Custom storage with error handling
|
||||
// Custom storage with error handling and migrations
|
||||
const storage = createJSONStorage<GrimoireState>(() => ({
|
||||
getItem: (key: string) => {
|
||||
try {
|
||||
const value = localStorage.getItem(key);
|
||||
if (!value) return null;
|
||||
|
||||
// Parse and validate/migrate state
|
||||
const parsed = JSON.parse(value);
|
||||
const storedVersion = parsed.__version || 5;
|
||||
|
||||
// Check if migration is needed
|
||||
if (storedVersion < CURRENT_VERSION) {
|
||||
console.log(
|
||||
`[Storage] State version outdated (v${storedVersion}), migrating...`,
|
||||
);
|
||||
const migrated = migrateState(parsed);
|
||||
|
||||
// Save migrated state back to localStorage
|
||||
localStorage.setItem(key, JSON.stringify(migrated));
|
||||
|
||||
toast.success("State Updated", {
|
||||
description: `Migrated from v${storedVersion} to v${CURRENT_VERSION}`,
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
return JSON.stringify(migrated);
|
||||
}
|
||||
|
||||
// Validate current version state
|
||||
if (!validateState(parsed)) {
|
||||
console.warn(
|
||||
"[Storage] State validation failed, resetting to initial state",
|
||||
);
|
||||
toast.error("State Corrupted", {
|
||||
description: "Your state was corrupted and has been reset.",
|
||||
duration: 5000,
|
||||
});
|
||||
return null; // Return null to use initialState
|
||||
}
|
||||
|
||||
return value;
|
||||
} catch (error) {
|
||||
console.warn("Failed to read from localStorage:", error);
|
||||
console.error("[Storage] Failed to read from localStorage:", error);
|
||||
toast.error("Failed to Load State", {
|
||||
description: "Using default state.",
|
||||
duration: 5000,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
@@ -43,6 +90,10 @@ const storage = createJSONStorage<GrimoireState>(() => ({
|
||||
console.error(
|
||||
"localStorage quota exceeded. State will not be persisted.",
|
||||
);
|
||||
toast.error("Storage Full", {
|
||||
description: "Could not save state. Storage quota exceeded.",
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -74,15 +125,18 @@ export const useGrimoire = () => {
|
||||
}
|
||||
}, [state.locale, browserLocale, setState]);
|
||||
|
||||
return {
|
||||
state,
|
||||
locale: state.locale || browserLocale,
|
||||
activeWorkspace: state.workspaces[state.activeWorkspaceId],
|
||||
createWorkspace: () => {
|
||||
const count = Object.keys(state.workspaces).length + 1;
|
||||
setState((prev) => Logic.createWorkspace(prev, count.toString()));
|
||||
},
|
||||
addWindow: (appId: AppId, props: any, title?: string, commandString?: string) =>
|
||||
// Wrap all callbacks in useCallback for stable references
|
||||
const createWorkspace = useCallback(() => {
|
||||
setState((prev) => {
|
||||
const nextNumber = Logic.findLowestAvailableWorkspaceNumber(
|
||||
prev.workspaces,
|
||||
);
|
||||
return Logic.createWorkspace(prev, nextNumber);
|
||||
});
|
||||
}, [setState]);
|
||||
|
||||
const addWindow = useCallback(
|
||||
(appId: AppId, props: any, title?: string, commandString?: string) =>
|
||||
setState((prev) =>
|
||||
Logic.addWindow(prev, {
|
||||
appId,
|
||||
@@ -91,17 +145,39 @@ export const useGrimoire = () => {
|
||||
commandString,
|
||||
}),
|
||||
),
|
||||
updateWindow: (windowId: string, updates: Partial<Pick<WindowInstance, "props" | "title" | "commandString" | "appId">>) =>
|
||||
setState((prev) => Logic.updateWindow(prev, windowId, updates)),
|
||||
removeWindow: (id: string) =>
|
||||
setState((prev) => Logic.removeWindow(prev, id)),
|
||||
moveWindowToWorkspace: (windowId: string, targetWorkspaceId: string) =>
|
||||
[setState],
|
||||
);
|
||||
|
||||
const updateWindow = useCallback(
|
||||
(
|
||||
windowId: string,
|
||||
updates: Partial<
|
||||
Pick<WindowInstance, "props" | "title" | "commandString" | "appId">
|
||||
>,
|
||||
) => setState((prev) => Logic.updateWindow(prev, windowId, updates)),
|
||||
[setState],
|
||||
);
|
||||
|
||||
const removeWindow = useCallback(
|
||||
(id: string) => setState((prev) => Logic.removeWindow(prev, id)),
|
||||
[setState],
|
||||
);
|
||||
|
||||
const moveWindowToWorkspace = useCallback(
|
||||
(windowId: string, targetWorkspaceId: string) =>
|
||||
setState((prev) =>
|
||||
Logic.moveWindowToWorkspace(prev, windowId, targetWorkspaceId),
|
||||
),
|
||||
updateLayout: (layout: any) =>
|
||||
setState((prev) => Logic.updateLayout(prev, layout)),
|
||||
setActiveWorkspace: (id: string) =>
|
||||
[setState],
|
||||
);
|
||||
|
||||
const updateLayout = useCallback(
|
||||
(layout: any) => setState((prev) => Logic.updateLayout(prev, layout)),
|
||||
[setState],
|
||||
);
|
||||
|
||||
const setActiveWorkspace = useCallback(
|
||||
(id: string) =>
|
||||
setState((prev) => {
|
||||
// Validate target workspace exists
|
||||
if (!prev.workspaces[id]) {
|
||||
@@ -133,9 +209,33 @@ export const useGrimoire = () => {
|
||||
// Normal workspace switch
|
||||
return { ...prev, activeWorkspaceId: id };
|
||||
}),
|
||||
setActiveAccount: (pubkey: string | undefined) =>
|
||||
[setState],
|
||||
);
|
||||
|
||||
const setActiveAccount = useCallback(
|
||||
(pubkey: string | undefined) =>
|
||||
setState((prev) => Logic.setActiveAccount(prev, pubkey)),
|
||||
setActiveAccountRelays: (relays: any) =>
|
||||
[setState],
|
||||
);
|
||||
|
||||
const setActiveAccountRelays = useCallback(
|
||||
(relays: any) =>
|
||||
setState((prev) => Logic.setActiveAccountRelays(prev, relays)),
|
||||
[setState],
|
||||
);
|
||||
|
||||
return {
|
||||
state,
|
||||
locale: state.locale || browserLocale,
|
||||
activeWorkspace: state.workspaces[state.activeWorkspaceId],
|
||||
createWorkspace,
|
||||
addWindow,
|
||||
updateWindow,
|
||||
removeWindow,
|
||||
moveWindowToWorkspace,
|
||||
updateLayout,
|
||||
setActiveWorkspace,
|
||||
setActiveAccount,
|
||||
setActiveAccountRelays,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -72,7 +72,7 @@ export function useAccountSync() {
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Skipping invalid relay URL in Kind 10002 event: ${tag[1]}`,
|
||||
error
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,11 @@ export function useAccountSync() {
|
||||
inbox: inboxRelays
|
||||
.map((url) => {
|
||||
try {
|
||||
return { url: normalizeRelayURL(url), read: true, write: false };
|
||||
return {
|
||||
url: normalizeRelayURL(url),
|
||||
read: true,
|
||||
write: false,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -91,7 +95,11 @@ export function useAccountSync() {
|
||||
outbox: outboxRelays
|
||||
.map((url) => {
|
||||
try {
|
||||
return { url: normalizeRelayURL(url), read: false, write: true };
|
||||
return {
|
||||
url: normalizeRelayURL(url),
|
||||
read: false,
|
||||
write: true,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -107,5 +115,5 @@ export function useAccountSync() {
|
||||
subscription.unsubscribe();
|
||||
storeSubscription.unsubscribe();
|
||||
};
|
||||
}, [activeAccount?.pubkey, eventStore]);
|
||||
}, [activeAccount?.pubkey, eventStore, setActiveAccountRelays]);
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export function useNostrEvent(
|
||||
} else {
|
||||
console.warn("[useNostrEvent] Unknown pointer type:", pointer);
|
||||
}
|
||||
}, [pointerKey]);
|
||||
}, [pointer, pointerKey]);
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,13 @@ export function useReqTimeline(
|
||||
);
|
||||
}, [eventsMap]);
|
||||
|
||||
// Stabilize filters and relays for dependency array
|
||||
// Using JSON.stringify and .join() for deep comparison - this is intentional
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const stableFilters = useMemo(() => filters, [JSON.stringify(filters)]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const stableRelays = useMemo(() => relays, [relays.join(",")]);
|
||||
|
||||
useEffect(() => {
|
||||
if (relays.length === 0) {
|
||||
setLoading(false);
|
||||
@@ -113,7 +120,7 @@ export function useReqTimeline(
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [id, JSON.stringify(filters), relays.join(","), limit, stream]);
|
||||
}, [id, stableFilters, stableRelays, limit, stream, eventStore]);
|
||||
|
||||
return {
|
||||
events: events || [],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import type { NostrEvent, Filter } from "nostr-tools";
|
||||
import { useEventStore, useObservableMemo } from "applesauce-react/hooks";
|
||||
import { createTimelineLoader } from "@/services/loaders";
|
||||
@@ -35,6 +35,13 @@ export function useTimeline(
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Stabilize filters and relays for dependency array
|
||||
// Using JSON.stringify and .join() for deep comparison - this is intentional
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const stableFilters = useMemo(() => filters, [JSON.stringify(filters)]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const stableRelays = useMemo(() => relays, [relays.join(",")]);
|
||||
|
||||
// Load events into store
|
||||
useEffect(() => {
|
||||
if (relays.length === 0) return;
|
||||
@@ -64,7 +71,7 @@ export function useTimeline(
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, [id, relays.length, limit]);
|
||||
}, [id, stableRelays, limit, eventStore, stableFilters]);
|
||||
|
||||
// Watch store for matching events
|
||||
const timeline = useObservableMemo(() => {
|
||||
|
||||
@@ -223,3 +223,34 @@
|
||||
height: 4px;
|
||||
margin: -2px 0;
|
||||
}
|
||||
|
||||
/* Accessibility: Focus indicators for keyboard navigation */
|
||||
@layer base {
|
||||
/* Focus-visible for buttons and interactive elements */
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
[role="button"]:focus-visible {
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Focus-visible for input elements */
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline-offset: 0;
|
||||
}
|
||||
|
||||
/* Focus-visible for command launcher items */
|
||||
[cmdk-item]:focus-visible {
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Focus-visible for tab buttons */
|
||||
.tabbar-button:focus-visible {
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,9 +174,10 @@ export function transitionAuthState(
|
||||
}
|
||||
return noChange;
|
||||
|
||||
default:
|
||||
default: {
|
||||
// Exhaustive check
|
||||
const _exhaustive: never = currentStatus;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface ParsedCommand {
|
||||
commandName: string;
|
||||
args: string[];
|
||||
fullInput: string;
|
||||
command?: typeof manPages[string];
|
||||
command?: (typeof manPages)[string];
|
||||
props?: any;
|
||||
title?: string;
|
||||
error?: string;
|
||||
|
||||
269
src/lib/error-handler.ts
Normal file
269
src/lib/error-handler.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Global Error Handler
|
||||
*
|
||||
* Captures and handles:
|
||||
* - Unhandled promise rejections
|
||||
* - Uncaught errors
|
||||
* - LocalStorage quota exceeded errors
|
||||
* - Network failures
|
||||
*/
|
||||
|
||||
export interface ErrorContext {
|
||||
timestamp: Date;
|
||||
userAgent: string;
|
||||
url: string;
|
||||
activeAccount?: string;
|
||||
errorType: string;
|
||||
}
|
||||
|
||||
export interface ErrorReport {
|
||||
error: Error | string;
|
||||
context: ErrorContext;
|
||||
stack?: string;
|
||||
}
|
||||
|
||||
type ErrorCallback = (report: ErrorReport) => void;
|
||||
|
||||
class GlobalErrorHandler {
|
||||
private callbacks: ErrorCallback[] = [];
|
||||
private initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize global error handlers
|
||||
*/
|
||||
initialize() {
|
||||
if (this.initialized) {
|
||||
console.warn("[ErrorHandler] Already initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
event.preventDefault(); // Prevent default console error
|
||||
|
||||
const error =
|
||||
event.reason instanceof Error
|
||||
? event.reason
|
||||
: new Error(String(event.reason));
|
||||
|
||||
this.report(error, "unhandled_rejection");
|
||||
});
|
||||
|
||||
// Handle uncaught errors
|
||||
window.addEventListener("error", (event) => {
|
||||
event.preventDefault(); // Prevent default console error
|
||||
|
||||
const error = event.error || new Error(event.message);
|
||||
this.report(error, "uncaught_error");
|
||||
});
|
||||
|
||||
// Wrap localStorage methods to catch quota errors
|
||||
this.wrapLocalStorage();
|
||||
|
||||
this.initialized = true;
|
||||
console.log("[ErrorHandler] Global error handler initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback to be notified of errors
|
||||
*/
|
||||
onError(callback: ErrorCallback) {
|
||||
this.callbacks.push(callback);
|
||||
return () => {
|
||||
this.callbacks = this.callbacks.filter((cb) => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Report an error
|
||||
*/
|
||||
report(error: Error | string, errorType: string) {
|
||||
const report: ErrorReport = {
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
context: {
|
||||
timestamp: new Date(),
|
||||
userAgent: navigator.userAgent,
|
||||
url: window.location.href,
|
||||
errorType,
|
||||
},
|
||||
stack:
|
||||
error instanceof Error ? error.stack : new Error(String(error)).stack,
|
||||
};
|
||||
|
||||
// Try to get active account from localStorage
|
||||
try {
|
||||
const state = localStorage.getItem("grimoire_v6");
|
||||
if (state) {
|
||||
const parsed = JSON.parse(state);
|
||||
report.context.activeAccount = parsed.activeAccount?.pubkey;
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage read errors
|
||||
}
|
||||
|
||||
// Log to console
|
||||
console.error("[ErrorHandler]", {
|
||||
type: errorType,
|
||||
error: report.error,
|
||||
context: report.context,
|
||||
});
|
||||
|
||||
// Notify callbacks
|
||||
this.callbacks.forEach((callback) => {
|
||||
try {
|
||||
callback(report);
|
||||
} catch (err) {
|
||||
console.error("[ErrorHandler] Callback error:", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap localStorage methods to catch quota exceeded errors
|
||||
*/
|
||||
private wrapLocalStorage() {
|
||||
const originalSetItem = localStorage.setItem.bind(localStorage);
|
||||
|
||||
localStorage.setItem = (key: string, value: string) => {
|
||||
try {
|
||||
originalSetItem(key, value);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof DOMException &&
|
||||
error.name === "QuotaExceededError"
|
||||
) {
|
||||
this.report(
|
||||
new Error(
|
||||
`LocalStorage quota exceeded while saving key: ${key} (${(value.length / 1024).toFixed(2)}KB)`,
|
||||
),
|
||||
"localstorage_quota",
|
||||
);
|
||||
|
||||
// Attempt to free space by removing old data
|
||||
this.handleQuotaExceeded();
|
||||
|
||||
// Try again after cleanup
|
||||
try {
|
||||
originalSetItem(key, value);
|
||||
} catch (retryError) {
|
||||
// If still fails, notify user
|
||||
this.report(
|
||||
new Error(
|
||||
"LocalStorage quota exceeded even after cleanup. Data may be lost.",
|
||||
),
|
||||
"localstorage_quota_critical",
|
||||
);
|
||||
throw retryError;
|
||||
}
|
||||
} else {
|
||||
this.report(error as Error, "localstorage_error");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle localStorage quota exceeded by removing old data
|
||||
*/
|
||||
private handleQuotaExceeded() {
|
||||
console.warn(
|
||||
"[ErrorHandler] LocalStorage quota exceeded, attempting cleanup...",
|
||||
);
|
||||
|
||||
try {
|
||||
// Strategy: Keep critical data, remove caches
|
||||
const keysToKeep = ["grimoire_v6"]; // Core state
|
||||
const keysToRemove: string[] = [];
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && !keysToKeep.includes(key)) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
keysToRemove.forEach((key) => {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
console.log(`[ErrorHandler] Removed localStorage key: ${key}`);
|
||||
} catch {
|
||||
// Ignore removal errors
|
||||
}
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[ErrorHandler] Removed ${keysToRemove.length} localStorage items`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[ErrorHandler] Failed to clean up localStorage:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all error callbacks (for testing)
|
||||
*/
|
||||
clearCallbacks() {
|
||||
this.callbacks = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const errorHandler = new GlobalErrorHandler();
|
||||
|
||||
/**
|
||||
* Initialize error handling with UI notifications
|
||||
*/
|
||||
export function initializeErrorHandling() {
|
||||
errorHandler.initialize();
|
||||
|
||||
// Register default callback for user notifications
|
||||
errorHandler.onError((report) => {
|
||||
const error = report.error;
|
||||
const { errorType } = report.context;
|
||||
|
||||
// Critical errors that require user attention
|
||||
if (errorType === "localstorage_quota_critical") {
|
||||
showErrorToast(
|
||||
"Storage Full",
|
||||
"Your browser storage is full. Some data may not be saved. Consider clearing browser data or exporting your settings.",
|
||||
"error",
|
||||
);
|
||||
} else if (errorType === "localstorage_quota") {
|
||||
showErrorToast(
|
||||
"Storage Warning",
|
||||
"Browser storage is nearly full. Old data was cleaned up automatically.",
|
||||
"warning",
|
||||
);
|
||||
} else if (errorType === "unhandled_rejection") {
|
||||
// Only show user-facing promise rejections
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
if (errorMessage.includes("fetch") || errorMessage.includes("relay")) {
|
||||
showErrorToast(
|
||||
"Network Error",
|
||||
"Failed to connect to relay or fetch data. Please check your connection.",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error toast notification
|
||||
*/
|
||||
function showErrorToast(
|
||||
title: string,
|
||||
message: string,
|
||||
type: "error" | "warning" = "error",
|
||||
) {
|
||||
// Dynamic import to avoid circular dependency
|
||||
import("sonner").then(({ toast }) => {
|
||||
if (type === "error") {
|
||||
toast.error(title, { description: message, duration: 5000 });
|
||||
} else {
|
||||
toast.warning(title, { description: message, duration: 5000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -44,16 +44,15 @@ function formatList(items: string[], maxDisplay: number): string {
|
||||
export function formatEventIds(ids: string[], maxDisplay = 2): string {
|
||||
if (!ids || ids.length === 0) return "";
|
||||
|
||||
const encoded = ids
|
||||
.map((id) => {
|
||||
try {
|
||||
const note = nip19.noteEncode(id);
|
||||
return truncateBech32(note);
|
||||
} catch {
|
||||
// Fallback for invalid IDs: truncate hex
|
||||
return id.length > 16 ? `${id.slice(0, 8)}...${id.slice(-6)}` : id;
|
||||
}
|
||||
});
|
||||
const encoded = ids.map((id) => {
|
||||
try {
|
||||
const note = nip19.noteEncode(id);
|
||||
return truncateBech32(note);
|
||||
} catch {
|
||||
// Fallback for invalid IDs: truncate hex
|
||||
return id.length > 16 ? `${id.slice(0, 8)}...${id.slice(-6)}` : id;
|
||||
}
|
||||
});
|
||||
|
||||
return formatList(encoded, maxDisplay);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,89 @@
|
||||
/**
|
||||
* Structured logging utility with log levels
|
||||
* Only logs debug messages in development mode
|
||||
* Provides context-aware logging with timestamps and user context
|
||||
*/
|
||||
|
||||
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
interface LogContext {
|
||||
timestamp: string;
|
||||
context: string;
|
||||
user?: string;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
context: LogContext;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
class Logger {
|
||||
private context: string;
|
||||
private static logs: LogEntry[] = [];
|
||||
private static maxLogs = 100; // Keep last 100 logs in memory
|
||||
|
||||
constructor(context: string) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
private log(level: LogLevel, message: string, data?: unknown) {
|
||||
// Format message
|
||||
const prefix = `[${this.context}]`;
|
||||
const logMessage = data !== undefined ? [prefix, message, data] : [prefix, message];
|
||||
private getLogContext(action?: string): LogContext {
|
||||
const ctx: LogContext = {
|
||||
timestamp: new Date().toISOString(),
|
||||
context: this.context,
|
||||
};
|
||||
|
||||
// Try to get active user from state
|
||||
try {
|
||||
const state = localStorage.getItem("grimoire_v6");
|
||||
if (state) {
|
||||
const parsed = JSON.parse(state);
|
||||
if (parsed.activeAccount?.pubkey) {
|
||||
ctx.user = parsed.activeAccount.pubkey.slice(0, 8); // First 8 chars for privacy
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
|
||||
if (action) {
|
||||
ctx.action = action;
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private log(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
data?: unknown,
|
||||
action?: string,
|
||||
) {
|
||||
const logContext = this.getLogContext(action);
|
||||
const entry: LogEntry = {
|
||||
level,
|
||||
message,
|
||||
context: logContext,
|
||||
data,
|
||||
};
|
||||
|
||||
// Store in memory for debugging
|
||||
Logger.logs.push(entry);
|
||||
if (Logger.logs.length > Logger.maxLogs) {
|
||||
Logger.logs.shift();
|
||||
}
|
||||
|
||||
// Format for console
|
||||
const prefix = `[${logContext.timestamp}] [${logContext.context}]`;
|
||||
const userInfo = logContext.user ? ` [user:${logContext.user}]` : "";
|
||||
const actionInfo = logContext.action
|
||||
? ` [action:${logContext.action}]`
|
||||
: "";
|
||||
const fullPrefix = `${prefix}${userInfo}${actionInfo}`;
|
||||
|
||||
const logMessage =
|
||||
data !== undefined ? [fullPrefix, message, data] : [fullPrefix, message];
|
||||
|
||||
switch (level) {
|
||||
case "debug":
|
||||
@@ -32,25 +100,48 @@ class Logger {
|
||||
break;
|
||||
case "error":
|
||||
console.error(...logMessage);
|
||||
// Could send to error tracking service here
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
debug(message: string, data?: unknown) {
|
||||
this.log("debug", message, data);
|
||||
debug(message: string, data?: unknown, action?: string) {
|
||||
this.log("debug", message, data, action);
|
||||
}
|
||||
|
||||
info(message: string, data?: unknown) {
|
||||
this.log("info", message, data);
|
||||
info(message: string, data?: unknown, action?: string) {
|
||||
this.log("info", message, data, action);
|
||||
}
|
||||
|
||||
warn(message: string, data?: unknown) {
|
||||
this.log("warn", message, data);
|
||||
warn(message: string, data?: unknown, action?: string) {
|
||||
this.log("warn", message, data, action);
|
||||
}
|
||||
|
||||
error(message: string, error?: unknown) {
|
||||
this.log("error", message, error);
|
||||
error(message: string, error?: unknown, action?: string) {
|
||||
this.log("error", message, error, action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent logs for debugging
|
||||
*/
|
||||
static getRecentLogs(level?: LogLevel): LogEntry[] {
|
||||
if (level) {
|
||||
return Logger.logs.filter((log) => log.level === level);
|
||||
}
|
||||
return [...Logger.logs];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear logs from memory
|
||||
*/
|
||||
static clearLogs() {
|
||||
Logger.logs = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Export logs as JSON for debugging
|
||||
*/
|
||||
static exportLogs(): string {
|
||||
return JSON.stringify(Logger.logs, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
170
src/lib/migrations.test.ts
Normal file
170
src/lib/migrations.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { migrateState, validateState, CURRENT_VERSION } from "./migrations";
|
||||
|
||||
describe("migrations", () => {
|
||||
describe("v6 to v7 migration", () => {
|
||||
it("should convert numeric labels to number field", () => {
|
||||
const oldState = {
|
||||
__version: 6,
|
||||
windows: {},
|
||||
activeWorkspaceId: "ws1",
|
||||
workspaces: {
|
||||
ws1: {
|
||||
id: "ws1",
|
||||
label: "1",
|
||||
layout: null,
|
||||
windowIds: [],
|
||||
},
|
||||
ws2: {
|
||||
id: "ws2",
|
||||
label: "2",
|
||||
layout: null,
|
||||
windowIds: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const migrated = migrateState(oldState);
|
||||
|
||||
expect(migrated.__version).toBe(CURRENT_VERSION);
|
||||
expect(migrated.workspaces.ws1.number).toBe(1);
|
||||
expect(migrated.workspaces.ws1.label).toBeUndefined();
|
||||
expect(migrated.workspaces.ws2.number).toBe(2);
|
||||
expect(migrated.workspaces.ws2.label).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should convert non-numeric labels to number with label", () => {
|
||||
const oldState = {
|
||||
__version: 6,
|
||||
windows: {},
|
||||
activeWorkspaceId: "ws1",
|
||||
workspaces: {
|
||||
ws1: {
|
||||
id: "ws1",
|
||||
label: "Main",
|
||||
layout: null,
|
||||
windowIds: [],
|
||||
},
|
||||
ws2: {
|
||||
id: "ws2",
|
||||
label: "Development",
|
||||
layout: null,
|
||||
windowIds: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const migrated = migrateState(oldState);
|
||||
|
||||
expect(migrated.__version).toBe(CURRENT_VERSION);
|
||||
expect(migrated.workspaces.ws1.number).toBe(1);
|
||||
expect(migrated.workspaces.ws1.label).toBe("Main");
|
||||
expect(migrated.workspaces.ws2.number).toBe(2);
|
||||
expect(migrated.workspaces.ws2.label).toBe("Development");
|
||||
});
|
||||
|
||||
it("should handle mixed numeric and text labels", () => {
|
||||
const oldState = {
|
||||
__version: 6,
|
||||
windows: {},
|
||||
activeWorkspaceId: "ws1",
|
||||
workspaces: {
|
||||
ws1: {
|
||||
id: "ws1",
|
||||
label: "1",
|
||||
layout: null,
|
||||
windowIds: [],
|
||||
},
|
||||
ws2: {
|
||||
id: "ws2",
|
||||
label: "Main",
|
||||
layout: null,
|
||||
windowIds: [],
|
||||
},
|
||||
ws3: {
|
||||
id: "ws3",
|
||||
label: "3",
|
||||
layout: null,
|
||||
windowIds: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const migrated = migrateState(oldState);
|
||||
|
||||
expect(migrated.__version).toBe(CURRENT_VERSION);
|
||||
expect(migrated.workspaces.ws1.number).toBe(1);
|
||||
expect(migrated.workspaces.ws1.label).toBeUndefined();
|
||||
expect(migrated.workspaces.ws2.number).toBe(2);
|
||||
expect(migrated.workspaces.ws2.label).toBe("Main");
|
||||
expect(migrated.workspaces.ws3.number).toBe(3);
|
||||
expect(migrated.workspaces.ws3.label).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should validate migrated state", () => {
|
||||
const oldState = {
|
||||
__version: 6,
|
||||
windows: {},
|
||||
activeWorkspaceId: "ws1",
|
||||
workspaces: {
|
||||
ws1: {
|
||||
id: "ws1",
|
||||
label: "1",
|
||||
layout: null,
|
||||
windowIds: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const migrated = migrateState(oldState);
|
||||
expect(validateState(migrated)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateState", () => {
|
||||
it("should validate correct state structure", () => {
|
||||
const state = {
|
||||
__version: CURRENT_VERSION,
|
||||
windows: {},
|
||||
activeWorkspaceId: "default",
|
||||
workspaces: {
|
||||
default: {
|
||||
id: "default",
|
||||
number: 1,
|
||||
layout: null,
|
||||
windowIds: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(validateState(state)).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject state without __version", () => {
|
||||
const state = {
|
||||
windows: {},
|
||||
activeWorkspaceId: "default",
|
||||
workspaces: {
|
||||
default: {
|
||||
id: "default",
|
||||
number: 1,
|
||||
layout: null,
|
||||
windowIds: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(validateState(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject state with missing workspaces", () => {
|
||||
const state = {
|
||||
__version: CURRENT_VERSION,
|
||||
windows: {},
|
||||
activeWorkspaceId: "default",
|
||||
};
|
||||
|
||||
expect(validateState(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
291
src/lib/migrations.ts
Normal file
291
src/lib/migrations.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* State Migration System
|
||||
*
|
||||
* Handles schema version upgrades and state validation
|
||||
* Ensures data integrity across application updates
|
||||
*/
|
||||
|
||||
import { GrimoireState } from "@/types/app";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const CURRENT_VERSION = 7;
|
||||
|
||||
/**
|
||||
* Migration function type
|
||||
*/
|
||||
type MigrationFn = (state: any) => any;
|
||||
|
||||
/**
|
||||
* Migration registry - add new migrations here
|
||||
* Each migration transforms state from version N to N+1
|
||||
*/
|
||||
const migrations: Record<number, MigrationFn> = {
|
||||
// Migration from v5 to v6 - adds __version field
|
||||
5: (state: any) => {
|
||||
return {
|
||||
__version: 6,
|
||||
...state,
|
||||
};
|
||||
},
|
||||
// Migration from v6 to v7 - separates workspace number from label
|
||||
6: (state: any) => {
|
||||
const migratedWorkspaces: Record<string, any> = {};
|
||||
|
||||
// Convert each workspace from old format (label as string) to new format (number + optional label)
|
||||
for (const [id, workspace] of Object.entries(state.workspaces || {})) {
|
||||
const ws = workspace as any;
|
||||
|
||||
// Try to parse the label as a number
|
||||
const parsedNumber = parseInt(ws.label, 10);
|
||||
|
||||
if (!isNaN(parsedNumber)) {
|
||||
// Label is numeric - use it as the number, no label
|
||||
migratedWorkspaces[id] = {
|
||||
...ws,
|
||||
number: parsedNumber,
|
||||
label: undefined,
|
||||
};
|
||||
} else {
|
||||
// Label is not numeric - assign it the next available number, keep label
|
||||
// Find the highest number used so far
|
||||
const usedNumbers = Object.values(migratedWorkspaces).map(
|
||||
(w: any) => w.number,
|
||||
);
|
||||
const maxNumber = usedNumbers.length > 0 ? Math.max(...usedNumbers) : 0;
|
||||
|
||||
migratedWorkspaces[id] = {
|
||||
...ws,
|
||||
number: maxNumber + 1,
|
||||
label: ws.label,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
__version: 7,
|
||||
workspaces: migratedWorkspaces,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate state structure
|
||||
* Basic checks to ensure state is not corrupted
|
||||
*/
|
||||
export function validateState(state: any): state is GrimoireState {
|
||||
try {
|
||||
// Must be an object
|
||||
if (!state || typeof state !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must have required top-level fields
|
||||
if (
|
||||
!state.windows ||
|
||||
!state.workspaces ||
|
||||
!state.activeWorkspaceId ||
|
||||
typeof state.__version !== "number"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Windows must be an object
|
||||
if (typeof state.windows !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Workspaces must be an object
|
||||
if (typeof state.workspaces !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Active workspace must exist
|
||||
if (!state.workspaces[state.activeWorkspaceId]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// All window IDs in workspaces must exist in windows
|
||||
for (const workspace of Object.values(state.workspaces)) {
|
||||
if (!Array.isArray((workspace as any).windowIds)) {
|
||||
return false;
|
||||
}
|
||||
for (const windowId of (workspace as any).windowIds) {
|
||||
if (!state.windows[windowId]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[Migrations] Validation error:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate state from old version to current version
|
||||
* Applies migrations sequentially
|
||||
*/
|
||||
export function migrateState(state: any): GrimoireState {
|
||||
let currentState = state;
|
||||
const startVersion = state.__version || 5; // Default to 5 if no version
|
||||
|
||||
console.log(
|
||||
`[Migrations] Migrating from v${startVersion} to v${CURRENT_VERSION}`,
|
||||
);
|
||||
|
||||
// Apply migrations sequentially
|
||||
for (let version = startVersion; version < CURRENT_VERSION; version++) {
|
||||
const migration = migrations[version];
|
||||
if (migration) {
|
||||
console.log(`[Migrations] Applying migration v${version} -> v${version + 1}`);
|
||||
try {
|
||||
currentState = migration(currentState);
|
||||
} catch (error) {
|
||||
console.error(`[Migrations] Migration v${version} failed:`, error);
|
||||
throw new Error(
|
||||
`Failed to migrate from version ${version} to ${version + 1}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate migrated state
|
||||
if (!validateState(currentState)) {
|
||||
throw new Error("Migrated state failed validation");
|
||||
}
|
||||
|
||||
return currentState as GrimoireState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load state from localStorage with migration and validation
|
||||
*/
|
||||
export function loadStateWithMigration(
|
||||
key: string,
|
||||
initialState: GrimoireState,
|
||||
): GrimoireState {
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (!stored) {
|
||||
return initialState;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(stored);
|
||||
|
||||
// Check if migration is needed
|
||||
const storedVersion = parsed.__version || 5;
|
||||
if (storedVersion < CURRENT_VERSION) {
|
||||
console.log(
|
||||
`[Migrations] State version outdated (v${storedVersion}), migrating...`,
|
||||
);
|
||||
const migrated = migrateState(parsed);
|
||||
|
||||
// Save migrated state
|
||||
localStorage.setItem(key, JSON.stringify(migrated));
|
||||
|
||||
toast.success("State Updated", {
|
||||
description: `Migrated from v${storedVersion} to v${CURRENT_VERSION}`,
|
||||
});
|
||||
|
||||
return migrated;
|
||||
}
|
||||
|
||||
// Validate current version state
|
||||
if (!validateState(parsed)) {
|
||||
console.warn("[Migrations] State validation failed, using initial state");
|
||||
toast.error("State Corrupted", {
|
||||
description: "Your state was corrupted and has been reset.",
|
||||
});
|
||||
return initialState;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.error("[Migrations] Failed to load state:", error);
|
||||
toast.error("Failed to Load State", {
|
||||
description: "Using default state. Your data may have been lost.",
|
||||
});
|
||||
return initialState;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export state to JSON file
|
||||
*/
|
||||
export function exportState(state: GrimoireState): void {
|
||||
try {
|
||||
const json = JSON.stringify(state, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `grimoire-state-v${state.__version}-${Date.now()}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success("State Exported", {
|
||||
description: "Your state has been downloaded as JSON",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Migrations] Export failed:", error);
|
||||
toast.error("Export Failed", {
|
||||
description: "Could not export state",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import state from JSON file
|
||||
*/
|
||||
export function importState(
|
||||
file: File,
|
||||
callback: (state: GrimoireState) => void,
|
||||
): void {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const json = e.target?.result as string;
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
// Validate and migrate imported state
|
||||
const storedVersion = parsed.__version || 5;
|
||||
let finalState: GrimoireState;
|
||||
|
||||
if (storedVersion < CURRENT_VERSION) {
|
||||
console.log(
|
||||
`[Migrations] Imported state is v${storedVersion}, migrating...`,
|
||||
);
|
||||
finalState = migrateState(parsed);
|
||||
} else if (!validateState(parsed)) {
|
||||
throw new Error("Imported state failed validation");
|
||||
} else {
|
||||
finalState = parsed;
|
||||
}
|
||||
|
||||
callback(finalState);
|
||||
|
||||
toast.success("State Imported", {
|
||||
description: `Loaded state from v${storedVersion}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Migrations] Import failed:", error);
|
||||
toast.error("Import Failed", {
|
||||
description: "Invalid or corrupted state file",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
toast.error("Import Failed", {
|
||||
description: "Could not read file",
|
||||
});
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
}
|
||||
@@ -51,7 +51,11 @@ export async function getRelayInfo(
|
||||
|
||||
const info = await fetchRelayInfo(normalizedUrl);
|
||||
if (info) {
|
||||
await db.relayInfo.put({ url: normalizedUrl, info, fetchedAt: Date.now() });
|
||||
await db.relayInfo.put({
|
||||
url: normalizedUrl,
|
||||
info,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
return info;
|
||||
@@ -72,7 +76,10 @@ export async function getCachedRelayInfo(
|
||||
const cached = await db.relayInfo.get(normalizedUrl);
|
||||
return cached?.info ?? null;
|
||||
} catch (error) {
|
||||
console.warn(`NIP-11: Failed to get cached relay info for ${wsUrl}:`, error);
|
||||
console.warn(
|
||||
`NIP-11: Failed to get cached relay info for ${wsUrl}:`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -86,15 +93,19 @@ export async function getRelayInfoBatch(
|
||||
const results = new Map<string, RelayInformation>();
|
||||
|
||||
// Normalize URLs first
|
||||
const normalizedUrls = wsUrls.map((url) => {
|
||||
try {
|
||||
return normalizeRelayURL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}).filter((url): url is string => url !== null);
|
||||
const normalizedUrls = wsUrls
|
||||
.map((url) => {
|
||||
try {
|
||||
return normalizeRelayURL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((url): url is string => url !== null);
|
||||
|
||||
const infos = await Promise.all(normalizedUrls.map((url) => getRelayInfo(url)));
|
||||
const infos = await Promise.all(
|
||||
normalizedUrls.map((url) => getRelayInfo(url)),
|
||||
);
|
||||
|
||||
infos.forEach((info, i) => {
|
||||
if (info) results.set(normalizedUrls[i], info);
|
||||
|
||||
@@ -68,7 +68,10 @@ export function parseOpenCommand(args: string[]): ParsedOpenCommand {
|
||||
try {
|
||||
return normalizeRelayURL(url);
|
||||
} catch (error) {
|
||||
console.warn(`Skipping invalid relay hint in nevent: ${url}`, error);
|
||||
console.warn(
|
||||
`Skipping invalid relay hint in nevent: ${url}`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
@@ -87,7 +90,10 @@ export function parseOpenCommand(args: string[]): ParsedOpenCommand {
|
||||
try {
|
||||
return normalizeRelayURL(url);
|
||||
} catch (error) {
|
||||
console.warn(`Skipping invalid relay hint in naddr: ${url}`, error);
|
||||
console.warn(
|
||||
`Skipping invalid relay hint in naddr: ${url}`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
|
||||
@@ -14,7 +14,9 @@ describe("normalizeRelayURL", () => {
|
||||
|
||||
it("should normalize URLs with and without trailing slash to the same value", () => {
|
||||
const withTrailingSlash = normalizeRelayURL("wss://theforest.nostr1.com/");
|
||||
const withoutTrailingSlash = normalizeRelayURL("wss://theforest.nostr1.com");
|
||||
const withoutTrailingSlash = normalizeRelayURL(
|
||||
"wss://theforest.nostr1.com",
|
||||
);
|
||||
expect(withTrailingSlash).toBe(withoutTrailingSlash);
|
||||
});
|
||||
|
||||
@@ -64,7 +66,9 @@ describe("normalizeRelayURL", () => {
|
||||
});
|
||||
|
||||
it("should handle complex URLs with path, port, and query", () => {
|
||||
const result = normalizeRelayURL("wss://relay.example.com:8080/path?key=value");
|
||||
const result = normalizeRelayURL(
|
||||
"wss://relay.example.com:8080/path?key=value",
|
||||
);
|
||||
expect(result).toBe("wss://relay.example.com:8080/path?key=value");
|
||||
});
|
||||
|
||||
@@ -79,7 +83,9 @@ describe("normalizeRelayURL", () => {
|
||||
});
|
||||
|
||||
it("should throw on whitespace-only string", () => {
|
||||
expect(() => normalizeRelayURL(" ")).toThrow("Relay URL cannot be empty");
|
||||
expect(() => normalizeRelayURL(" ")).toThrow(
|
||||
"Relay URL cannot be empty",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw TypeError on null input", () => {
|
||||
@@ -89,7 +95,9 @@ describe("normalizeRelayURL", () => {
|
||||
|
||||
it("should throw TypeError on undefined input", () => {
|
||||
expect(() => normalizeRelayURL(undefined as any)).toThrow(TypeError);
|
||||
expect(() => normalizeRelayURL(undefined as any)).toThrow("must be a string");
|
||||
expect(() => normalizeRelayURL(undefined as any)).toThrow(
|
||||
"must be a string",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw TypeError on non-string input (number)", () => {
|
||||
@@ -111,7 +119,9 @@ describe("normalizeRelayURL", () => {
|
||||
});
|
||||
|
||||
it("should handle URLs with special characters in query", () => {
|
||||
const result = normalizeRelayURL("wss://relay.example.com?key=<script>alert('xss')</script>");
|
||||
const result = normalizeRelayURL(
|
||||
"wss://relay.example.com?key=<script>alert('xss')</script>",
|
||||
);
|
||||
expect(result).toContain("wss://relay.example.com/");
|
||||
// Note: URL encoding is handled by browser's URL parsing
|
||||
});
|
||||
|
||||
@@ -18,9 +18,7 @@ import { normalizeURL as applesauceNormalizeURL } from "applesauce-core/helpers"
|
||||
export function normalizeRelayURL(url: string): string {
|
||||
// Input validation
|
||||
if (typeof url !== "string") {
|
||||
throw new TypeError(
|
||||
`Relay URL must be a string, received: ${typeof url}`
|
||||
);
|
||||
throw new TypeError(`Relay URL must be a string, received: ${typeof url}`);
|
||||
}
|
||||
|
||||
const trimmed = url.trim();
|
||||
@@ -44,7 +42,7 @@ export function normalizeRelayURL(url: string): string {
|
||||
throw new Error(
|
||||
`Failed to normalize relay URL "${url}": ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,11 +79,7 @@ describe("parseReqCommand", () => {
|
||||
it("should combine explicit relays with nprofile relay hints and normalize all", () => {
|
||||
const nprofile =
|
||||
"nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p";
|
||||
const result = parseReqCommand([
|
||||
"-a",
|
||||
nprofile,
|
||||
"wss://relay.damus.io",
|
||||
]);
|
||||
const result = parseReqCommand(["-a", nprofile, "wss://relay.damus.io"]);
|
||||
// All relays should be normalized
|
||||
expect(result.relays).toEqual([
|
||||
"wss://r.x.com/",
|
||||
@@ -144,10 +140,7 @@ describe("parseReqCommand", () => {
|
||||
const hex = "a".repeat(64);
|
||||
const npub =
|
||||
"npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6";
|
||||
const result = parseReqCommand([
|
||||
"-a",
|
||||
`${hex},${npub},user@domain.com`,
|
||||
]);
|
||||
const result = parseReqCommand(["-a", `${hex},${npub},user@domain.com`]);
|
||||
expect(result.filter.authors).toEqual([
|
||||
hex,
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
|
||||
@@ -239,10 +232,7 @@ describe("parseReqCommand", () => {
|
||||
const hex = "a".repeat(64);
|
||||
const npub =
|
||||
"npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6";
|
||||
const result = parseReqCommand([
|
||||
"-p",
|
||||
`${hex},${npub},user@domain.com`,
|
||||
]);
|
||||
const result = parseReqCommand(["-p", `${hex},${npub},user@domain.com`]);
|
||||
expect(result.filter["#p"]).toEqual([
|
||||
hex,
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
|
||||
@@ -364,10 +354,7 @@ describe("parseReqCommand", () => {
|
||||
});
|
||||
|
||||
it("should normalize relays with and without trailing slash to same value", () => {
|
||||
const result = parseReqCommand([
|
||||
"wss://relay.com",
|
||||
"wss://relay.com/",
|
||||
]);
|
||||
const result = parseReqCommand(["wss://relay.com", "wss://relay.com/"]);
|
||||
// Should deduplicate because they normalize to the same URL
|
||||
expect(result.relays).toEqual(["wss://relay.com/", "wss://relay.com/"]);
|
||||
});
|
||||
@@ -469,11 +456,7 @@ describe("parseReqCommand", () => {
|
||||
});
|
||||
|
||||
it("should parse comma-separated values with spaces", () => {
|
||||
const result = parseReqCommand([
|
||||
"--tag",
|
||||
"a",
|
||||
"value1, value2, value3",
|
||||
]);
|
||||
const result = parseReqCommand(["--tag", "a", "value1, value2, value3"]);
|
||||
expect(result.filter["#a"]).toEqual(["value1", "value2", "value3"]);
|
||||
});
|
||||
|
||||
|
||||
41
src/main.tsx
41
src/main.tsx
@@ -6,26 +6,33 @@ import "./index.css";
|
||||
import "react-mosaic-component/react-mosaic-component.css";
|
||||
import { Toaster } from "sonner";
|
||||
import { TooltipProvider } from "./components/ui/tooltip";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { initializeErrorHandling } from "./lib/error-handler";
|
||||
|
||||
// Add dark class to html element for default dark theme
|
||||
document.documentElement.classList.add("dark");
|
||||
|
||||
// Initialize global error handling
|
||||
initializeErrorHandling();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<EventStoreProvider eventStore={eventStore}>
|
||||
<TooltipProvider>
|
||||
<Toaster
|
||||
position="top-center"
|
||||
theme="dark"
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: "hsl(var(--background))",
|
||||
color: "hsl(var(--foreground))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Root />
|
||||
</TooltipProvider>
|
||||
</EventStoreProvider>,
|
||||
<ErrorBoundary level="app">
|
||||
<EventStoreProvider eventStore={eventStore}>
|
||||
<TooltipProvider>
|
||||
<Toaster
|
||||
position="top-center"
|
||||
theme="dark"
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: "hsl(var(--background))",
|
||||
color: "hsl(var(--foreground))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Root />
|
||||
</TooltipProvider>
|
||||
</EventStoreProvider>
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
@@ -114,7 +114,9 @@ class RelayStateManager {
|
||||
connected: relay.connected$.pipe(startWith(relay.connected)),
|
||||
notices: relay.notice$.pipe(
|
||||
startWith(Array.isArray(relay.notices) ? relay.notices : []),
|
||||
map(notice => Array.isArray(notice) ? notice : (notice ? [notice] : []))
|
||||
map((notice) =>
|
||||
Array.isArray(notice) ? notice : notice ? [notice] : [],
|
||||
),
|
||||
),
|
||||
challenge: relay.challenge$.pipe(startWith(relay.challenge)),
|
||||
authenticated: relay.authenticated$.pipe(startWith(relay.authenticated)),
|
||||
@@ -201,7 +203,8 @@ class RelayStateManager {
|
||||
// Priority 3: New challenge (or challenge change)
|
||||
else if (
|
||||
challenge &&
|
||||
(!state.currentChallenge || state.currentChallenge.challenge !== challenge)
|
||||
(!state.currentChallenge ||
|
||||
state.currentChallenge.challenge !== challenge)
|
||||
) {
|
||||
const preference = this.authPreferences.get(url);
|
||||
authEvent = { type: "CHALLENGE_RECEIVED", challenge, preference };
|
||||
@@ -220,9 +223,12 @@ class RelayStateManager {
|
||||
if (authEvent) {
|
||||
const transition = transitionAuthState(state.authStatus, authEvent);
|
||||
|
||||
logger.info(`${url} auth transition: ${state.authStatus} → ${transition.newStatus}`, {
|
||||
event: authEvent.type,
|
||||
});
|
||||
logger.info(
|
||||
`${url} auth transition: ${state.authStatus} → ${transition.newStatus}`,
|
||||
{
|
||||
event: authEvent.type,
|
||||
},
|
||||
);
|
||||
|
||||
// Update state
|
||||
state.authStatus = transition.newStatus;
|
||||
@@ -537,9 +543,7 @@ class RelayStateManager {
|
||||
state.currentChallenge &&
|
||||
this.isChallengeExpired(state.currentChallenge.receivedAt)
|
||||
) {
|
||||
console.log(
|
||||
`[RelayStateManager] Challenge expired for ${state.url}`,
|
||||
);
|
||||
console.log(`[RelayStateManager] Challenge expired for ${state.url}`);
|
||||
state.currentChallenge = undefined;
|
||||
if (state.authStatus === "challenge_received") {
|
||||
state.authStatus = "none";
|
||||
|
||||
@@ -27,7 +27,8 @@ export interface WindowInstance {
|
||||
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
label: string;
|
||||
number: number; // Numeric identifier for shortcuts (e.g., Cmd+1, Cmd+2)
|
||||
label?: string; // Optional user-editable label
|
||||
layout: MosaicNode<string> | null;
|
||||
windowIds: string[];
|
||||
}
|
||||
@@ -45,6 +46,7 @@ export interface UserRelays {
|
||||
}
|
||||
|
||||
export interface GrimoireState {
|
||||
__version: number; // Schema version for migrations
|
||||
windows: Record<string, WindowInstance>;
|
||||
workspaces: Record<string, Workspace>;
|
||||
activeWorkspaceId: string;
|
||||
|
||||
Reference in New Issue
Block a user