feat: debug command, simplify state

This commit is contained in:
Alejandro Gómez
2025-12-18 23:32:00 +01:00
parent 1b9e50ed89
commit 812b719ea0
9 changed files with 97 additions and 110 deletions

View File

@@ -1,6 +1,7 @@
import { useGrimoire } from "@/core/state";
import { Copy, Check } from "lucide-react";
import { useCopy } from "@/hooks/useCopy";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
import { CodeCopyButton } from "@/components/CodeCopyButton";
export function DebugViewer() {
const { state } = useGrimoire();
@@ -8,32 +9,26 @@ export function DebugViewer() {
const stateJson = JSON.stringify(state, null, 2);
const handleCopy = () => {
copy(stateJson);
};
return (
<div className="h-full w-full flex flex-col">
<div className="flex items-center justify-between p-4 border-b">
<h2 className="text-lg font-semibold">Application State</h2>
<button
onClick={() => copy(stateJson)}
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-md hover:bg-muted transition-colors"
title="Copy state to clipboard"
>
{copied ? (
<>
<Check className="h-4 w-4 text-green-500" />
<span>Copied</span>
</>
) : (
<>
<Copy className="h-4 w-4" />
<span>Copy</span>
</>
)}
</button>
</div>
<div className="flex-1 overflow-auto p-4">
<pre className="text-xs font-mono bg-muted rounded-md p-4 overflow-x-auto">
{stateJson}
</pre>
<div className="flex-1 overflow-auto relative">
<SyntaxHighlight
code={stateJson}
language="json"
className="bg-muted p-4"
/>
<CodeCopyButton
onCopy={handleCopy}
copied={copied}
label="Copy state"
/>
</div>
</div>
);

View File

@@ -55,7 +55,7 @@ import {
AccordionTrigger,
} from "./ui/accordion";
import { RelayLink } from "./nostr/RelayLink";
import type { NostrFilter, NostrEvent } from "@/types/nostr";
import type { NostrFilter } from "@/types/nostr";
import {
formatEventIds,
formatDTags,
@@ -678,8 +678,10 @@ export default function ReqViewer({
// Memoize fallbackRelays to prevent re-creation on every render
const fallbackRelays = useMemo(
() =>
state.activeAccount?.relays?.inbox.map((r) => r.url) || AGGREGATOR_RELAYS,
[state.activeAccount?.relays?.inbox],
state.activeAccount?.relays
?.filter((r) => r.read)
.map((r) => r.url) || AGGREGATOR_RELAYS,
[state.activeAccount?.relays],
);
// Memoize outbox options to prevent object re-creation
@@ -749,34 +751,38 @@ export default function ReqViewer({
// Virtuoso scroll position preservation for prepending events
const STARTING_INDEX = 100000;
const [firstItemIndex, setFirstItemIndex] = useState(STARTING_INDEX);
const prevEventsRef = useRef<NostrEvent[]>([]);
const seenEventIdsRef = useRef<Set<string>>(new Set());
// Adjust firstItemIndex when new events are prepended to preserve scroll position
// Uses Set-based tracking to handle rapid batches correctly
useEffect(() => {
const prevEvents = prevEventsRef.current;
const currentEvents = events;
const prevLength = prevEvents.length;
const currentLength = currentEvents.length;
// Reset on clear/query change (events array shrunk)
if (currentLength < prevLength) {
// Reset on query change (events cleared)
if (events.length === 0) {
seenEventIdsRef.current = new Set();
setFirstItemIndex(STARTING_INDEX);
prevEventsRef.current = currentEvents;
return;
}
// Detect new events prepended (only in streaming mode after EOSE)
const newEventsCount = currentLength - prevLength;
if (newEventsCount > 0 && prevLength > 0 && stream && eoseReceived) {
// Verify first event changed (events were prepended, not inserted in middle)
const firstIdChanged = currentEvents[0]?.id !== prevEvents[0]?.id;
if (firstIdChanged) {
// Decrement firstItemIndex to maintain scroll position
setFirstItemIndex((prev) => prev - newEventsCount);
// Find new events at the start of the array (prepended)
// This approach is immune to rapid updates because we track ALL seen IDs cumulatively
let prependCount = 0;
for (let i = 0; i < events.length; i++) {
const event = events[i];
if (!seenEventIdsRef.current.has(event.id)) {
// New event found at position i
prependCount++;
seenEventIdsRef.current.add(event.id);
} else {
// Found first existing event, stop counting
// All events after this are old (already seen)
break;
}
}
prevEventsRef.current = currentEvents;
// Adjust index only in streaming mode after EOSE
if (prependCount > 0 && stream && eoseReceived) {
setFirstItemIndex((prev) => prev - prependCount);
}
}, [events, stream, eoseReceived]);
/**

View File

@@ -107,14 +107,14 @@ export default function UserMenu() {
</DropdownMenuLabel>
</DropdownMenuGroup>
{relays && relays.all.length > 0 && (
{relays && relays.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
Relays
</DropdownMenuLabel>
{relays.all.map((relay) => (
{relays.map((relay) => (
<RelayLink
className="px-2 py-1"
urlClassname="text-sm"

View File

@@ -3,7 +3,7 @@ import type { MosaicNode } from "react-mosaic-component";
import {
GrimoireState,
WindowInstance,
UserRelays,
RelayInfo,
LayoutConfig,
} from "@/types/app";
import { insertWindow } from "@/lib/layout-utils";
@@ -273,7 +273,7 @@ export const setActiveAccount = (
*/
export const setActiveAccountRelays = (
state: GrimoireState,
relays: UserRelays,
relays: RelayInfo[],
): GrimoireState => {
if (!state.activeAccount) {
return state;

View File

@@ -6,6 +6,7 @@ import {
AppId,
WindowInstance,
LayoutConfig,
RelayInfo,
} from "@/types/app";
import { useLocale } from "@/hooks/useLocale";
import * as Logic from "./logic";
@@ -255,7 +256,7 @@ export const useGrimoire = () => {
);
const setActiveAccountRelays = useCallback(
(relays: any) =>
(relays: RelayInfo[]) =>
setState((prev) => Logic.setActiveAccountRelays(prev, relays)),
[setState],
);

View File

@@ -2,9 +2,8 @@ import { useEffect } from "react";
import { useEventStore, useObservableMemo } from "applesauce-react/hooks";
import accounts from "@/services/accounts";
import { useGrimoire } from "@/core/state";
import { getInboxes, getOutboxes } from "applesauce-core/helpers";
import { addressLoader } from "@/services/loaders";
import type { RelayInfo, UserRelays } from "@/types/app";
import type { RelayInfo } from "@/types/app";
import { normalizeRelayURL } from "@/lib/relay-url";
/**
@@ -48,12 +47,10 @@ export function useAccountSync() {
if (relayListEvent.id === lastRelayEventId) return;
lastRelayEventId = relayListEvent.id;
// Parse inbox and outbox relays
const inboxRelays = getInboxes(relayListEvent);
const outboxRelays = getOutboxes(relayListEvent);
// Get all relays from tags
const allRelays: RelayInfo[] = [];
// Parse relays from tags (NIP-65 format)
// Tag format: ["r", "relay-url", "read|write"]
// If no marker, relay is used for both read and write
const relays: RelayInfo[] = [];
const seenUrls = new Set<string>();
for (const tag of relayListEvent.tags) {
@@ -63,11 +60,11 @@ export function useAccountSync() {
if (seenUrls.has(url)) continue;
seenUrls.add(url);
const type = tag[2];
allRelays.push({
const marker = tag[2];
relays.push({
url,
read: !type || type === "read",
write: !type || type === "write",
read: !marker || marker === "read",
write: !marker || marker === "write",
});
} catch (error) {
console.warn(
@@ -78,36 +75,6 @@ export function useAccountSync() {
}
}
const relays: UserRelays = {
inbox: inboxRelays
.map((url) => {
try {
return {
url: normalizeRelayURL(url),
read: true,
write: false,
};
} catch {
return null;
}
})
.filter((r): r is RelayInfo => r !== null),
outbox: outboxRelays
.map((url) => {
try {
return {
url: normalizeRelayURL(url),
read: false,
write: true,
};
} catch {
return null;
}
})
.filter((r): r is RelayInfo => r !== null),
all: allRelays,
};
setActiveAccountRelays(relays);
});

View File

@@ -8,7 +8,7 @@
import { GrimoireState } from "@/types/app";
import { toast } from "sonner";
export const CURRENT_VERSION = 8;
export const CURRENT_VERSION = 9;
/**
* Migration function type
@@ -81,6 +81,30 @@ const migrations: Record<number, MigrationFn> = {
},
};
},
// Migration from v8 to v9 - simplifies relay structure
8: (state: any) => {
// Simplify activeAccount.relays from {inbox, outbox, all} to just an array
// The 'all' array already has the correct read/write flags per relay
if (state.activeAccount?.relays) {
const oldRelays = state.activeAccount.relays;
// If it has the old structure (with inbox/outbox/all), migrate it
if (oldRelays.all && Array.isArray(oldRelays.all)) {
return {
...state,
__version: 9,
activeAccount: {
...state.activeAccount,
relays: oldRelays.all,
},
};
}
}
// No relays to migrate, just bump version
return {
...state,
__version: 9,
};
},
};
/**

View File

@@ -72,12 +72,6 @@ export interface RelayInfo {
write: boolean;
}
export interface UserRelays {
inbox: RelayInfo[];
outbox: RelayInfo[];
all: RelayInfo[];
}
export interface GrimoireState {
__version: number; // Schema version for migrations
windows: Record<string, WindowInstance>;
@@ -86,7 +80,7 @@ export interface GrimoireState {
layoutConfig: LayoutConfig; // Global configuration for window insertion behavior
activeAccount?: {
pubkey: string;
relays?: UserRelays;
relays?: RelayInfo[];
};
locale?: {
locale: string;

View File

@@ -108,18 +108,18 @@ export const manPages: Record<string, ManPageEntry> = {
category: "Documentation",
defaultProps: {},
},
// debug: {
// name: "debug",
// section: "1",
// synopsis: "debug",
// description:
// "Display the current application state for debugging purposes. Shows windows, workspaces, active account, and other internal state in a formatted view.",
// examples: ["debug View current application state"],
// seeAlso: ["help"],
// appId: "debug",
// category: "System",
// defaultProps: {},
// },
debug: {
name: "debug",
section: "1",
synopsis: "debug",
description:
"Display the current application state for debugging purposes. Shows windows, workspaces, active account, and other internal state in a formatted view.",
examples: ["debug View current application state"],
seeAlso: ["help"],
appId: "debug",
category: "System",
defaultProps: {},
},
man: {
name: "man",
section: "1",