diff --git a/src/components/CommandLauncher.tsx b/src/components/CommandLauncher.tsx index 37043ca..a82ea39 100644 --- a/src/components/CommandLauncher.tsx +++ b/src/components/CommandLauncher.tsx @@ -109,83 +109,84 @@ export default function CommandLauncher({ Command Launcher - -
- + +
+ - - - {commandName - ? `No command found: ${commandName}` - : "Start typing..."} - + + + {commandName + ? `No command found: ${commandName}` + : "Start typing..."} + - {categories.map((category) => ( - - {filteredCommands - .filter(([_, cmd]) => cmd.category === category) - .map(([name, cmd]) => { - const isExactMatch = name === commandName; - return ( - handleSelect(name)} - className="command-item" - data-exact-match={isExactMatch} - > -
-
- {name} - {cmd.synopsis !== name && ( - - {cmd.synopsis.replace(name, "").trim()} - - )} - {isExactMatch && ( - - ✓ - - )} + {categories.map((category) => ( + + {filteredCommands + .filter(([_, cmd]) => cmd.category === category) + .map(([name, cmd]) => { + const isExactMatch = name === commandName; + return ( + handleSelect(name)} + className="command-item" + data-exact-match={isExactMatch} + > +
+
+ {name} + {cmd.synopsis !== name && ( + + {cmd.synopsis.replace(name, "").trim()} + + )} + {isExactMatch && ( + + ✓ + + )} +
+
+ {cmd.description.split(".")[0]} +
-
- {cmd.description.split(".")[0]} -
-
- - ); - })} - - ))} - + + ); + })} + + ))} + -
-
- ↑↓ navigate - execute - esc close +
+
+ ↑↓ navigate + execute + esc close +
+ {recognizedCommand && ( +
Ready to execute
+ )}
- {recognizedCommand && ( -
Ready to execute
- )}
-
- - - + + + ); } diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx index 68d1c7b..7f9a339 100644 --- a/src/components/DynamicWindowTitle.tsx +++ b/src/components/DynamicWindowTitle.tsx @@ -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) { diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..e4bfecc --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -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 { + constructor(props: Props) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + 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 ( +
+
+
+ +

Application Error

+
+ +
+

+ Grimoire encountered a critical error and cannot continue. You + can try reloading the application or clearing your browser + data. +

+ + {error && ( +
+
+ {error.name}: {error.message} +
+ {error.stack && ( +
+                        {error.stack}
+                      
+ )} +
+ )} + + {errorInfo?.componentStack && ( +
+ + Component Stack + +
+                      {errorInfo.componentStack}
+                    
+
+ )} +
+ +
+ + +
+ +

+ If this problem persists, please report it on GitHub with the + error details above. +

+
+
+ ); + } + + // Window-level error: inline error display + return ( +
+
+
+ +

Window Error

+
+ +

+ This window encountered an error. You can try reopening it or + continue using other windows. +

+ + {error && ( +
+
+ {error.name}: {error.message} +
+
+ )} + + +
+
+ ); + } + + return children; + } +} diff --git a/src/components/Home.tsx b/src/components/Home.tsx index 4bdccbe..4c4ee2f 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -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" > diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index fb19df5..c32cd3b 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -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 (
- {Object.values(workspaces).map((ws) => ( + {sortedWorkspaces.map((ws) => ( ))} diff --git a/src/components/WinViewer.tsx b/src/components/WinViewer.tsx index a7f2359..a3f6fb4 100644 --- a/src/components/WinViewer.tsx +++ b/src/components/WinViewer.tsx @@ -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 diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 8827168..440f3d9 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -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 ( +
+
+ +

Loading...

+
+
+ ); +} interface WindowRendererProps { window: WindowInstance; @@ -168,7 +192,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { return ( -
{content}
+ }> +
{content}
+
); } diff --git a/src/components/WindowTitle.tsx b/src/components/WindowTitle.tsx index 5373201..246a6d6 100644 --- a/src/components/WindowTitle.tsx +++ b/src/components/WindowTitle.tsx @@ -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 ( - onClose(id)} /> + + onClose(id)} /> + ); } diff --git a/src/components/WindowToolbar.tsx b/src/components/WindowToolbar.tsx index 0a43699..438f078 100644 --- a/src/components/WindowToolbar.tsx +++ b/src/components/WindowToolbar.tsx @@ -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" > @@ -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" > diff --git a/src/components/nostr/RichText/Mention.tsx b/src/components/nostr/RichText/Mention.tsx index 428a85c..02f0732 100644 --- a/src/components/nostr/RichText/Mention.tsx +++ b/src/components/nostr/RichText/Mention.tsx @@ -79,7 +79,8 @@ export function Mention({ node }: MentionNodeProps) { if (!options.showEventEmbeds) { return ( - {node.encoded || `naddr:${pointer.identifier || pointer.pubkey.slice(0, 8)}...`} + {node.encoded || + `naddr:${pointer.identifier || pointer.pubkey.slice(0, 8)}...`} ); } diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx index 7c90917..15fbfc9 100644 --- a/src/components/nostr/user-menu.tsx +++ b/src/components/nostr/user-menu.tsx @@ -83,7 +83,7 @@ export default function UserMenu() { return ( -