mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 23:47:12 +02:00
chore: remove planning docs
This commit is contained in:
@@ -1,870 +0,0 @@
|
||||
# Design: Editable Window Commands & Command History
|
||||
|
||||
**Created:** 2025-12-12
|
||||
**Status:** Design Complete - Ready for Implementation
|
||||
**Complexity:** High (5 implementation phases)
|
||||
|
||||
## Overview
|
||||
|
||||
Enable users to edit the command that created a window (e.g., change `profile alice@domain.com` to `profile bob@domain.com`) and provide full undo/redo capabilities for all window operations.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Editable Commands Architecture](#1-editable-commands-architecture)
|
||||
2. [Command History Architecture](#2-command-history-architecture)
|
||||
3. [User Interface Components](#3-user-interface-components)
|
||||
4. [Implementation Phases](#4-implementation-phases)
|
||||
5. [Key Design Decisions](#5-key-design-decisions--rationale)
|
||||
6. [Edge Cases](#6-edge-cases-handled)
|
||||
7. [Testing Strategy](#7-testing-strategy)
|
||||
8. [Performance](#8-performance-considerations)
|
||||
9. [Documentation Updates](#9-documentation-updates)
|
||||
|
||||
---
|
||||
|
||||
## 1. Editable Commands Architecture
|
||||
|
||||
### Schema Changes
|
||||
|
||||
```typescript
|
||||
// src/types/app.ts - Add commandString to WindowInstance
|
||||
interface WindowInstance {
|
||||
id: string;
|
||||
appId: AppId;
|
||||
title: string;
|
||||
props: any;
|
||||
commandString?: string; // NEW: Original command (e.g., "profile alice@domain.com")
|
||||
}
|
||||
```
|
||||
|
||||
**Backward Compatibility:** Optional field (?) means existing windows continue working without migration.
|
||||
|
||||
### State Management
|
||||
|
||||
```typescript
|
||||
// src/core/logic.ts - New function
|
||||
export const updateWindow = (
|
||||
state: GrimoireState,
|
||||
windowId: string,
|
||||
updates: {
|
||||
props?: any;
|
||||
title?: string;
|
||||
commandString?: string;
|
||||
appId?: AppId;
|
||||
}
|
||||
): GrimoireState => {
|
||||
const window = state.windows[windowId];
|
||||
if (!window) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
windows: {
|
||||
...state.windows,
|
||||
[windowId]: { ...window, ...updates },
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Command Flow
|
||||
|
||||
```
|
||||
User types command
|
||||
↓
|
||||
CommandLauncher parses input
|
||||
↓
|
||||
Async argParser resolves (e.g., NIP-05)
|
||||
↓
|
||||
addWindow(appId, props, title, commandString)
|
||||
↓
|
||||
Window created with commandString stored
|
||||
↓
|
||||
User clicks edit button (⌘E)
|
||||
↓
|
||||
EditCommandDialog shows commandString
|
||||
↓
|
||||
User edits and submits
|
||||
↓
|
||||
Re-parse command (async)
|
||||
↓
|
||||
updateWindow(windowId, newProps, newTitle, newCommandString)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Command History Architecture
|
||||
|
||||
### Data Structures
|
||||
|
||||
```typescript
|
||||
// src/types/history.ts
|
||||
|
||||
type HistoryAction =
|
||||
| { type: 'COMMAND_EXECUTED'; commandString: string; windowId?: string }
|
||||
| { type: 'WINDOW_CLOSED'; windowId: string }
|
||||
| { type: 'WINDOW_EDITED'; windowId: string; oldCommand: string; newCommand: string }
|
||||
| { type: 'WORKSPACE_CREATED'; workspaceId: string; label: string }
|
||||
| { type: 'LAYOUT_CHANGED'; workspaceId: string };
|
||||
|
||||
interface HistoryEntry {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
action: HistoryAction;
|
||||
stateBefore: GrimoireState; // For undo
|
||||
stateAfter: GrimoireState; // For redo
|
||||
}
|
||||
|
||||
interface CommandHistory {
|
||||
entries: HistoryEntry[]; // Newest first
|
||||
currentIndex: number; // -1 = present, 0+ = steps back in time
|
||||
maxEntries: number; // Default: 50 (circular buffer)
|
||||
}
|
||||
```
|
||||
|
||||
### History State Machine
|
||||
|
||||
```
|
||||
Present State (currentIndex = -1)
|
||||
│
|
||||
├─ Undo → currentIndex = 0 (1 step back)
|
||||
│ └─ Undo → currentIndex = 1 (2 steps back)
|
||||
│ ├─ Redo → currentIndex = 0
|
||||
│ └─ New Action → Truncate future, currentIndex = -1
|
||||
│
|
||||
└─ New Action → Add entry, currentIndex = -1
|
||||
```
|
||||
|
||||
**Key Insight:** currentIndex is a time cursor. -1 means "present", positive numbers mean "N steps back in history."
|
||||
|
||||
### Core History Functions
|
||||
|
||||
```typescript
|
||||
// src/core/history.ts
|
||||
|
||||
export const recordAction = (
|
||||
history: CommandHistory,
|
||||
action: HistoryAction,
|
||||
stateBefore: GrimoireState,
|
||||
stateAfter: GrimoireState
|
||||
): CommandHistory => {
|
||||
// Truncate future if in past state (Git-style)
|
||||
const entries = history.currentIndex === -1
|
||||
? history.entries
|
||||
: history.entries.slice(history.currentIndex + 1);
|
||||
|
||||
const newEntry: HistoryEntry = {
|
||||
id: uuidv4(),
|
||||
timestamp: Date.now(),
|
||||
action,
|
||||
stateBefore,
|
||||
stateAfter,
|
||||
};
|
||||
|
||||
// Circular buffer: keep only last N entries
|
||||
const newEntries = [newEntry, ...entries].slice(0, history.maxEntries);
|
||||
|
||||
return {
|
||||
...history,
|
||||
entries: newEntries,
|
||||
currentIndex: -1, // Back to present
|
||||
};
|
||||
};
|
||||
|
||||
export const undo = (
|
||||
history: CommandHistory,
|
||||
currentState: GrimoireState
|
||||
): { history: CommandHistory; newState: GrimoireState } => {
|
||||
if (history.currentIndex >= history.entries.length - 1) {
|
||||
return { history, newState: currentState }; // Can't undo further
|
||||
}
|
||||
|
||||
const targetIndex = history.currentIndex + 1;
|
||||
const targetEntry = history.entries[targetIndex];
|
||||
|
||||
return {
|
||||
history: { ...history, currentIndex: targetIndex },
|
||||
newState: targetEntry.stateBefore,
|
||||
};
|
||||
};
|
||||
|
||||
export const redo = (
|
||||
history: CommandHistory,
|
||||
currentState: GrimoireState
|
||||
): { history: CommandHistory; newState: GrimoireState } => {
|
||||
if (history.currentIndex <= -1) {
|
||||
return { history, newState: currentState }; // Already at present
|
||||
}
|
||||
|
||||
const currentEntry = history.entries[history.currentIndex];
|
||||
|
||||
return {
|
||||
history: { ...history, currentIndex: history.currentIndex - 1 },
|
||||
newState: currentEntry.stateAfter,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Integration with State Updates
|
||||
|
||||
```typescript
|
||||
// src/core/state.ts
|
||||
|
||||
export const useGrimoire = () => {
|
||||
const [state, setState] = useAtom(grimoireStateAtom);
|
||||
const [history, setHistory] = useAtom(historyAtom);
|
||||
|
||||
const addWindow = useCallback((appId: AppId, props: any, title?: string, commandString?: string) => {
|
||||
setState((prevState) => {
|
||||
const nextState = Logic.addWindow(prevState, {
|
||||
appId,
|
||||
props,
|
||||
title: title || appId.toUpperCase(),
|
||||
commandString
|
||||
});
|
||||
|
||||
// Record history atomically
|
||||
setHistory(prev => History.recordAction(prev, {
|
||||
type: 'COMMAND_EXECUTED',
|
||||
commandString: commandString || generateRawCommand(appId, props),
|
||||
}, prevState, nextState));
|
||||
|
||||
return nextState;
|
||||
});
|
||||
}, [setState, setHistory]);
|
||||
|
||||
const performUndo = useCallback(() => {
|
||||
const { history: newHistory, newState } = History.undo(history, state);
|
||||
setState(newState);
|
||||
setHistory(newHistory);
|
||||
}, [history, state, setState, setHistory]);
|
||||
|
||||
const performRedo = useCallback(() => {
|
||||
const { history: newHistory, newState } = History.redo(history, state);
|
||||
setState(newState);
|
||||
setHistory(newHistory);
|
||||
}, [history, state, setState, setHistory]);
|
||||
|
||||
return {
|
||||
state,
|
||||
addWindow,
|
||||
updateWindow,
|
||||
performUndo,
|
||||
performRedo,
|
||||
// ... other methods
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. User Interface Components
|
||||
|
||||
### Edit Command Dialog
|
||||
|
||||
```typescript
|
||||
// src/components/EditCommandDialog.tsx
|
||||
|
||||
interface EditCommandDialogProps {
|
||||
window: WindowInstance;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function EditCommandDialog({ window, open, onClose }: EditCommandDialogProps) {
|
||||
const [input, setInput] = useState(window.commandString || '');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { updateWindow } = useGrimoire();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const parts = input.trim().split(/\s+/);
|
||||
const commandName = parts[0];
|
||||
const args = parts.slice(1);
|
||||
|
||||
const command = manPages[commandName];
|
||||
if (!command) {
|
||||
throw new Error(`Unknown command: ${commandName}`);
|
||||
}
|
||||
|
||||
// Async parsing support (e.g., NIP-05 resolution)
|
||||
const props = command.argParser
|
||||
? await Promise.resolve(command.argParser(args))
|
||||
: command.defaultProps || {};
|
||||
|
||||
const title = args.length > 0
|
||||
? `${commandName.toUpperCase()} ${args.join(' ')}`
|
||||
: commandName.toUpperCase();
|
||||
|
||||
updateWindow(window.id, {
|
||||
props,
|
||||
title,
|
||||
commandString: input,
|
||||
appId: command.appId,
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogTitle>Edit Command</DialogTitle>
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
|
||||
placeholder="Enter command..."
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="text-destructive text-sm">{error}</p>}
|
||||
{isLoading && <p className="text-muted-foreground text-sm">Parsing command...</p>}
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose} variant="outline">Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={isLoading}>Update</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Window Toolbar Edit Button
|
||||
|
||||
```typescript
|
||||
// src/components/WindowToolbar.tsx - Add edit button
|
||||
|
||||
export function WindowToolbar({ windowId, onClose }: WindowToolbarProps) {
|
||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||
const window = useWindowInstance(windowId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mosaic-window-controls">
|
||||
<button
|
||||
className="edit-button"
|
||||
onClick={() => setShowEditDialog(true)}
|
||||
title="Edit command (⌘E)"
|
||||
>
|
||||
<PencilIcon className="size-4" />
|
||||
</button>
|
||||
<button className="close-button" onClick={onClose}>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EditCommandDialog
|
||||
window={window}
|
||||
open={showEditDialog}
|
||||
onClose={() => setShowEditDialog(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Command Palette History Navigation
|
||||
|
||||
```typescript
|
||||
// src/components/CommandLauncher.tsx - Add arrow key navigation
|
||||
|
||||
export function CommandLauncher({ open, onOpenChange }: CommandLauncherProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
const history = useCommandHistory();
|
||||
|
||||
// Get command history (only COMMAND_EXECUTED actions)
|
||||
const commandHistory = useMemo(() =>
|
||||
history.entries
|
||||
.filter(e => e.action.type === 'COMMAND_EXECUTED')
|
||||
.map(e => e.action.commandString)
|
||||
.reverse(), // Most recent first
|
||||
[history]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
executeCommand();
|
||||
setHistoryIndex(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp' && historyIndex < commandHistory.length - 1) {
|
||||
e.preventDefault();
|
||||
const newIndex = historyIndex + 1;
|
||||
setHistoryIndex(newIndex);
|
||||
setInput(commandHistory[newIndex]);
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (historyIndex > 0) {
|
||||
const newIndex = historyIndex - 1;
|
||||
setHistoryIndex(newIndex);
|
||||
setInput(commandHistory[newIndex]);
|
||||
} else if (historyIndex === 0) {
|
||||
setHistoryIndex(-1);
|
||||
setInput(''); // Clear when going past most recent
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ... rest of component
|
||||
}
|
||||
```
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
```typescript
|
||||
// src/hooks/useGlobalKeyboardShortcuts.ts
|
||||
|
||||
export function useGlobalKeyboardShortcuts() {
|
||||
const { performUndo, performRedo } = useGrimoire();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Undo: Cmd+Z (Mac) or Ctrl+Z (Windows/Linux)
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
performUndo();
|
||||
}
|
||||
|
||||
// Redo: Cmd+Shift+Z (Mac) or Ctrl+Shift+Z (Windows/Linux)
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
performRedo();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [performUndo, performRedo]);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation Phases
|
||||
|
||||
### Phase 1: Editable Commands Foundation ✅
|
||||
**Goal:** Store command strings and enable programmatic updates
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Add `commandString?: string` to `WindowInstance` type
|
||||
- [ ] Implement `updateWindow()` in `src/core/logic.ts`
|
||||
- [ ] Add `updateWindow` to `useGrimoire` hook
|
||||
- [ ] Modify `CommandLauncher` to pass `commandString` to `addWindow`
|
||||
- [ ] Write unit tests for `updateWindow`
|
||||
|
||||
**Files to modify:**
|
||||
- `src/types/app.ts`
|
||||
- `src/core/logic.ts`
|
||||
- `src/core/state.ts`
|
||||
- `src/components/CommandLauncher.tsx`
|
||||
- `src/core/logic.test.ts` (new)
|
||||
|
||||
**Estimated effort:** 2-3 hours
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Edit Command UI 🎯
|
||||
**Goal:** User-facing command editing feature
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Create `EditCommandDialog` component
|
||||
- [ ] Add edit button to `WindowToolbar`
|
||||
- [ ] Add ⌘E keyboard shortcut for focused window
|
||||
- [ ] Handle async command parsing (loading states)
|
||||
- [ ] Add error handling and validation
|
||||
|
||||
**Files to create:**
|
||||
- `src/components/EditCommandDialog.tsx`
|
||||
|
||||
**Files to modify:**
|
||||
- `src/components/WindowToolbar.tsx`
|
||||
- `src/hooks/useWindowKeyboardShortcuts.ts` (new)
|
||||
|
||||
**Estimated effort:** 3-4 hours
|
||||
|
||||
**Deliverable:** Working command editing feature! Users can edit window commands.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: History Infrastructure 📊
|
||||
**Goal:** Track all state changes for undo/redo
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Create history types in `src/types/history.ts`
|
||||
- [ ] Implement history atom with `atomWithStorage`
|
||||
- [ ] Implement `recordAction`, `undo`, `redo` functions
|
||||
- [ ] Integrate history recording into state mutations
|
||||
- [ ] Write comprehensive unit tests
|
||||
|
||||
**Files to create:**
|
||||
- `src/types/history.ts`
|
||||
- `src/core/history.ts`
|
||||
- `src/hooks/useHistory.ts`
|
||||
- `src/core/history.test.ts`
|
||||
|
||||
**Files to modify:**
|
||||
- `src/core/state.ts` (record history on all mutations)
|
||||
|
||||
**Estimated effort:** 4-5 hours
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Undo/Redo UI ⚡
|
||||
**Goal:** User-facing undo/redo functionality
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Add global keyboard shortcuts (⌘Z, ⌘⇧Z)
|
||||
- [ ] Add toast notifications for undo/redo feedback
|
||||
- [ ] Add visual history indicator (optional)
|
||||
- [ ] Test with various window operations
|
||||
|
||||
**Files to create:**
|
||||
- `src/hooks/useGlobalKeyboardShortcuts.ts`
|
||||
|
||||
**Files to modify:**
|
||||
- `src/App.tsx`
|
||||
|
||||
**Estimated effort:** 2-3 hours
|
||||
|
||||
**Deliverable:** Full undo/redo system with keyboard shortcuts!
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: History Viewer & Polish 🎨
|
||||
**Goal:** Complete history experience
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Create `HistoryViewer` component (timeline UI)
|
||||
- [ ] Add `history` command to `manPages`
|
||||
- [ ] Add command palette history navigation (↑/↓)
|
||||
- [ ] Add context menu for windows (right-click)
|
||||
- [ ] Performance optimization if needed
|
||||
|
||||
**Files to create:**
|
||||
- `src/components/viewers/HistoryViewer.tsx`
|
||||
|
||||
**Files to modify:**
|
||||
- `src/types/man.ts`
|
||||
- `src/components/CommandLauncher.tsx` (enhance)
|
||||
|
||||
**Estimated effort:** 3-4 hours
|
||||
|
||||
**Deliverable:** Full history viewer and enhanced command palette!
|
||||
|
||||
---
|
||||
|
||||
**Total estimated effort:** 14-19 hours across 5 phases
|
||||
|
||||
---
|
||||
|
||||
## 5. Key Design Decisions & Rationale
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| **Store commandString in WindowInstance** | Preserves exact user input, enables lossless editing. Optional field ensures backward compatibility. |
|
||||
| **Separate history atom** | Clean separation of concerns, independent persistence, easier to add limits without affecting main state. |
|
||||
| **Full state snapshots (not deltas)** | Simpler implementation, reliable undo/redo. Structural sharing keeps memory reasonable. 50-entry circular buffer prevents unbounded growth. |
|
||||
| **Git-style history (truncate future)** | Simple mental model matching text editors. Users understand "undo to desired state, continue from there." |
|
||||
| **Explicit history recording** | Reliable and predictable. Action metadata (command string, window ID) readily available. |
|
||||
| **Async command parsing support** | Commands like `profile alice@domain.com` require NIP-05 resolution. EditDialog shows loading state during async operations. |
|
||||
| **localStorage for history** | Simple, persistent, works offline. Can migrate to IndexedDB if needed for unlimited history. |
|
||||
| **currentIndex = -1 for present** | Clear distinction between "at present" vs "in past". Makes redo logic simpler. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Edge Cases Handled
|
||||
|
||||
| Edge Case | Solution |
|
||||
|-----------|----------|
|
||||
| **Old windows without commandString** | Use `generateRawCommand()` as fallback. Optionally mark as "[reconstructed]" in UI. |
|
||||
| **Async command parsing** | Show loading state in EditCommandDialog. Handle errors gracefully with error message display. |
|
||||
| **Invalid command after edit** | Parse validation before updating. Display error message, don't update window. |
|
||||
| **New action after undo** | Truncate future entries (Git-style). User proceeds from undone state. |
|
||||
| **History memory limits** | Circular buffer with maxEntries (50). Oldest entries automatically dropped. |
|
||||
| **Layout changes without commands** | Debounce layout changes, record LAYOUT_CHANGED action after 1s of inactivity. |
|
||||
| **localStorage quota exceeded** | Jotai's storage handles gracefully. Can implement compression if needed (LZ-string). |
|
||||
| **Command that opens multiple windows** | HistoryAction supports `windowIds: string[]` for future extension. |
|
||||
| **Undo/redo keyboard shortcuts conflict** | Use standard shortcuts (⌘Z, ⌘⇧Z) that don't conflict with browser or other app shortcuts. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```typescript
|
||||
// src/core/history.test.ts
|
||||
describe('History System', () => {
|
||||
describe('recordAction', () => {
|
||||
it('adds entry to history', () => { ... });
|
||||
it('maintains maxEntries limit', () => { ... });
|
||||
it('truncates future when in past state', () => { ... });
|
||||
it('stores both stateBefore and stateAfter', () => { ... });
|
||||
});
|
||||
|
||||
describe('undo', () => {
|
||||
it('restores stateBefore', () => { ... });
|
||||
it('updates currentIndex correctly', () => { ... });
|
||||
it('returns unchanged when at limit', () => { ... });
|
||||
it('works with multiple undo operations', () => { ... });
|
||||
});
|
||||
|
||||
describe('redo', () => {
|
||||
it('restores stateAfter', () => { ... });
|
||||
it('returns unchanged when at present', () => { ... });
|
||||
it('works after multiple undos', () => { ... });
|
||||
});
|
||||
|
||||
describe('circular buffer', () => {
|
||||
it('drops oldest entries when maxEntries exceeded', () => { ... });
|
||||
it('maintains correct currentIndex after truncation', () => { ... });
|
||||
});
|
||||
});
|
||||
|
||||
// src/core/logic.test.ts
|
||||
describe('updateWindow', () => {
|
||||
it('updates window props', () => { ... });
|
||||
it('updates title', () => { ... });
|
||||
it('updates commandString', () => { ... });
|
||||
it('updates appId', () => { ... });
|
||||
it('updates multiple fields at once', () => { ... });
|
||||
it('returns unchanged for nonexistent window', () => { ... });
|
||||
it('preserves other window fields', () => { ... });
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Tests (Manual)
|
||||
|
||||
1. **Edit Command Flow:**
|
||||
- Execute `profile alice@domain.com`
|
||||
- Click edit button
|
||||
- Change to `profile bob@domain.com`
|
||||
- Verify window updates with new profile data
|
||||
|
||||
2. **Undo/Redo Flow:**
|
||||
- Execute multiple commands (profile, req, open)
|
||||
- Undo each command (⌘Z)
|
||||
- Verify state restored correctly
|
||||
- Redo each command (⌘⇧Z)
|
||||
- Verify state matches original
|
||||
|
||||
3. **History Navigation:**
|
||||
- Open command palette
|
||||
- Press ↑ multiple times
|
||||
- Verify recent commands appear
|
||||
- Press ↓ to go back
|
||||
- Verify command changes
|
||||
|
||||
4. **Async Command Editing:**
|
||||
- Edit profile window with NIP-05
|
||||
- Verify loading state shown
|
||||
- Verify update after resolution completes
|
||||
|
||||
5. **Error Handling:**
|
||||
- Edit command to invalid syntax
|
||||
- Verify error message shown
|
||||
- Verify window not updated
|
||||
|
||||
6. **History Limits:**
|
||||
- Execute >50 commands
|
||||
- Verify oldest commands dropped
|
||||
- Verify undo still works correctly
|
||||
|
||||
### Performance Tests
|
||||
|
||||
1. **History Storage Size:**
|
||||
- Execute 50 commands
|
||||
- Check localStorage size
|
||||
- Verify <1MB (reasonable limit)
|
||||
|
||||
2. **Undo/Redo Latency:**
|
||||
- Measure time to undo with complex state
|
||||
- Should be <50ms (imperceptible)
|
||||
|
||||
3. **Command Parsing:**
|
||||
- Measure async NIP-05 resolution time
|
||||
- Should complete within 2-3 seconds
|
||||
|
||||
---
|
||||
|
||||
## 8. Performance Considerations
|
||||
|
||||
| Aspect | Performance | Notes |
|
||||
|--------|-------------|-------|
|
||||
| **Memory** | ~10MB for 50 entries | Within localStorage limits. Each state ~100KB. Structural sharing reduces actual memory usage. |
|
||||
| **Undo/Redo latency** | <10ms | O(1) state swap, imperceptible to user. |
|
||||
| **History recording** | <5ms | `structuredClone()` is native and fast. |
|
||||
| **localStorage writes** | Debounced by Jotai | No performance impact on normal operations. Writes happen in background. |
|
||||
| **Command parsing** | Varies (50ms - 3s) | Depends on async operations (NIP-05). Loading state shown during parse. |
|
||||
|
||||
### Future Optimizations (if needed)
|
||||
|
||||
1. **Delta encoding** - Store only changed fields instead of full state snapshots
|
||||
- Pro: ~90% memory reduction
|
||||
- Con: Complex implementation, harder debugging
|
||||
|
||||
2. **LZ-string compression** - Compress state snapshots before localStorage
|
||||
- Pro: 50-70% size reduction
|
||||
- Con: CPU overhead for compress/decompress
|
||||
|
||||
3. **IndexedDB migration** - Move from localStorage to IndexedDB
|
||||
- Pro: No size limits, better performance for large datasets
|
||||
- Con: More complex API, async operations
|
||||
|
||||
4. **Lazy loading** - Only keep recent N entries in memory
|
||||
- Pro: Reduced memory footprint
|
||||
- Con: Async loading on undo, more complex code
|
||||
|
||||
**Recommendation:** Start with simple approach. Optimize only if profiling shows real issues.
|
||||
|
||||
---
|
||||
|
||||
## 9. Documentation Updates
|
||||
|
||||
### CLAUDE.md Addition
|
||||
|
||||
Add new section after "## Core Architecture":
|
||||
|
||||
```markdown
|
||||
### Command History & Editing
|
||||
|
||||
Windows store their originating command string, enabling editing and comprehensive undo/redo.
|
||||
|
||||
**Edit Commands:**
|
||||
- Click pencil icon in window toolbar or press ⌘E
|
||||
- Edit the command string in the dialog
|
||||
- Command is re-parsed (with async support for NIP-05, etc.)
|
||||
- Window updates with new data
|
||||
|
||||
**Undo/Redo:**
|
||||
- **⌘Z**: Undo last action (window create, close, edit, layout change)
|
||||
- **⌘⇧Z**: Redo previously undone action
|
||||
- History limited to last 50 actions (circular buffer)
|
||||
- Git-style: new actions after undo truncate future
|
||||
|
||||
**Command History:**
|
||||
- **↑/↓** in command palette: Navigate through recent commands
|
||||
- History stored in separate Jotai atom (`historyAtom`)
|
||||
- Each entry captures state snapshots before/after for perfect undo
|
||||
|
||||
**Implementation:**
|
||||
- `src/core/history.ts` - History logic and storage
|
||||
- `src/types/history.ts` - Type definitions
|
||||
- `src/hooks/useHistory.ts` - React integration
|
||||
- `src/components/EditCommandDialog.tsx` - Edit UI
|
||||
```
|
||||
|
||||
### Type Documentation
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* WindowInstance represents a single window in the Grimoire application.
|
||||
* Each window displays content from a specific app (profile, event, NIP viewer, etc.)
|
||||
* and is created from a user command via the command palette.
|
||||
*
|
||||
* @property id - Unique window identifier (UUID)
|
||||
* @property appId - Type of app/viewer this window displays
|
||||
* @property title - Window title shown in toolbar
|
||||
* @property props - App-specific properties (pubkey, filter, etc.)
|
||||
* @property commandString - The original command that created this window (e.g., "profile alice@domain.com").
|
||||
* Enables command editing and history features. May be undefined for windows created
|
||||
* before this feature was added. Use generateRawCommand() as a fallback.
|
||||
*/
|
||||
interface WindowInstance {
|
||||
id: string;
|
||||
appId: AppId;
|
||||
title: string;
|
||||
props: any;
|
||||
commandString?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Examples
|
||||
|
||||
```typescript
|
||||
// Example: Creating a window with command string
|
||||
addWindow('profile', { pubkey: '...' }, 'PROFILE alice', 'profile alice@domain.com');
|
||||
|
||||
// Example: Updating a window
|
||||
updateWindow(windowId, {
|
||||
props: { pubkey: 'new-pubkey' },
|
||||
title: 'PROFILE bob',
|
||||
commandString: 'profile bob@domain.com',
|
||||
});
|
||||
|
||||
// Example: Undo/redo
|
||||
performUndo(); // Undo last action
|
||||
performRedo(); // Redo undone action
|
||||
|
||||
// Example: Recording custom history action
|
||||
recordHistory({
|
||||
type: 'LAYOUT_CHANGED',
|
||||
workspaceId: 'workspace-id',
|
||||
}, prevState, nextState);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Security & Privacy Considerations
|
||||
|
||||
| Concern | Mitigation |
|
||||
|---------|-----------|
|
||||
| **Sensitive data in history** | History stored in localStorage (same origin only). Consider adding opt-out for privacy-conscious users. |
|
||||
| **Command injection** | All commands parsed through `manPages` argParser. No arbitrary code execution. |
|
||||
| **localStorage quota attacks** | Circular buffer limits history size. Jotai handles quota exceeded gracefully. |
|
||||
| **XSS via command strings** | Command strings displayed in UI are properly escaped by React. |
|
||||
|
||||
---
|
||||
|
||||
## 11. Future Enhancements
|
||||
|
||||
### Potential Phase 6+ Features
|
||||
|
||||
1. **Workspace-specific undo** - Undo operations scoped to current workspace
|
||||
2. **History search** - Search through command history by text
|
||||
3. **History export** - Export history as JSON for debugging
|
||||
4. **Command templates** - Save frequently used commands as templates
|
||||
5. **Bulk operations** - Edit multiple windows at once
|
||||
6. **History persistence options** - Choose localStorage vs IndexedDB vs in-memory
|
||||
7. **Visual timeline** - Graphical timeline of actions with branching
|
||||
8. **Command aliases** - Create shortcuts for long commands (e.g., `alice` → `profile alice@domain.com`)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
This design provides a complete, production-ready system for editable window commands and comprehensive command history:
|
||||
|
||||
✅ **Backward Compatible** - Optional fields, no migrations needed
|
||||
✅ **Simple Mental Model** - Git-style history familiar to developers
|
||||
✅ **Memory Efficient** - Circular buffer with structural sharing
|
||||
✅ **Type Safe** - Full TypeScript coverage
|
||||
✅ **Testable** - Pure functions, comprehensive test suite
|
||||
✅ **Phased Implementation** - Each phase independently deliverable
|
||||
✅ **Well Documented** - Clear API contracts and usage examples
|
||||
✅ **User-Friendly** - Familiar keyboard shortcuts, visual feedback, error handling
|
||||
|
||||
The architecture cleanly separates concerns (state vs history), handles edge cases gracefully, and provides excellent UX with keyboard shortcuts, visual feedback, and error handling.
|
||||
|
||||
**Status:** Design complete and validated. Ready for Phase 1 implementation.
|
||||
|
||||
**Next Steps:**
|
||||
1. Review design with team/stakeholders
|
||||
2. Create implementation branch
|
||||
3. Start with Phase 1 (foundation)
|
||||
4. Iterate through phases 2-5
|
||||
5. Deploy and gather user feedback
|
||||
@@ -1,276 +0,0 @@
|
||||
# Enhancement: OPEN Command Always Uses nevent with Full Metadata
|
||||
|
||||
**Date:** 2025-12-13
|
||||
**Type:** Enhancement
|
||||
**Status:** ✅ Implemented
|
||||
|
||||
## Overview
|
||||
|
||||
Updated the OPEN command reconstruction to always generate `nevent` identifiers (never `note`) with full metadata including kind information and relay hints from seen relays.
|
||||
|
||||
## Changes
|
||||
|
||||
### Previous Behavior
|
||||
|
||||
```typescript
|
||||
// Simple events → note
|
||||
open note1...
|
||||
|
||||
// Events with metadata → nevent
|
||||
open nevent1...
|
||||
```
|
||||
|
||||
### New Behavior
|
||||
|
||||
```typescript
|
||||
// Always nevent with full metadata
|
||||
open nevent1... // Includes: id, kind, author, seen relays
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
### Key Updates
|
||||
|
||||
1. **Import Event Store & Relay Helpers**
|
||||
```typescript
|
||||
import eventStore from "@/services/event-store";
|
||||
import { getSeenRelays } from "applesauce-core/helpers/relays";
|
||||
```
|
||||
|
||||
2. **Lookup Event in Store**
|
||||
```typescript
|
||||
const event = eventStore.event(pointer.id);
|
||||
```
|
||||
|
||||
3. **Extract Seen Relays**
|
||||
```typescript
|
||||
const seenRelaysSet = getSeenRelays(event);
|
||||
const seenRelays = seenRelaysSet ? Array.from(seenRelaysSet) : undefined;
|
||||
```
|
||||
|
||||
4. **Always Encode as nevent with Full Metadata**
|
||||
```typescript
|
||||
const nevent = nip19.neventEncode({
|
||||
id: event.id,
|
||||
kind: event.kind, // ✅ Kind information
|
||||
author: event.pubkey,
|
||||
relays: seenRelays, // ✅ Seen relays
|
||||
});
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
### 1. Complete Context
|
||||
nevent identifiers include all context needed to fetch the event:
|
||||
- **Event ID**: Unique identifier
|
||||
- **Kind**: Event type (helps with rendering)
|
||||
- **Author**: Pubkey (useful for context)
|
||||
- **Relay Hints**: Where the event was seen (improves fetch success rate)
|
||||
|
||||
### 2. Better Relay Discovery
|
||||
Using seen relays ensures the reconstructed command points to relays that actually have the event, improving fetch reliability.
|
||||
|
||||
### 3. Consistency
|
||||
All event references use the same format (nevent), making the system more predictable.
|
||||
|
||||
### 4. Future-Proof
|
||||
nevent is the recommended format for event references with context, ensuring compatibility with other Nostr tools.
|
||||
|
||||
## Example Scenarios
|
||||
|
||||
### Scenario 1: Event in Store with Seen Relays
|
||||
|
||||
**Window State:**
|
||||
```typescript
|
||||
{
|
||||
pointer: { id: "abc123..." }
|
||||
}
|
||||
```
|
||||
|
||||
**Lookup Result:**
|
||||
```typescript
|
||||
event = {
|
||||
id: "abc123...",
|
||||
kind: 1,
|
||||
pubkey: "def456...",
|
||||
// ... other fields
|
||||
}
|
||||
seenRelays = ["wss://relay.damus.io", "wss://nos.lol"]
|
||||
```
|
||||
|
||||
**Reconstructed Command:**
|
||||
```
|
||||
open nevent1qqs... // Contains: id, kind:1, author, 2 relay hints
|
||||
```
|
||||
|
||||
### Scenario 2: Event Not in Store (Fallback)
|
||||
|
||||
**Window State:**
|
||||
```typescript
|
||||
{
|
||||
pointer: {
|
||||
id: "abc123...",
|
||||
relays: ["wss://relay.primal.net"],
|
||||
author: "def456..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Reconstructed Command:**
|
||||
```
|
||||
open nevent1qqs... // Uses stored pointer data
|
||||
```
|
||||
|
||||
### Scenario 3: Addressable Events (naddr)
|
||||
|
||||
**Window State:**
|
||||
```typescript
|
||||
{
|
||||
pointer: {
|
||||
kind: 30023,
|
||||
pubkey: "abc123...",
|
||||
identifier: "my-article"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Lookup Result:**
|
||||
```typescript
|
||||
seenRelays = ["wss://relay.nostr.band"]
|
||||
```
|
||||
|
||||
**Reconstructed Command:**
|
||||
```
|
||||
open naddr1... // With updated relay hints from seen relays
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Event Store Lookups
|
||||
|
||||
**Regular Events:**
|
||||
```typescript
|
||||
const event = eventStore.event(pointer.id);
|
||||
// Synchronous lookup in local cache
|
||||
```
|
||||
|
||||
**Addressable/Replaceable Events:**
|
||||
```typescript
|
||||
const event = eventStore.replaceable(
|
||||
pointer.kind,
|
||||
pointer.pubkey,
|
||||
pointer.identifier || ""
|
||||
);
|
||||
// Synchronous lookup for latest replaceable event
|
||||
```
|
||||
|
||||
### Seen Relays Extraction
|
||||
|
||||
```typescript
|
||||
const seenRelaysSet = getSeenRelays(event);
|
||||
// Returns Set<string> of relay URLs where event was seen
|
||||
// Managed by applesauce-core
|
||||
```
|
||||
|
||||
### Encoding
|
||||
|
||||
```typescript
|
||||
// nevent encoding (EventPointer)
|
||||
nip19.neventEncode({
|
||||
id: string,
|
||||
kind?: number, // Optional but recommended
|
||||
author?: string, // Optional but recommended
|
||||
relays?: string[], // Optional but improves fetch
|
||||
});
|
||||
|
||||
// naddr encoding (AddressPointer)
|
||||
nip19.naddrEncode({
|
||||
kind: number, // Required
|
||||
pubkey: string, // Required
|
||||
identifier: string, // Required
|
||||
relays?: string[], // Optional but improves fetch
|
||||
});
|
||||
```
|
||||
|
||||
## Edge Cases Handled
|
||||
|
||||
| Case | Behavior |
|
||||
|------|----------|
|
||||
| **Event in store** | Use kind & seen relays from event |
|
||||
| **Event not in store** | Fallback to stored pointer data |
|
||||
| **No seen relays** | Omit relays (still valid nevent) |
|
||||
| **Encoding error** | Fallback to raw ID display |
|
||||
| **Addressable events** | Use naddr with seen relays |
|
||||
|
||||
## Performance
|
||||
|
||||
- **Event lookup**: O(1) - EventStore uses Map internally
|
||||
- **Seen relays**: O(1) - Cached by applesauce
|
||||
- **Encoding**: <1ms - Native nip19 encoding
|
||||
- **Total overhead**: <5ms per reconstruction
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Test Cases
|
||||
|
||||
1. **Open any event**: `open note1...` or `nevent1...`
|
||||
2. **Click edit button**
|
||||
3. **Verify**: CommandLauncher shows `open nevent1...` with full metadata
|
||||
|
||||
**Expected nevent structure:**
|
||||
- Has more characters than note (includes metadata)
|
||||
- When decoded, shows kind, author, and relay hints
|
||||
- Relays match where event was seen
|
||||
|
||||
### Verification Commands
|
||||
|
||||
```typescript
|
||||
// Decode the nevent to verify contents
|
||||
import { nip19 } from "nostr-tools";
|
||||
const decoded = nip19.decode("nevent1...");
|
||||
console.log(decoded);
|
||||
// Output:
|
||||
// {
|
||||
// type: "nevent",
|
||||
// data: {
|
||||
// id: "abc123...",
|
||||
// kind: 1,
|
||||
// author: "def456...",
|
||||
// relays: ["wss://relay.damus.io", "wss://nos.lol"]
|
||||
// }
|
||||
// }
|
||||
```
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `src/lib/command-reconstructor.ts`
|
||||
- Added imports: eventStore, getSeenRelays
|
||||
- Updated `open` case: Always nevent with metadata
|
||||
- Enhanced `naddr` case: Include seen relays
|
||||
|
||||
## Benefits Over Previous Approach
|
||||
|
||||
| Aspect | Before | After |
|
||||
|--------|--------|-------|
|
||||
| **Format** | note or nevent | Always nevent |
|
||||
| **Kind info** | ❌ Not in note | ✅ Always included |
|
||||
| **Relay hints** | ⚠️ Only if stored | ✅ From seen relays |
|
||||
| **Context** | Minimal | Complete |
|
||||
| **Reliability** | Partial | High |
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Cache reconstructed commands to avoid repeated lookups
|
||||
- [ ] Prune relay list to top N most reliable relays
|
||||
- [ ] Add event fetch timeout for missing events
|
||||
|
||||
## Conclusion
|
||||
|
||||
The OPEN command now provides complete context through nevent identifiers with:
|
||||
- ✅ Event kind information
|
||||
- ✅ Author pubkey
|
||||
- ✅ Relay hints from actual seen relays
|
||||
- ✅ Better fetch reliability
|
||||
- ✅ Consistent format across all events
|
||||
|
||||
This enhancement ensures users get rich, actionable command strings when editing OPEN windows.
|
||||
@@ -1,201 +0,0 @@
|
||||
# Fix: OPEN Command Reconstruction
|
||||
|
||||
**Date:** 2025-12-13
|
||||
**Issue:** OPEN command sometimes didn't include event bech32 when clicking edit
|
||||
**Status:** ✅ Fixed
|
||||
|
||||
## Problem Analysis
|
||||
|
||||
### Root Cause
|
||||
|
||||
The command reconstructor was checking for the wrong props structure.
|
||||
|
||||
**Incorrect code:**
|
||||
```typescript
|
||||
case "open": {
|
||||
if (props.id) { ... } // ❌ Wrong!
|
||||
if (props.address) { ... } // ❌ Wrong!
|
||||
return "open";
|
||||
}
|
||||
```
|
||||
|
||||
**Actual props structure:**
|
||||
```typescript
|
||||
{
|
||||
pointer: EventPointer | AddressPointer
|
||||
}
|
||||
```
|
||||
|
||||
Where:
|
||||
- `EventPointer`: `{ id: string, relays?: string[], author?: string }`
|
||||
- `AddressPointer`: `{ kind: number, pubkey: string, identifier: string, relays?: string[] }`
|
||||
|
||||
### Why This Happened
|
||||
|
||||
The `parseOpenCommand` function returns `{ pointer: EventPointer | AddressPointer }`, but the reconstructor was looking for `props.id` and `props.address` directly. This mismatch caused the reconstruction to fail and return just `"open"` without the event identifier.
|
||||
|
||||
## Solution
|
||||
|
||||
Updated the `open` case in `command-reconstructor.ts` to properly handle the pointer structure:
|
||||
|
||||
### Implementation
|
||||
|
||||
```typescript
|
||||
case "open": {
|
||||
// Handle pointer structure from parseOpenCommand
|
||||
if (!props.pointer) return "open";
|
||||
|
||||
const pointer = props.pointer;
|
||||
|
||||
try {
|
||||
// EventPointer (has id field)
|
||||
if ("id" in pointer) {
|
||||
// If has relays or author metadata, use nevent
|
||||
if (pointer.relays?.length || pointer.author) {
|
||||
const nevent = nip19.neventEncode({
|
||||
id: pointer.id,
|
||||
relays: pointer.relays,
|
||||
author: pointer.author,
|
||||
});
|
||||
return `open ${nevent}`;
|
||||
}
|
||||
// Otherwise use simple note
|
||||
const note = nip19.noteEncode(pointer.id);
|
||||
return `open ${note}`;
|
||||
}
|
||||
|
||||
// AddressPointer (has kind, pubkey, identifier)
|
||||
if ("kind" in pointer) {
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: pointer.kind,
|
||||
pubkey: pointer.pubkey,
|
||||
identifier: pointer.identifier,
|
||||
relays: pointer.relays,
|
||||
});
|
||||
return `open ${naddr}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to encode open command:", error);
|
||||
// Fallback to raw pointer display
|
||||
if ("id" in pointer) {
|
||||
return `open ${pointer.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
return "open";
|
||||
}
|
||||
```
|
||||
|
||||
## Encoding Strategy
|
||||
|
||||
### EventPointer (has `id`)
|
||||
|
||||
1. **With metadata** (relays or author) → Encode as `nevent`
|
||||
- Input: `{ id: "abc...", relays: ["wss://relay.com"], author: "def..." }`
|
||||
- Output: `open nevent1...`
|
||||
- Preserves relay hints and author information
|
||||
|
||||
2. **Without metadata** → Encode as `note`
|
||||
- Input: `{ id: "abc..." }`
|
||||
- Output: `open note1...`
|
||||
- Simpler, more common format
|
||||
|
||||
### AddressPointer (has `kind`, `pubkey`, `identifier`)
|
||||
|
||||
- Always encode as `naddr`
|
||||
- Input: `{ kind: 30023, pubkey: "abc...", identifier: "my-article" }`
|
||||
- Output: `open naddr1...`
|
||||
- Used for replaceable/parameterized replaceable events
|
||||
|
||||
## Test Cases
|
||||
|
||||
### Test 1: Simple Event (note)
|
||||
```typescript
|
||||
// Window with EventPointer (just ID)
|
||||
{
|
||||
pointer: { id: "abc123..." }
|
||||
}
|
||||
// Reconstructs to:
|
||||
"open note1..."
|
||||
```
|
||||
|
||||
### Test 2: Event with Metadata (nevent)
|
||||
```typescript
|
||||
// Window with EventPointer (with relays/author)
|
||||
{
|
||||
pointer: {
|
||||
id: "abc123...",
|
||||
relays: ["wss://relay.damus.io"],
|
||||
author: "def456..."
|
||||
}
|
||||
}
|
||||
// Reconstructs to:
|
||||
"open nevent1..."
|
||||
```
|
||||
|
||||
### Test 3: Addressable Event (naddr)
|
||||
```typescript
|
||||
// Window with AddressPointer
|
||||
{
|
||||
pointer: {
|
||||
kind: 30023,
|
||||
pubkey: "abc123...",
|
||||
identifier: "my-article",
|
||||
relays: ["wss://relay.nostr.band"]
|
||||
}
|
||||
}
|
||||
// Reconstructs to:
|
||||
"open naddr1..."
|
||||
```
|
||||
|
||||
### Test 4: Original Hex Input
|
||||
```typescript
|
||||
// User typed: open abc123... (hex)
|
||||
// Parser creates: { pointer: { id: "abc123..." } }
|
||||
// Reconstructs to: "open note1..." (encoded as bech32)
|
||||
// ✅ Better UX - consistent bech32 format
|
||||
```
|
||||
|
||||
## Why This Fix Works
|
||||
|
||||
1. **Correct Props Structure**: Now checks `props.pointer` instead of non-existent `props.id`
|
||||
2. **Type Detection**: Uses `"id" in pointer` vs `"kind" in pointer` to distinguish types
|
||||
3. **Smart Encoding**:
|
||||
- Uses `nevent` when metadata exists (preserves relay hints)
|
||||
- Uses `note` for simple cases (cleaner)
|
||||
- Uses `naddr` for addressable events (required format)
|
||||
4. **Error Handling**: Fallback to raw ID if encoding fails
|
||||
5. **Consistent Output**: All reconstructed commands use bech32 format
|
||||
|
||||
## Impact
|
||||
|
||||
### Before Fix
|
||||
- Clicking edit on open windows showed just `"open"` with no event ID
|
||||
- Users couldn't edit open commands
|
||||
- Command reconstruction was broken for all open windows
|
||||
|
||||
### After Fix
|
||||
- ✅ Clicking edit shows full command: `open note1...` / `nevent1...` / `naddr1...`
|
||||
- ✅ Users can edit and resubmit open commands
|
||||
- ✅ Preserves relay hints and metadata when present
|
||||
- ✅ Consistent with how users type commands
|
||||
|
||||
## Files Changed
|
||||
|
||||
- `src/lib/command-reconstructor.ts` - Fixed `open` case (lines 39-81)
|
||||
|
||||
## Verification
|
||||
|
||||
✅ TypeScript compilation successful
|
||||
✅ No breaking changes
|
||||
✅ Backward compatible (handles both old and new windows)
|
||||
|
||||
## Related Components
|
||||
|
||||
- `src/lib/open-parser.ts` - Defines pointer structures and parsing logic
|
||||
- `src/components/EventDetailViewer.tsx` - Consumes pointer prop
|
||||
- `src/types/man.ts` - Defines open command entry
|
||||
|
||||
## Future Improvements
|
||||
|
||||
None needed - the fix is complete and handles all cases properly.
|
||||
@@ -1,328 +0,0 @@
|
||||
# Editable Commands - Final Implementation
|
||||
|
||||
**Date:** 2025-12-13
|
||||
**Status:** ✅ Complete
|
||||
**Approach:** Reuse CommandLauncher with prefilled commands + command reconstruction
|
||||
|
||||
## Overview
|
||||
|
||||
Implemented editable window commands by reusing the familiar CommandLauncher interface. Users click the edit button and the CommandLauncher opens prefilled with the window's command, providing a seamless editing experience. Includes intelligent command reconstruction for windows created before command tracking was added.
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. Reuse CommandLauncher Instead of Custom Dialog
|
||||
|
||||
**Rationale:**
|
||||
- ✅ Users already know how to use CommandLauncher
|
||||
- ✅ Consistent UX across command creation and editing
|
||||
- ✅ No duplicate UI code
|
||||
- ✅ All CommandLauncher features available (suggestions, parsing hints, etc.)
|
||||
|
||||
**Implementation:**
|
||||
- Edit mode state managed via Jotai atoms
|
||||
- CommandLauncher detects edit mode and prefills input
|
||||
- Updates existing window instead of creating new one
|
||||
|
||||
### 2. Command Reconstruction for Old Windows
|
||||
|
||||
**Problem:** Windows created before commandString tracking have no stored command.
|
||||
|
||||
**Solution:** Intelligent reconstruction based on appId and props:
|
||||
|
||||
```typescript
|
||||
// Examples of reconstruction:
|
||||
profile window → "profile npub1..." (encodes hex to npub)
|
||||
req window → "req -k 1,3 -l 50 -a npub1..." (reconstructs all flags)
|
||||
nip window → "nip 19"
|
||||
open window → "open note1..." (encodes hex to note)
|
||||
```
|
||||
|
||||
**Coverage:**
|
||||
- ✅ Simple commands: nip, kind, man, kinds, conn, help
|
||||
- ✅ Profile commands: with npub encoding
|
||||
- ✅ Open commands: with note/naddr encoding
|
||||
- ✅ Complex req commands: all flags reconstructed
|
||||
- ✅ Relay, encode, decode commands
|
||||
|
||||
## Architecture
|
||||
|
||||
### State Management via Jotai Atoms
|
||||
|
||||
**New atom:**
|
||||
```typescript
|
||||
// src/core/command-launcher-state.ts
|
||||
export interface EditModeState {
|
||||
windowId: string;
|
||||
initialCommand: string;
|
||||
}
|
||||
|
||||
export const commandLauncherEditModeAtom = atom<EditModeState | null>(null);
|
||||
```
|
||||
|
||||
**Flow:**
|
||||
1. User clicks edit button → WindowToolbar sets edit mode atom
|
||||
2. WindowToolbar opens CommandLauncher
|
||||
3. CommandLauncher detects edit mode, prefills command
|
||||
4. User edits and submits
|
||||
5. CommandLauncher calls updateWindow instead of addWindow
|
||||
6. Edit mode atom cleared
|
||||
|
||||
### Component Integration
|
||||
|
||||
```
|
||||
WindowToolbar (edit button)
|
||||
↓ (sets editMode atom)
|
||||
↓ (calls onEditCommand)
|
||||
Home.tsx (opens CommandLauncher)
|
||||
↓
|
||||
CommandLauncher (reads editMode atom)
|
||||
↓ (prefills input)
|
||||
↓ (user edits)
|
||||
↓ (calls updateWindow)
|
||||
Window updated!
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Files Created (2)
|
||||
|
||||
1. **`src/lib/command-reconstructor.ts`** (245 lines)
|
||||
- `reconstructCommand(window)` - Main reconstruction function
|
||||
- `reconstructReqCommand(props)` - Complex req command reconstruction
|
||||
- Handles all command types with intelligent encoding (npub, note, naddr)
|
||||
|
||||
2. **`src/core/command-launcher-state.ts`** (13 lines)
|
||||
- Edit mode state atom
|
||||
- Clean separation of concerns
|
||||
|
||||
### Files Modified (4)
|
||||
|
||||
1. **`src/components/CommandLauncher.tsx`**
|
||||
- Added edit mode detection via atom
|
||||
- Prefills input when in edit mode
|
||||
- Calls updateWindow vs addWindow based on mode
|
||||
- Clears edit mode after execution
|
||||
|
||||
2. **`src/components/WindowToolbar.tsx`**
|
||||
- Edit button triggers edit mode
|
||||
- Uses reconstructCommand for old windows
|
||||
- Sets edit mode atom and opens launcher
|
||||
|
||||
3. **`src/components/WindowTitle.tsx`**
|
||||
- Passes onEditCommand callback to toolbar
|
||||
|
||||
4. **`src/components/Home.tsx`**
|
||||
- Passes CommandLauncher opener to WindowTile
|
||||
|
||||
### Files Removed (1)
|
||||
|
||||
- **`src/components/EditCommandDialog.tsx`** - No longer needed!
|
||||
|
||||
## Command Reconstruction Examples
|
||||
|
||||
### Simple Commands
|
||||
```typescript
|
||||
// NIP window
|
||||
{ appId: "nip", props: { number: "19" } }
|
||||
→ "nip 19"
|
||||
|
||||
// Kind window
|
||||
{ appId: "kind", props: { number: "1" } }
|
||||
→ "kind 1"
|
||||
|
||||
// Man window
|
||||
{ appId: "man", props: { cmd: "profile" } }
|
||||
→ "man profile"
|
||||
```
|
||||
|
||||
### Profile Command with Encoding
|
||||
```typescript
|
||||
// Profile window with hex pubkey
|
||||
{
|
||||
appId: "profile",
|
||||
props: { pubkey: "abc123..." }
|
||||
}
|
||||
→ "profile npub1..." // Encoded as npub for readability
|
||||
```
|
||||
|
||||
### Complex Req Command
|
||||
```typescript
|
||||
// Req window with multiple filters
|
||||
{
|
||||
appId: "req",
|
||||
props: {
|
||||
filter: {
|
||||
kinds: [1, 3],
|
||||
authors: ["abc..."],
|
||||
limit: 50,
|
||||
"#t": ["nostr", "bitcoin"]
|
||||
},
|
||||
relays: ["relay.damus.io"]
|
||||
}
|
||||
}
|
||||
→ "req -k 1,3 -a npub1... -l 50 -t nostr,bitcoin relay.damus.io"
|
||||
```
|
||||
|
||||
### Open Command with Encoding
|
||||
```typescript
|
||||
// Open window with event ID
|
||||
{
|
||||
appId: "open",
|
||||
props: { id: "def456..." }
|
||||
}
|
||||
→ "open note1..." // Encoded as note for consistency
|
||||
```
|
||||
|
||||
## User Experience
|
||||
|
||||
### Editing a Window
|
||||
|
||||
1. **Click edit button** (Pencil icon) in any window toolbar
|
||||
2. **CommandLauncher opens** with command prefilled
|
||||
3. **Edit command** using familiar interface with:
|
||||
- Command suggestions
|
||||
- Syntax hints
|
||||
- Real-time parsing feedback
|
||||
4. **Press Enter** or click away
|
||||
5. **Window updates instantly** with new data
|
||||
|
||||
### Old Windows (No commandString)
|
||||
|
||||
For windows created before command tracking:
|
||||
1. Click edit button
|
||||
2. Command is **automatically reconstructed** from window data
|
||||
3. Edit reconstructed command normally
|
||||
4. Window updates and now has commandString saved
|
||||
|
||||
## Edge Cases Handled
|
||||
|
||||
| Edge Case | Solution |
|
||||
|-----------|----------|
|
||||
| **Old windows without commandString** | Reconstruct command from appId + props |
|
||||
| **Complex req commands** | Intelligently reconstruct all flags from filter object |
|
||||
| **Hex values** | Encode to npub/note/naddr for readability |
|
||||
| **Invalid reconstructed command** | User can immediately fix in CommandLauncher |
|
||||
| **Async commands (NIP-05)** | Full async support maintained |
|
||||
| **Command changes appId** | Window viewer changes to new app type |
|
||||
| **Edit mode interrupted** | Edit mode atom automatically cleared on launcher close |
|
||||
|
||||
## Technical Highlights
|
||||
|
||||
### Encoding Strategy
|
||||
|
||||
The reconstructor automatically encodes hex values for better UX:
|
||||
|
||||
```typescript
|
||||
// Pubkeys → npub
|
||||
"abc123..." → "npub1..."
|
||||
|
||||
// Event IDs → note
|
||||
"def456..." → "note1..."
|
||||
|
||||
// Addresses → naddr (for replaceable events)
|
||||
"30023:pubkey:d-tag" → "naddr1..."
|
||||
```
|
||||
|
||||
This makes reconstructed commands readable and matches what users typically type.
|
||||
|
||||
### Req Command Reconstruction
|
||||
|
||||
Most complex reconstruction - handles:
|
||||
- Kinds: `-k 1,3,7`
|
||||
- Authors: `-a npub1...,npub2...` (with encoding)
|
||||
- Limit: `-l 50`
|
||||
- Tags: `-e`, `-p`, `-t`, `-d`, `--tag`
|
||||
- Time ranges: `--since`, `--until`
|
||||
- Search: `--search "text"`
|
||||
- Flags: `--close-on-eose`
|
||||
- Relays: `relay1.com relay2.com`
|
||||
|
||||
### State Management Pattern
|
||||
|
||||
Using Jotai atoms for edit mode provides:
|
||||
- ✅ No prop drilling required
|
||||
- ✅ Clean separation from main UI state
|
||||
- ✅ Automatic cleanup on launcher close
|
||||
- ✅ Type-safe state updates
|
||||
- ✅ Easy to extend for future features
|
||||
|
||||
## Testing
|
||||
|
||||
### TypeScript Compilation
|
||||
✅ `npx tsc --noEmit` - No errors
|
||||
|
||||
### Dev Server
|
||||
✅ Running on http://localhost:5173/
|
||||
|
||||
### Manual Test Scenarios
|
||||
|
||||
**Test 1: Edit New Window (has commandString)**
|
||||
1. Create window: `profile alice@domain.com`
|
||||
2. Click edit button → CommandLauncher opens with "profile alice@domain.com"
|
||||
3. Change to: `profile bob@domain.com`
|
||||
4. Window updates to show Bob's profile
|
||||
|
||||
**Test 2: Edit Old Window (no commandString)**
|
||||
1. Open window from localStorage (created before this feature)
|
||||
2. Click edit button → Command automatically reconstructed!
|
||||
3. Edit reconstructed command
|
||||
4. Window updates and commandString is now saved
|
||||
|
||||
**Test 3: Edit Complex Req Command**
|
||||
1. Create: `req -k 1,3 -l 20 -t nostr,bitcoin`
|
||||
2. Click edit → Exact command shown
|
||||
3. Change to: `req -k 1 -l 50`
|
||||
4. Window updates with new filter
|
||||
|
||||
**Test 4: Reconstruct and Edit**
|
||||
1. Old profile window with hex pubkey
|
||||
2. Click edit → See `profile npub1...` (reconstructed with encoding!)
|
||||
3. Edit normally
|
||||
4. Works perfectly
|
||||
|
||||
**Test 5: Change App Type via Edit**
|
||||
1. Profile window
|
||||
2. Click edit, change to: `req -k 1`
|
||||
3. Window changes from ProfileViewer to ReqViewer
|
||||
|
||||
## Performance
|
||||
|
||||
- **Memory**: Minimal (edit mode atom ~100 bytes)
|
||||
- **Reconstruction**: <1ms for simple commands, <10ms for complex req
|
||||
- **Encoding**: <1ms per hex value (npub/note encoding)
|
||||
- **No performance impact**: Only runs when edit button clicked
|
||||
|
||||
## Benefits Over Dialog Approach
|
||||
|
||||
1. **Familiar Interface**: Users already know CommandLauncher
|
||||
2. **Feature Complete**: All launcher features available (suggestions, hints, validation)
|
||||
3. **Less Code**: Removed entire EditCommandDialog component
|
||||
4. **Consistent UX**: Same interface for create and edit
|
||||
5. **Command History**: Users can use ↑/↓ navigation (already in CommandLauncher)
|
||||
6. **Visual Feedback**: Parsing hints, command matching, suggestions all work
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Add "(editing)" indicator in CommandLauncher title when in edit mode
|
||||
- [ ] Command history navigation with ↑/↓ (can leverage existing history feature)
|
||||
- [ ] Keyboard shortcut: ⌘E to edit focused window
|
||||
- [ ] Right-click context menu with edit option
|
||||
- [ ] Undo/Redo system (full Phase 3-5 from design doc)
|
||||
|
||||
## Conclusion
|
||||
|
||||
The final implementation achieves all MVP goals:
|
||||
- ✅ Edit any window command
|
||||
- ✅ Reuse familiar CommandLauncher interface
|
||||
- ✅ Intelligent command reconstruction for old windows
|
||||
- ✅ Full async support (NIP-05 resolution)
|
||||
- ✅ Clean architecture with Jotai atoms
|
||||
- ✅ Type-safe and production-ready
|
||||
|
||||
**Bonus achievements:**
|
||||
- ✅ Simpler than dialog approach (removed 130 lines of code)
|
||||
- ✅ Better UX (familiar interface)
|
||||
- ✅ Smart reconstruction with encoding (npub, note, naddr)
|
||||
- ✅ Handles all command types including complex req
|
||||
|
||||
The implementation is **production-ready** and provides an excellent user experience by leveraging existing, familiar components! 🎉
|
||||
@@ -1,223 +0,0 @@
|
||||
# Editable Commands MVP - Implementation Summary
|
||||
|
||||
**Date:** 2025-12-13
|
||||
**Status:** ✅ Complete
|
||||
**Complexity:** Medium
|
||||
|
||||
## Overview
|
||||
|
||||
Implemented the MVP for editable window commands, allowing users to edit the command that created a window (e.g., change `profile alice@domain.com` to `profile bob@domain.com`) with full async support and error handling.
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
### Phase 1: Foundation (Completed)
|
||||
|
||||
**Files Modified:**
|
||||
- `src/types/app.ts` - Added `commandString?: string` to WindowInstance
|
||||
- `src/core/logic.ts` - Implemented `updateWindow()` pure function and updated `addWindow()` signature
|
||||
- `src/core/state.ts` - Added `updateWindow` hook and modified `addWindow` to accept commandString parameter
|
||||
|
||||
**Key Features:**
|
||||
- ✅ Backward compatible: `commandString` is optional, existing windows continue working
|
||||
- ✅ Pure functional approach: all state mutations immutable
|
||||
- ✅ Type-safe: full TypeScript coverage
|
||||
|
||||
### Phase 2: Command Parser Utility (Completed)
|
||||
|
||||
**Files Created:**
|
||||
- `src/lib/command-parser.ts` - Reusable command parsing logic
|
||||
|
||||
**Exports:**
|
||||
- `parseCommandInput(input)` - Basic command parsing (command name + args)
|
||||
- `executeCommandParser(parsed)` - Executes argParser with async support
|
||||
- `parseAndExecuteCommand(input)` - Complete pipeline for command execution
|
||||
|
||||
**Key Features:**
|
||||
- ✅ DRY principle: single source of truth for parsing
|
||||
- ✅ Async support: handles NIP-05 resolution and other async operations
|
||||
- ✅ Error handling: returns structured errors for invalid commands
|
||||
- ✅ Reusable: used by both CommandLauncher and EditCommandDialog
|
||||
|
||||
### Phase 3: UI Components (Completed)
|
||||
|
||||
**Files Created:**
|
||||
- `src/components/EditCommandDialog.tsx` - Command editing dialog
|
||||
|
||||
**Files Modified:**
|
||||
- `src/components/CommandLauncher.tsx` - Now uses command-parser utility and passes commandString
|
||||
- `src/components/WindowToolbar.tsx` - Added edit button (Pencil icon) and dialog integration
|
||||
- `src/components/WindowTitle.tsx` - Passes window instance to WindowToolbar
|
||||
|
||||
**UI Features:**
|
||||
- ✅ Edit button with Pencil icon in window toolbar
|
||||
- ✅ Modal dialog for command editing
|
||||
- ✅ Loading states during async parsing (e.g., NIP-05 resolution)
|
||||
- ✅ Error display without closing dialog
|
||||
- ✅ Keyboard support (Enter to submit)
|
||||
- ✅ Fallback message for old windows without commandString
|
||||
- ✅ Disabled state while loading
|
||||
- ✅ Input validation (empty command prevention)
|
||||
|
||||
## Technical Highlights
|
||||
|
||||
### Async Command Support
|
||||
|
||||
The implementation fully supports async command parsers:
|
||||
|
||||
```typescript
|
||||
// Example: profile command with NIP-05 resolution
|
||||
argParser: async (args: string[]) => {
|
||||
const parsed = await parseProfileCommand(args);
|
||||
return parsed;
|
||||
}
|
||||
```
|
||||
|
||||
EditCommandDialog shows "Parsing command..." during async operations.
|
||||
|
||||
### Error Handling
|
||||
|
||||
Comprehensive error handling at multiple levels:
|
||||
1. **Parse errors**: Unknown command, invalid syntax
|
||||
2. **Async errors**: NIP-05 resolution failures, network issues
|
||||
3. **Validation errors**: Empty commands, malformed arguments
|
||||
|
||||
All errors displayed in dialog without closing, allowing user to fix issues.
|
||||
|
||||
### Command String Storage
|
||||
|
||||
Every new window now stores its original command:
|
||||
|
||||
```typescript
|
||||
// When creating window via CommandLauncher
|
||||
addWindow(command.appId, props, title, "profile alice@domain.com");
|
||||
|
||||
// Window object now includes:
|
||||
{
|
||||
id: "uuid",
|
||||
appId: "profile",
|
||||
title: "PROFILE alice@domain.com",
|
||||
props: { pubkey: "..." },
|
||||
commandString: "profile alice@domain.com" // NEW
|
||||
}
|
||||
```
|
||||
|
||||
### Window Updates
|
||||
|
||||
Editing a command can change:
|
||||
- **props**: New data for the window (e.g., different pubkey)
|
||||
- **title**: Display title updates automatically
|
||||
- **commandString**: Stores the new command
|
||||
- **appId**: Can even change the app type (e.g., profile → req)
|
||||
|
||||
```typescript
|
||||
// User edits: "profile alice" → "req -k 1"
|
||||
updateWindow(windowId, {
|
||||
props: { filter: { kinds: [1], limit: 50 } },
|
||||
title: "REQ -k 1",
|
||||
commandString: "req -k 1",
|
||||
appId: "req" // Window viewer changes completely!
|
||||
});
|
||||
```
|
||||
|
||||
## Edge Cases Handled
|
||||
|
||||
| Edge Case | Solution |
|
||||
|-----------|----------|
|
||||
| **Old windows without commandString** | Show message: "This window was created before command tracking" |
|
||||
| **Invalid command** | Display error, keep dialog open for fixing |
|
||||
| **Async parsing** | Show loading state, disable submit during resolution |
|
||||
| **Empty input** | Disable submit button, show validation error |
|
||||
| **Command changes appId** | Full window update, viewer changes to new app type |
|
||||
| **Parsing errors** | Graceful error display with specific error messages |
|
||||
|
||||
## Testing
|
||||
|
||||
### TypeScript Compilation
|
||||
✅ `npx tsc --noEmit` - No errors
|
||||
|
||||
### Dev Server
|
||||
✅ `npm run dev` - Running on http://localhost:5173/
|
||||
|
||||
### Manual Testing Scenarios
|
||||
|
||||
**Test 1: Edit Simple Command**
|
||||
1. Open window: `nip 01`
|
||||
2. Click edit button (Pencil icon)
|
||||
3. Change to: `nip 19`
|
||||
4. Submit → Window updates to show NIP-19
|
||||
|
||||
**Test 2: Edit Async Command (NIP-05)**
|
||||
1. Open window: `profile alice@domain.com`
|
||||
2. Click edit button
|
||||
3. Change to: `profile bob@domain.com`
|
||||
4. See "Parsing command..." loading state
|
||||
5. Window updates after NIP-05 resolution
|
||||
|
||||
**Test 3: Invalid Command**
|
||||
1. Open any window
|
||||
2. Click edit button
|
||||
3. Enter: `invalidcommand xyz`
|
||||
4. See error: "Unknown command: invalidcommand"
|
||||
5. Dialog stays open for correction
|
||||
|
||||
**Test 4: Change App Type**
|
||||
1. Open window: `profile alice@domain.com`
|
||||
2. Click edit button
|
||||
3. Change to: `req -k 1 -l 20`
|
||||
4. Window completely changes from ProfileViewer to ReqViewer
|
||||
|
||||
**Test 5: Old Window (No commandString)**
|
||||
1. Use existing window created before this feature
|
||||
2. Click edit button
|
||||
3. See message about command tracking
|
||||
4. Can still enter new command to update window
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
**Created (2 files):**
|
||||
- `src/lib/command-parser.ts`
|
||||
- `src/components/EditCommandDialog.tsx`
|
||||
|
||||
**Modified (6 files):**
|
||||
- `src/types/app.ts`
|
||||
- `src/core/logic.ts`
|
||||
- `src/core/state.ts`
|
||||
- `src/components/CommandLauncher.tsx`
|
||||
- `src/components/WindowToolbar.tsx`
|
||||
- `src/components/WindowTitle.tsx`
|
||||
|
||||
## Future Enhancements (Post-MVP)
|
||||
|
||||
- [ ] Keyboard shortcut: ⌘E to edit focused window
|
||||
- [ ] Command history navigation: ↑/↓ in edit dialog
|
||||
- [ ] Undo/Redo system (full Phase 3-5 from design doc)
|
||||
- [ ] Command validation before showing error (real-time)
|
||||
- [ ] Command suggestions/autocomplete in edit dialog
|
||||
- [ ] Right-click context menu with edit option
|
||||
|
||||
## Architecture Benefits
|
||||
|
||||
1. **Clean Separation**: Parser logic separated from UI
|
||||
2. **Reusability**: Parser used by CommandLauncher and EditCommandDialog
|
||||
3. **Type Safety**: Full TypeScript coverage
|
||||
4. **Testability**: Pure functions easy to unit test
|
||||
5. **Extensibility**: Easy to add command history, undo/redo later
|
||||
6. **Backward Compatible**: No breaking changes to existing code
|
||||
|
||||
## Performance
|
||||
|
||||
- **Memory**: Minimal (commandString adds ~50-100 bytes per window)
|
||||
- **Parsing**: <10ms for simple commands, 100-3000ms for async (NIP-05)
|
||||
- **UI Responsiveness**: Instant dialog open, loading states during async
|
||||
- **State Updates**: O(1) immutable updates via spread operators
|
||||
|
||||
## Conclusion
|
||||
|
||||
The editable commands MVP is **fully functional and production-ready**. Users can now:
|
||||
- ✅ Edit any window command
|
||||
- ✅ Handle async commands (NIP-05)
|
||||
- ✅ See clear error messages
|
||||
- ✅ Experience smooth loading states
|
||||
- ✅ Update window data instantly
|
||||
|
||||
The implementation follows the design document (Phases 1-2), maintains code quality standards, and provides an excellent foundation for future enhancements (history, undo/redo).
|
||||
@@ -1,589 +0,0 @@
|
||||
# Plan: Repository Tree Visualization for RepositoryDetailRenderer
|
||||
|
||||
## Overview
|
||||
|
||||
Add file tree exploration and file content viewing to the Repository detail renderer (kind 30617), using the `@fiatjaf/git-natural-api` library to fetch git trees via HTTP from clone URLs.
|
||||
|
||||
## Library Analysis: `@fiatjaf/git-natural-api`
|
||||
|
||||
**Package**: `jsr:@fiatjaf/git-natural-api@0.1.3`
|
||||
**Dependencies**: `@noble/hashes` (SHA-1), `fflate` (compression)
|
||||
|
||||
### Exported Functions
|
||||
|
||||
```typescript
|
||||
// Get directory tree at a ref (uses blob:none filter - tree structure only, no file contents)
|
||||
async function getDirectoryTreeAt(url: string, ref: string): Promise<DirectoryTree>
|
||||
|
||||
// Get full tree with file contents (shallow clone)
|
||||
async function shallowCloneRepositoryAt(url: string, ref: string): Promise<DirectoryTree>
|
||||
|
||||
// Get a single git object by hash
|
||||
async function getObject(url: string, hash: string): Promise<GitObject>
|
||||
|
||||
// Get info/refs from server (to find default branch, capabilities)
|
||||
async function getInfoRefs(url: string): Promise<InfoRefs>
|
||||
|
||||
// Lower-level utilities
|
||||
function loadTree(treeObject: GitObject, objects: Map<string, GitObject>): DirectoryTree
|
||||
function parseTree(data: Uint8Array): TreeEntry[]
|
||||
```
|
||||
|
||||
### Return Types (inferred from source)
|
||||
|
||||
```typescript
|
||||
interface DirectoryTree {
|
||||
directories: Array<{
|
||||
name: string
|
||||
hash: string
|
||||
content: DirectoryTree | null // null when using blob:none filter
|
||||
}>
|
||||
files: Array<{
|
||||
name: string
|
||||
hash: string
|
||||
content: Uint8Array | null // null when using blob:none filter
|
||||
}>
|
||||
}
|
||||
|
||||
interface InfoRefs {
|
||||
service: string | null
|
||||
refs: Record<string, string> // e.g., {"refs/heads/main": "abc123..."}
|
||||
capabilities: string[]
|
||||
symrefs: Record<string, string> // e.g., {"HEAD": "refs/heads/main"}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Classes
|
||||
|
||||
```typescript
|
||||
class MissingCapability extends Error {
|
||||
url: string
|
||||
capability: string
|
||||
}
|
||||
|
||||
class MissingRef extends Error {}
|
||||
```
|
||||
|
||||
### Required Server Capabilities
|
||||
|
||||
The library requires these git protocol capabilities:
|
||||
- `multi_ack_detailed` - Required, throws if missing
|
||||
- `side-band-64k` - Required, throws if missing
|
||||
- `shallow` - Required
|
||||
- `object-format=sha1` - Required
|
||||
- `filter` - Required for `getDirectoryTreeAt` (uses `blob:none`)
|
||||
- `ofs-delta` - Optional, used if available
|
||||
|
||||
**Important**: Many git servers (especially self-hosted) may not support the `filter` capability. Fallback to `shallowCloneRepositoryAt` is needed.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Core Infrastructure
|
||||
|
||||
#### 1.1 Add git-natural-api dependency
|
||||
|
||||
```bash
|
||||
npm install jsr:@fiatjaf/git-natural-api
|
||||
```
|
||||
|
||||
Or via import map / esm.sh for JSR packages.
|
||||
|
||||
#### 1.2 Create `useGitTree` hook
|
||||
|
||||
**File**: `src/hooks/useGitTree.ts`
|
||||
|
||||
```typescript
|
||||
interface UseGitTreeOptions {
|
||||
cloneUrls: string[] // Try multiple URLs in order
|
||||
ref?: string // Branch/tag/commit, defaults to HEAD
|
||||
}
|
||||
|
||||
interface UseGitTreeResult {
|
||||
tree: DirectoryTree | null
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
serverUrl: string | null // Which server succeeded
|
||||
refetch: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**Logic**:
|
||||
1. Try each clone URL in sequence
|
||||
2. First call `getInfoRefs` to check capabilities and resolve ref
|
||||
3. If `filter` capability exists, use `getDirectoryTreeAt` (lighter)
|
||||
4. Otherwise fall back to `shallowCloneRepositoryAt`
|
||||
5. Cache result (possibly in Dexie for offline access)
|
||||
6. Handle `MissingCapability`, `MissingRef` errors gracefully
|
||||
|
||||
#### 1.3 Create `useGitBlob` hook for file content
|
||||
|
||||
**File**: `src/hooks/useGitBlob.ts`
|
||||
|
||||
```typescript
|
||||
interface UseGitBlobOptions {
|
||||
serverUrl: string
|
||||
hash: string
|
||||
}
|
||||
|
||||
interface UseGitBlobResult {
|
||||
content: Uint8Array | null
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
}
|
||||
```
|
||||
|
||||
Use `getObject(url, hash)` to fetch individual file blobs on demand.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: File Tree Component
|
||||
|
||||
#### 2.1 Create `FileTreeView` component
|
||||
|
||||
**File**: `src/components/ui/FileTreeView.tsx`
|
||||
|
||||
Design goals:
|
||||
- Match Grimoire's dark aesthetic with existing UI primitives
|
||||
- Use Radix Collapsible/Accordion for expand/collapse
|
||||
- Lucide icons: `Folder`, `FolderOpen`, `File`, `FileCode`, `FileText`, etc.
|
||||
- Support keyboard navigation (arrow keys, enter to expand/select)
|
||||
|
||||
```typescript
|
||||
interface FileTreeViewProps {
|
||||
tree: DirectoryTree
|
||||
onFileSelect: (file: { name: string; hash: string; path: string }) => void
|
||||
selectedPath?: string
|
||||
className?: string
|
||||
}
|
||||
```
|
||||
|
||||
**Structure**:
|
||||
- Recursive component for directories
|
||||
- File icons based on extension (map common extensions to icons)
|
||||
- Alphabetical sorting (directories first)
|
||||
- Expandable directory nodes with chevron
|
||||
- Click file to select → triggers file content fetch
|
||||
|
||||
#### 2.2 File Icon Mapping
|
||||
|
||||
```typescript
|
||||
const fileIcons: Record<string, LucideIcon> = {
|
||||
// Code files
|
||||
'.ts': FileCode, '.tsx': FileCode, '.js': FileCode, '.jsx': FileCode,
|
||||
'.py': FileCode, '.rs': FileCode, '.go': FileCode, '.rb': FileCode,
|
||||
// Config/data
|
||||
'.json': FileJson, '.yaml': FileText, '.yml': FileText, '.toml': FileText,
|
||||
// Documentation
|
||||
'.md': FileText, '.txt': FileText, '.rst': FileText,
|
||||
// Default
|
||||
'default': File,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Syntax Highlighting (Lazy Loading)
|
||||
|
||||
#### 3.1 Migrate to Shiki with on-demand loading
|
||||
|
||||
**Current state**: Using Prism.js with statically imported languages (diff, js, ts, jsx, tsx, bash, json, markdown, css, python, yaml).
|
||||
|
||||
**Problem**: Loading all highlighters upfront is wasteful for file tree where we need many more languages.
|
||||
|
||||
**Solution**: Use Shiki with fine-grained bundles and lazy loading.
|
||||
|
||||
**File**: `src/lib/shiki.ts`
|
||||
|
||||
```typescript
|
||||
import { createHighlighterCore, type HighlighterCore } from 'shiki/core'
|
||||
import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
|
||||
|
||||
let highlighter: HighlighterCore | null = null
|
||||
const loadedLanguages = new Set<string>()
|
||||
const loadedThemes = new Set<string>()
|
||||
|
||||
// Language to Shiki language ID mapping
|
||||
const languageMap: Record<string, string> = {
|
||||
'ts': 'typescript',
|
||||
'tsx': 'tsx',
|
||||
'js': 'javascript',
|
||||
'jsx': 'jsx',
|
||||
'py': 'python',
|
||||
'rs': 'rust',
|
||||
'go': 'go',
|
||||
'rb': 'ruby',
|
||||
'json': 'json',
|
||||
'yaml': 'yaml',
|
||||
'yml': 'yaml',
|
||||
'md': 'markdown',
|
||||
'css': 'css',
|
||||
'html': 'html',
|
||||
'sh': 'bash',
|
||||
'bash': 'bash',
|
||||
'diff': 'diff',
|
||||
// ... more mappings
|
||||
}
|
||||
|
||||
export async function getHighlighter(): Promise<HighlighterCore> {
|
||||
if (!highlighter) {
|
||||
highlighter = await createHighlighterCore({
|
||||
themes: [import('@shikijs/themes/github-dark')],
|
||||
langs: [], // Start empty, load on demand
|
||||
engine: createOnigurumaEngine(import('shiki/wasm'))
|
||||
})
|
||||
loadedThemes.add('github-dark')
|
||||
}
|
||||
return highlighter
|
||||
}
|
||||
|
||||
export async function highlightCode(code: string, ext: string): Promise<string> {
|
||||
const lang = languageMap[ext.replace('.', '')] || 'text'
|
||||
const hl = await getHighlighter()
|
||||
|
||||
// Load language if not already loaded
|
||||
if (!loadedLanguages.has(lang) && lang !== 'text') {
|
||||
try {
|
||||
await hl.loadLanguage(import(`@shikijs/langs/${lang}`))
|
||||
loadedLanguages.add(lang)
|
||||
} catch {
|
||||
// Language not available, fall back to plaintext
|
||||
}
|
||||
}
|
||||
|
||||
return hl.codeToHtml(code, { lang: loadedLanguages.has(lang) ? lang : 'text', theme: 'github-dark' })
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 Create `LazyCodeViewer` component
|
||||
|
||||
**File**: `src/components/ui/LazyCodeViewer.tsx`
|
||||
|
||||
```typescript
|
||||
interface LazyCodeViewerProps {
|
||||
content: Uint8Array | string
|
||||
filename: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LazyCodeViewer({ content, filename, className }: LazyCodeViewerProps) {
|
||||
const [html, setHtml] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const ext = getFileExtension(filename)
|
||||
|
||||
useEffect(() => {
|
||||
const text = typeof content === 'string'
|
||||
? content
|
||||
: new TextDecoder().decode(content)
|
||||
|
||||
highlightCode(text, ext)
|
||||
.then(setHtml)
|
||||
.finally(() => setLoading(false))
|
||||
}, [content, ext])
|
||||
|
||||
if (loading) return <Skeleton className="h-48" />
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("overflow-auto font-mono text-xs", className)}
|
||||
dangerouslySetInnerHTML={{ __html: html || '' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3 Theme Integration
|
||||
|
||||
Create a Shiki theme that matches Grimoire's CSS variables, or use a compatible preset (github-dark, one-dark-pro) and apply custom CSS overrides.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: RepositoryDetailRenderer Integration
|
||||
|
||||
#### 4.1 Add Tree Section to Detail Renderer
|
||||
|
||||
**File**: `src/components/nostr/kinds/RepositoryDetailRenderer.tsx`
|
||||
|
||||
Add new "Files" section after URLs/Maintainers:
|
||||
|
||||
```tsx
|
||||
// In RepositoryDetailRenderer
|
||||
const cloneUrls = useMemo(() => getCloneUrls(event), [event])
|
||||
|
||||
const { tree, loading, error, serverUrl } = useGitTree({
|
||||
cloneUrls,
|
||||
ref: 'HEAD' // or allow branch selection later
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 max-w-3xl mx-auto">
|
||||
{/* ... existing sections ... */}
|
||||
|
||||
{/* Files Section */}
|
||||
{cloneUrls.length > 0 && (
|
||||
<RepositoryFilesSection
|
||||
cloneUrls={cloneUrls}
|
||||
tree={tree}
|
||||
loading={loading}
|
||||
error={error}
|
||||
serverUrl={serverUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
```
|
||||
|
||||
#### 4.2 Create `RepositoryFilesSection` component
|
||||
|
||||
**File**: `src/components/nostr/kinds/RepositoryFilesSection.tsx`
|
||||
|
||||
```tsx
|
||||
interface RepositoryFilesSectionProps {
|
||||
cloneUrls: string[]
|
||||
tree: DirectoryTree | null
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
serverUrl: string | null
|
||||
}
|
||||
|
||||
export function RepositoryFilesSection({
|
||||
cloneUrls,
|
||||
tree,
|
||||
loading,
|
||||
error,
|
||||
serverUrl
|
||||
}: RepositoryFilesSectionProps) {
|
||||
const [selectedFile, setSelectedFile] = useState<{name: string, hash: string, path: string} | null>(null)
|
||||
|
||||
// Fetch file content when selected
|
||||
const { content, loading: contentLoading } = useGitBlob({
|
||||
serverUrl,
|
||||
hash: selectedFile?.hash
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<FolderTree className="size-5" />
|
||||
Files
|
||||
</h2>
|
||||
<Skeleton className="h-48" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<FolderTree className="size-5" />
|
||||
Files
|
||||
</h2>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>Unable to load repository files.</p>
|
||||
<p className="text-xs mt-1">{error.message}</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (!tree) return null
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<FolderTree className="size-5" />
|
||||
Files
|
||||
{serverUrl && (
|
||||
<span className="text-xs text-muted-foreground font-normal ml-2">
|
||||
from {new URL(serverUrl).hostname}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* File Tree */}
|
||||
<div className="border border-border rounded p-2 max-h-96 overflow-auto">
|
||||
<FileTreeView
|
||||
tree={tree}
|
||||
onFileSelect={setSelectedFile}
|
||||
selectedPath={selectedFile?.path}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File Content Preview */}
|
||||
<div className="border border-border rounded p-2 max-h-96 overflow-auto">
|
||||
{selectedFile ? (
|
||||
contentLoading ? (
|
||||
<Skeleton className="h-full" />
|
||||
) : content ? (
|
||||
<LazyCodeViewer content={content} filename={selectedFile.name} />
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground p-4">
|
||||
Unable to load file content
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground p-4 flex items-center justify-center h-full">
|
||||
Select a file to view its contents
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Resilience & Caching
|
||||
|
||||
#### 5.1 Multi-Server Fallback
|
||||
|
||||
Repositories have multiple clone URLs. Try each in order:
|
||||
|
||||
```typescript
|
||||
async function tryFetchTree(cloneUrls: string[], ref: string): Promise<{
|
||||
tree: DirectoryTree
|
||||
serverUrl: string
|
||||
}> {
|
||||
const errors: Error[] = []
|
||||
|
||||
for (const url of cloneUrls) {
|
||||
try {
|
||||
// Check capabilities first
|
||||
const info = await getInfoRefs(url)
|
||||
const hasFilter = info.capabilities.includes('filter')
|
||||
|
||||
// Resolve ref if symbolic
|
||||
const resolvedRef = ref.startsWith('refs/')
|
||||
? info.refs[ref]
|
||||
: info.symrefs['HEAD']?.startsWith('refs/')
|
||||
? info.refs[info.symrefs['HEAD']]
|
||||
: ref
|
||||
|
||||
const tree = hasFilter
|
||||
? await getDirectoryTreeAt(url, resolvedRef)
|
||||
: await shallowCloneRepositoryAt(url, resolvedRef)
|
||||
|
||||
return { tree, serverUrl: url }
|
||||
} catch (e) {
|
||||
errors.push(e as Error)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
throw new AggregateError(errors, 'All servers failed')
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.2 Dexie Caching
|
||||
|
||||
Cache trees and blobs in IndexedDB for offline access:
|
||||
|
||||
```typescript
|
||||
// In src/services/db.ts
|
||||
interface GitTreeCache {
|
||||
id: string // `${serverUrl}:${ref}`
|
||||
tree: DirectoryTree
|
||||
fetchedAt: number
|
||||
}
|
||||
|
||||
interface GitBlobCache {
|
||||
hash: string
|
||||
content: Uint8Array
|
||||
fetchedAt: number
|
||||
}
|
||||
|
||||
// Add to Dexie schema
|
||||
db.version(X).stores({
|
||||
// ... existing stores ...
|
||||
gitTrees: 'id, fetchedAt',
|
||||
gitBlobs: 'hash, fetchedAt'
|
||||
})
|
||||
```
|
||||
|
||||
TTL: 1 hour for trees, indefinite for blobs (content-addressed).
|
||||
|
||||
---
|
||||
|
||||
## File Tree UI Options
|
||||
|
||||
Given Grimoire's aesthetic (dark theme, Tailwind, Radix primitives), we should build a custom component rather than import a library. Options considered:
|
||||
|
||||
1. **Custom with Radix Collapsible** (Recommended)
|
||||
- Full control over styling
|
||||
- Matches existing app patterns
|
||||
- Uses existing dependencies
|
||||
|
||||
2. **FlyonUI Tree View**
|
||||
- Tailwind-based
|
||||
- Would require adding Preline plugins
|
||||
- Overkill for our needs
|
||||
|
||||
3. **react-arborist**
|
||||
- Full-featured tree with virtualization
|
||||
- Heavy dependency
|
||||
- More than we need
|
||||
|
||||
**Recommendation**: Build custom using Radix Collapsible + Lucide icons, following patterns from existing Accordion component.
|
||||
|
||||
---
|
||||
|
||||
## Bundle Size Considerations
|
||||
|
||||
### Current Prism Setup
|
||||
- `prismjs` core: ~20KB
|
||||
- Each language: 1-5KB
|
||||
- Currently importing 11 languages statically
|
||||
|
||||
### Shiki Migration
|
||||
- `shiki/core`: ~15KB
|
||||
- Each language: 10-50KB (grammars are larger but more accurate)
|
||||
- WASM engine: ~200KB (one-time)
|
||||
- Themes: 2-5KB each
|
||||
|
||||
**Strategy**:
|
||||
- Keep Prism for existing uses (diff highlighting in patches)
|
||||
- Use Shiki only for the file viewer with lazy loading
|
||||
- Or fully migrate to Shiki with lazy loading everywhere
|
||||
|
||||
**Recommendation**: Full Shiki migration for consistency, using lazy loading for all languages.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Core hooks** (`useGitTree`, `useGitBlob`)
|
||||
2. **FileTreeView component** (minimal, collapsible tree)
|
||||
3. **RepositoryFilesSection** integration
|
||||
4. **Shiki migration** with lazy loading
|
||||
5. **LazyCodeViewer** component
|
||||
6. **Caching layer** in Dexie
|
||||
7. **Polish** (loading states, error handling, keyboard nav)
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Unit tests for file extension → language mapping
|
||||
- Unit tests for tree traversal/sorting
|
||||
- Integration tests with mock git server responses
|
||||
- Manual testing with various repository types (GitHub, GitLab, self-hosted)
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Filter capability**: Many self-hosted git servers don't support `blob:none` filter. Fallback to full shallow clone works but is heavier.
|
||||
|
||||
2. **Large repositories**: Tree can be huge for monorepos. Consider pagination/virtualization for 1000+ files.
|
||||
|
||||
3. **Binary files**: Need detection and handling (show "Binary file" instead of trying to decode).
|
||||
|
||||
4. **Private repositories**: The API works with public repos only. Would need auth token support for private.
|
||||
|
||||
5. **CORS**: Some git servers may not allow browser requests. May need a proxy or show a helpful error.
|
||||
@@ -1,699 +0,0 @@
|
||||
# Plan: Migrate from Prism.js to Shiki with Lazy Loading
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the current Prism.js-based syntax highlighting with Shiki, enabling on-demand language loading while preserving the current minimalistic styling.
|
||||
|
||||
---
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Files Using `SyntaxHighlight`
|
||||
|
||||
| File | Language(s) Used | Context |
|
||||
|------|------------------|---------|
|
||||
| `PatchDetailRenderer.tsx` | `diff` | Git patches |
|
||||
| `CodeSnippetRenderer.tsx` | dynamic (via `mapLanguage()`) | NIP-C0 code snippets |
|
||||
| `CodeSnippetDetailRenderer.tsx` | dynamic (via `normalizedLanguage`) | Code snippet detail view |
|
||||
| `MarkdownContent.tsx` | dynamic (from markdown) | Article code blocks |
|
||||
| `JsonEventRow.tsx` | `json` | Event JSON view |
|
||||
| `DebugViewer.tsx` | `json` | App state debug |
|
||||
| `JsonViewer.tsx` | `json` | JSON dialog |
|
||||
| `ReqViewer.tsx` | `json` | Filter JSON display |
|
||||
|
||||
### Current Prism Setup
|
||||
|
||||
**Statically loaded languages** (`src/components/SyntaxHighlight.tsx`):
|
||||
- diff, javascript, typescript, jsx, tsx
|
||||
- bash, json, markdown, css, python, yaml
|
||||
|
||||
**Theme**: Custom CSS in `src/styles/prism-theme.css` using CSS variables:
|
||||
- Uses `hsl(var(--foreground))`, `hsl(var(--muted-foreground))`, `hsl(var(--primary))`
|
||||
- Special handling for diff tokens (deleted, inserted, coord)
|
||||
- Minimal color palette - mostly foreground/muted with primary accents
|
||||
|
||||
### Problems with Current Approach
|
||||
|
||||
1. **Bundle bloat**: All 11 language grammars loaded upfront (~30KB)
|
||||
2. **Limited languages**: Markdown code blocks with unsupported languages show no highlighting
|
||||
3. **No extension path**: Adding new languages requires editing component imports
|
||||
4. **Prism limitations**: Less accurate grammars compared to TextMate (used by VS Code/Shiki)
|
||||
|
||||
---
|
||||
|
||||
## Shiki Benefits
|
||||
|
||||
1. **TextMate grammars**: Same grammars as VS Code, more accurate highlighting
|
||||
2. **Lazy loading**: Languages loaded on-demand via dynamic imports
|
||||
3. **Fine-grained bundles**: Control exactly what's bundled
|
||||
4. **Theme flexibility**: CSS variables or inline styles
|
||||
5. **200+ languages**: Support virtually any language users might use
|
||||
|
||||
---
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Core Infrastructure
|
||||
|
||||
#### 1.1 Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install shiki
|
||||
```
|
||||
|
||||
**Bundle impact**:
|
||||
- `shiki/core`: ~15KB (min+gzip)
|
||||
- Oniguruma WASM: ~200KB (one-time, cached)
|
||||
- Languages: 10-50KB each (lazy loaded)
|
||||
- Theme: 2-5KB
|
||||
|
||||
#### 1.2 Create Shiki Service
|
||||
|
||||
**File**: `src/lib/shiki.ts`
|
||||
|
||||
```typescript
|
||||
import { createHighlighterCore, type HighlighterCore } from 'shiki/core'
|
||||
import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
|
||||
|
||||
// Singleton highlighter instance
|
||||
let highlighter: HighlighterCore | null = null
|
||||
let highlighterPromise: Promise<HighlighterCore> | null = null
|
||||
const loadedLanguages = new Set<string>()
|
||||
|
||||
// Grimoire dark theme matching current Prism styles
|
||||
const grimoireTheme = {
|
||||
name: 'grimoire-dark',
|
||||
type: 'dark' as const,
|
||||
colors: {
|
||||
'editor.background': '#000000',
|
||||
'editor.foreground': '#e5e5e5',
|
||||
},
|
||||
tokenColors: [
|
||||
// Comments - muted
|
||||
{
|
||||
scope: ['comment', 'punctuation.definition.comment'],
|
||||
settings: { foreground: '#6b7280' } // muted-foreground
|
||||
},
|
||||
// Strings - muted but slightly emphasized
|
||||
{
|
||||
scope: ['string', 'string.quoted'],
|
||||
settings: { foreground: '#9ca3af', fontStyle: '' }
|
||||
},
|
||||
// Keywords, operators - primary color
|
||||
{
|
||||
scope: ['keyword', 'storage', 'keyword.operator'],
|
||||
settings: { foreground: '#a855f7' } // primary purple
|
||||
},
|
||||
// Functions, classes - primary bold
|
||||
{
|
||||
scope: ['entity.name.function', 'entity.name.class', 'entity.name.type'],
|
||||
settings: { foreground: '#a855f7', fontStyle: 'bold' }
|
||||
},
|
||||
// Numbers, constants - primary
|
||||
{
|
||||
scope: ['constant', 'constant.numeric', 'constant.language'],
|
||||
settings: { foreground: '#a855f7' }
|
||||
},
|
||||
// Variables, parameters - foreground
|
||||
{
|
||||
scope: ['variable', 'variable.parameter'],
|
||||
settings: { foreground: '#e5e5e5' }
|
||||
},
|
||||
// Punctuation - slightly muted
|
||||
{
|
||||
scope: ['punctuation'],
|
||||
settings: { foreground: '#b3b3b3' }
|
||||
},
|
||||
// Properties, attributes
|
||||
{
|
||||
scope: ['variable.other.property', 'entity.other.attribute-name'],
|
||||
settings: { foreground: '#d4d4d4' }
|
||||
},
|
||||
// Tags (HTML/JSX)
|
||||
{
|
||||
scope: ['entity.name.tag'],
|
||||
settings: { foreground: '#a855f7' }
|
||||
},
|
||||
// Diff - deleted (red)
|
||||
{
|
||||
scope: ['markup.deleted', 'punctuation.definition.deleted'],
|
||||
settings: { foreground: '#ff8787' }
|
||||
},
|
||||
// Diff - inserted (green)
|
||||
{
|
||||
scope: ['markup.inserted', 'punctuation.definition.inserted'],
|
||||
settings: { foreground: '#69db7c' }
|
||||
},
|
||||
// Diff - changed
|
||||
{
|
||||
scope: ['markup.changed'],
|
||||
settings: { foreground: '#66d9ef' }
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Language alias mapping (file extensions and common names to Shiki IDs)
|
||||
*/
|
||||
const LANGUAGE_ALIASES: Record<string, string> = {
|
||||
// Extensions
|
||||
'ts': 'typescript',
|
||||
'tsx': 'tsx',
|
||||
'js': 'javascript',
|
||||
'jsx': 'jsx',
|
||||
'py': 'python',
|
||||
'rb': 'ruby',
|
||||
'rs': 'rust',
|
||||
'go': 'go',
|
||||
'sh': 'bash',
|
||||
'bash': 'bash',
|
||||
'shell': 'bash',
|
||||
'zsh': 'bash',
|
||||
'yml': 'yaml',
|
||||
'yaml': 'yaml',
|
||||
'md': 'markdown',
|
||||
'json': 'json',
|
||||
'jsonc': 'jsonc',
|
||||
'css': 'css',
|
||||
'scss': 'scss',
|
||||
'sass': 'sass',
|
||||
'less': 'less',
|
||||
'html': 'html',
|
||||
'htm': 'html',
|
||||
'xml': 'xml',
|
||||
'svg': 'xml',
|
||||
'sql': 'sql',
|
||||
'c': 'c',
|
||||
'cpp': 'cpp',
|
||||
'c++': 'cpp',
|
||||
'h': 'c',
|
||||
'hpp': 'cpp',
|
||||
'cs': 'csharp',
|
||||
'java': 'java',
|
||||
'kt': 'kotlin',
|
||||
'swift': 'swift',
|
||||
'php': 'php',
|
||||
'lua': 'lua',
|
||||
'vim': 'viml',
|
||||
'toml': 'toml',
|
||||
'ini': 'ini',
|
||||
'dockerfile': 'dockerfile',
|
||||
'docker': 'dockerfile',
|
||||
'makefile': 'makefile',
|
||||
'make': 'makefile',
|
||||
'diff': 'diff',
|
||||
'patch': 'diff',
|
||||
'sol': 'solidity',
|
||||
'zig': 'zig',
|
||||
'ex': 'elixir',
|
||||
'exs': 'elixir',
|
||||
'erl': 'erlang',
|
||||
'hs': 'haskell',
|
||||
'ml': 'ocaml',
|
||||
'clj': 'clojure',
|
||||
'scala': 'scala',
|
||||
'graphql': 'graphql',
|
||||
'gql': 'graphql',
|
||||
'proto': 'protobuf',
|
||||
'nix': 'nix',
|
||||
}
|
||||
|
||||
/**
|
||||
* Core languages to preload (most commonly used)
|
||||
*/
|
||||
const CORE_LANGUAGES = ['javascript', 'typescript', 'json', 'diff', 'bash']
|
||||
|
||||
/**
|
||||
* Normalize language identifier to Shiki language ID
|
||||
*/
|
||||
export function normalizeLanguage(lang: string | null | undefined): string {
|
||||
if (!lang) return 'text'
|
||||
const normalized = lang.toLowerCase().trim()
|
||||
return LANGUAGE_ALIASES[normalized] || normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the singleton highlighter instance
|
||||
*/
|
||||
export async function getHighlighter(): Promise<HighlighterCore> {
|
||||
if (highlighter) return highlighter
|
||||
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = createHighlighterCore({
|
||||
themes: [grimoireTheme],
|
||||
langs: CORE_LANGUAGES.map(lang => import(`shiki/langs/${lang}.mjs`)),
|
||||
engine: createOnigurumaEngine(import('shiki/wasm'))
|
||||
}).then(hl => {
|
||||
highlighter = hl
|
||||
CORE_LANGUAGES.forEach(l => loadedLanguages.add(l))
|
||||
return hl
|
||||
})
|
||||
}
|
||||
|
||||
return highlighterPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a language on demand
|
||||
*/
|
||||
async function loadLanguage(lang: string): Promise<boolean> {
|
||||
if (lang === 'text' || loadedLanguages.has(lang)) return true
|
||||
|
||||
const hl = await getHighlighter()
|
||||
|
||||
try {
|
||||
// Dynamic import for the language
|
||||
await hl.loadLanguage(import(`shiki/langs/${lang}.mjs`))
|
||||
loadedLanguages.add(lang)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.warn(`[shiki] Language "${lang}" not available, falling back to plaintext`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight code with lazy language loading
|
||||
* Returns HTML string
|
||||
*/
|
||||
export async function highlightCode(
|
||||
code: string,
|
||||
language: string | null | undefined
|
||||
): Promise<string> {
|
||||
const lang = normalizeLanguage(language)
|
||||
const hl = await getHighlighter()
|
||||
|
||||
// Try to load the language if not already loaded
|
||||
const loaded = await loadLanguage(lang)
|
||||
const effectiveLang = loaded ? lang : 'text'
|
||||
|
||||
return hl.codeToHtml(code, {
|
||||
lang: effectiveLang,
|
||||
theme: 'grimoire-dark'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a language is available (loaded or loadable)
|
||||
*/
|
||||
export function isLanguageLoaded(lang: string): boolean {
|
||||
return loadedLanguages.has(normalizeLanguage(lang))
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload languages (e.g., on app startup or before rendering)
|
||||
*/
|
||||
export async function preloadLanguages(langs: string[]): Promise<void> {
|
||||
await getHighlighter()
|
||||
await Promise.all(langs.map(l => loadLanguage(normalizeLanguage(l))))
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 Create React Hook
|
||||
|
||||
**File**: `src/hooks/useHighlightedCode.ts`
|
||||
|
||||
```typescript
|
||||
import { useState, useEffect } from 'react'
|
||||
import { highlightCode, normalizeLanguage } from '@/lib/shiki'
|
||||
|
||||
interface UseHighlightedCodeResult {
|
||||
html: string | null
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to highlight code asynchronously with lazy language loading
|
||||
*/
|
||||
export function useHighlightedCode(
|
||||
code: string,
|
||||
language: string | null | undefined
|
||||
): UseHighlightedCodeResult {
|
||||
const [html, setHtml] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
highlightCode(code, language)
|
||||
.then(result => {
|
||||
if (!cancelled) {
|
||||
setHtml(result)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
if (!cancelled) {
|
||||
setError(err)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [code, language])
|
||||
|
||||
return { html, loading, error }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: New SyntaxHighlight Component
|
||||
|
||||
#### 2.1 Replace SyntaxHighlight Component
|
||||
|
||||
**File**: `src/components/SyntaxHighlight.tsx` (rewrite)
|
||||
|
||||
```typescript
|
||||
import { useHighlightedCode } from '@/hooks/useHighlightedCode'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SyntaxHighlightProps {
|
||||
code: string
|
||||
language?: string | null
|
||||
className?: string
|
||||
showLineNumbers?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Syntax highlighting component using Shiki with lazy language loading
|
||||
*
|
||||
* @example
|
||||
* <SyntaxHighlight code={patchContent} language="diff" />
|
||||
* <SyntaxHighlight code={jsonStr} language="json" />
|
||||
*/
|
||||
export function SyntaxHighlight({
|
||||
code,
|
||||
language,
|
||||
className = '',
|
||||
showLineNumbers = false,
|
||||
}: SyntaxHighlightProps) {
|
||||
const { html, loading, error } = useHighlightedCode(code, language)
|
||||
|
||||
// Loading state - show code without highlighting
|
||||
if (loading) {
|
||||
return (
|
||||
<pre className={cn(
|
||||
'shiki-loading overflow-x-auto max-w-full font-mono text-xs',
|
||||
className
|
||||
)}>
|
||||
<code className="text-foreground/70">{code}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state - fallback to plain code
|
||||
if (error || !html) {
|
||||
return (
|
||||
<pre className={cn(
|
||||
'overflow-x-auto max-w-full font-mono text-xs',
|
||||
className
|
||||
)}>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
// Render highlighted HTML
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'shiki-container overflow-x-auto max-w-full [&_pre]:!bg-transparent [&_code]:text-xs [&_code]:font-mono',
|
||||
showLineNumbers && 'line-numbers',
|
||||
className
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: CSS Theme Migration
|
||||
|
||||
#### 3.1 Update Styles
|
||||
|
||||
**File**: `src/styles/shiki-theme.css` (new file, replaces prism-theme.css)
|
||||
|
||||
```css
|
||||
/* Shiki syntax highlighting - Grimoire dark theme overrides */
|
||||
|
||||
/* Base container styling */
|
||||
.shiki-container pre {
|
||||
background: transparent !important;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.shiki-container code {
|
||||
font-family: "Oxygen Mono", monospace;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
/* Loading state - subtle pulse animation */
|
||||
.shiki-loading {
|
||||
animation: shiki-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shiki-pulse {
|
||||
0%, 100% { opacity: 0.7; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* Diff-specific styling - block-level backgrounds for inserted/deleted */
|
||||
.shiki-container .line.diff.add {
|
||||
background: rgba(52, 199, 89, 0.1);
|
||||
display: block;
|
||||
margin: 0 -1rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.shiki-container .line.diff.remove {
|
||||
background: rgba(255, 59, 48, 0.1);
|
||||
display: block;
|
||||
margin: 0 -1rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Hunk headers (@@ lines) */
|
||||
.shiki-container .line.diff.info {
|
||||
background: rgba(102, 217, 239, 0.08);
|
||||
display: block;
|
||||
margin: 0 -1rem;
|
||||
padding: 0 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Optional: Line numbers */
|
||||
.shiki-container.line-numbers .line::before {
|
||||
content: attr(data-line);
|
||||
display: inline-block;
|
||||
width: 2rem;
|
||||
margin-right: 1rem;
|
||||
text-align: right;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 Update index.css
|
||||
|
||||
**File**: `src/index.css` (modify)
|
||||
|
||||
```css
|
||||
/* Replace Prism import with Shiki */
|
||||
/* @import "./styles/prism-theme.css"; */ /* Remove */
|
||||
@import "./styles/shiki-theme.css"; /* Add */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Component Updates
|
||||
|
||||
#### 4.1 Update Consumers (Minimal Changes)
|
||||
|
||||
Most components need no changes since the `SyntaxHighlight` interface remains the same:
|
||||
|
||||
```typescript
|
||||
// Before (Prism)
|
||||
<SyntaxHighlight code={code} language="json" />
|
||||
|
||||
// After (Shiki) - same API!
|
||||
<SyntaxHighlight code={code} language="json" />
|
||||
```
|
||||
|
||||
#### 4.2 Update MarkdownContent for Async Highlighting
|
||||
|
||||
The `CodeBlock` component in `MarkdownContent.tsx` needs slight adjustment to handle the async nature:
|
||||
|
||||
```typescript
|
||||
// In MarkdownContent.tsx, update CodeBlock:
|
||||
function CodeBlock({ code, language }: { code: string; language: string | null }) {
|
||||
const { copy, copied } = useCopy()
|
||||
const isSingleLine = !code.includes('\n')
|
||||
|
||||
return (
|
||||
<div className="relative my-4">
|
||||
<SyntaxHighlight
|
||||
code={code}
|
||||
language={language}
|
||||
className="bg-muted p-4 border border-border rounded overflow-x-auto max-w-full"
|
||||
/>
|
||||
{!isSingleLine && (
|
||||
<CodeCopyButton onCopy={() => copy(code)} copied={copied} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The `language={language as any}` casts can be removed since Shiki accepts any string.
|
||||
|
||||
#### 4.3 Update CodeSnippetRenderer Language Mapping
|
||||
|
||||
Simplify since Shiki handles more languages:
|
||||
|
||||
```typescript
|
||||
// Before: Complex mapLanguage with limited support
|
||||
function mapLanguage(lang: string | null | undefined): "javascript" | ... { }
|
||||
|
||||
// After: Just normalize, Shiki handles the rest
|
||||
import { normalizeLanguage } from '@/lib/shiki'
|
||||
// Use normalizeLanguage(language) - returns valid language or 'text'
|
||||
```
|
||||
|
||||
#### 4.4 Remove Prism-specific Code
|
||||
|
||||
In `CodeSnippetDetailRenderer.tsx`, simplify the language validation:
|
||||
|
||||
```typescript
|
||||
// Before: Manual list of supported languages
|
||||
const supported = ['javascript', 'typescript', ...]
|
||||
return supported.includes(mapped) ? mapped : null
|
||||
|
||||
// After: Let Shiki handle it
|
||||
const normalizedLanguage = normalizeLanguage(language)
|
||||
// Always use it - Shiki will fall back to plaintext if unknown
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Cleanup
|
||||
|
||||
#### 5.1 Remove Prism Dependencies
|
||||
|
||||
```bash
|
||||
npm uninstall prismjs @types/prismjs
|
||||
```
|
||||
|
||||
#### 5.2 Delete Old Files
|
||||
|
||||
- `src/styles/prism-theme.css` → deleted (replaced by shiki-theme.css)
|
||||
|
||||
#### 5.3 Remove Prism Imports
|
||||
|
||||
Search and remove all:
|
||||
```typescript
|
||||
import Prism from "prismjs"
|
||||
import "prismjs/components/prism-*"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Summary
|
||||
|
||||
### Files to Create
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/lib/shiki.ts` | Shiki service with lazy loading |
|
||||
| `src/hooks/useHighlightedCode.ts` | React hook for async highlighting |
|
||||
| `src/styles/shiki-theme.css` | Theme styles |
|
||||
|
||||
### Files to Modify
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `src/components/SyntaxHighlight.tsx` | Complete rewrite |
|
||||
| `src/index.css` | Replace Prism import with Shiki |
|
||||
| `src/components/nostr/MarkdownContent.tsx` | Remove `as any` casts |
|
||||
| `src/components/nostr/kinds/CodeSnippetRenderer.tsx` | Simplify mapLanguage |
|
||||
| `src/components/nostr/kinds/CodeSnippetDetailRenderer.tsx` | Remove supported list |
|
||||
|
||||
### Files to Delete
|
||||
| File | Reason |
|
||||
|------|--------|
|
||||
| `src/styles/prism-theme.css` | Replaced by shiki-theme.css |
|
||||
|
||||
### Dependencies
|
||||
| Remove | Add |
|
||||
|--------|-----|
|
||||
| `prismjs` | `shiki` |
|
||||
| `@types/prismjs` | - |
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] **JSON highlighting**: DebugViewer, JsonViewer, JsonEventRow, ReqViewer
|
||||
- [ ] **Diff highlighting**: PatchDetailRenderer (git patches)
|
||||
- [ ] **Code snippets**: CodeSnippetRenderer, CodeSnippetDetailRenderer
|
||||
- [ ] **Markdown code blocks**: MarkdownContent (articles, NIPs)
|
||||
- [ ] JavaScript/TypeScript blocks
|
||||
- [ ] Python blocks
|
||||
- [ ] Unknown language blocks (should fallback gracefully)
|
||||
- [ ] **Loading states**: Check flash/pulse during language loading
|
||||
- [ ] **Bundle size**: Verify lazy loading works (check network tab)
|
||||
- [ ] **Theme consistency**: Colors match current Prism theme
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Initial Load
|
||||
- Core languages (JS, TS, JSON, diff, bash) preloaded
|
||||
- WASM engine loaded once, cached by browser
|
||||
- Other languages loaded on first use
|
||||
|
||||
### Caching
|
||||
- Shiki caches compiled grammars in memory
|
||||
- WASM binary cached by browser (~200KB, infrequent)
|
||||
- Language grammars cached per session
|
||||
|
||||
### Optimization Tips
|
||||
1. Preload languages for known content types
|
||||
2. Use `loading` state to show code immediately (unhighlighted)
|
||||
3. Consider debouncing for rapidly changing code
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues arise:
|
||||
1. Revert `package.json` to restore prismjs
|
||||
2. Restore `prism-theme.css`
|
||||
3. Restore original `SyntaxHighlight.tsx`
|
||||
4. Revert `index.css` import
|
||||
|
||||
The old Prism setup is self-contained and easy to restore.
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Line highlighting**: Add ability to highlight specific lines
|
||||
2. **Copy line numbers**: Option to include line numbers in copy
|
||||
3. **Language detection**: Auto-detect language from content
|
||||
4. **Custom themes**: Allow user theme selection
|
||||
5. **Repository file viewer**: Extend for git tree visualization
|
||||
@@ -1,812 +0,0 @@
|
||||
# Grimoire Theme Token System
|
||||
|
||||
## Overview
|
||||
|
||||
Comprehensive design token system for Grimoire's visual styling. This system abstracts all visual properties into semantic tokens that can be applied to any theme (dark, light, or custom). Built on industry-standard design token patterns used by Tailwind, Radix, and Material Design.
|
||||
|
||||
**Status**: Design Complete - Ready for Implementation
|
||||
**Approach**: Two-tier token system (Base + Semantic)
|
||||
**Format**: HSL for colors, CSS length units for spacing/sizing
|
||||
**Philosophy**: Theme-agnostic components, theme-specific values
|
||||
|
||||
---
|
||||
|
||||
## Token Categories
|
||||
|
||||
### 1. Colors (Surfaces & Interactive)
|
||||
Semantic color tokens for backgrounds, text, and UI elements.
|
||||
|
||||
**Structure**:
|
||||
```typescript
|
||||
colors: {
|
||||
// Surfaces
|
||||
background: HSL; // Main app background
|
||||
foreground: HSL; // Main text color
|
||||
card: HSL; // Elevated surface
|
||||
"card-foreground": HSL;
|
||||
popover: HSL; // Floating elements
|
||||
"popover-foreground": HSL;
|
||||
|
||||
// Interactive Elements
|
||||
primary: HSL; // Primary actions (buttons, links)
|
||||
"primary-foreground": HSL;
|
||||
secondary: HSL; // Secondary actions
|
||||
"secondary-foreground": HSL;
|
||||
muted: HSL; // Subtle backgrounds
|
||||
"muted-foreground": HSL; // Secondary text
|
||||
accent: HSL; // Highlight/emphasis (purple!)
|
||||
"accent-foreground": HSL;
|
||||
destructive: HSL; // Errors, danger
|
||||
"destructive-foreground": HSL;
|
||||
|
||||
// UI Elements
|
||||
border: HSL; // Dividers, borders
|
||||
input: HSL; // Input backgrounds
|
||||
ring: HSL; // Focus indicators
|
||||
}
|
||||
```
|
||||
|
||||
**Current Dark Theme Values**:
|
||||
```typescript
|
||||
colors: {
|
||||
background: "222.2 84% 4.9%", // Deep blue-gray
|
||||
foreground: "210 40% 98%", // Almost white
|
||||
accent: "270 100% 70%", // Grimoire purple!
|
||||
// ... full definition in grimoire-dark.ts
|
||||
}
|
||||
```
|
||||
|
||||
**Light Theme Values**:
|
||||
```typescript
|
||||
colors: {
|
||||
background: "40 20% 97%", // Warm off-white
|
||||
foreground: "222 84% 8%", // Very dark
|
||||
accent: "270 100% 50%", // Darker purple (contrast)
|
||||
// ... full definition in grimoire-light.ts
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Gradients
|
||||
Multi-stop gradient definitions for brand and decorative purposes.
|
||||
|
||||
**Structure**:
|
||||
```typescript
|
||||
gradients: {
|
||||
brand: [HSL, HSL, HSL, HSL]; // Grimoire 4-color brand gradient
|
||||
}
|
||||
```
|
||||
|
||||
**Current Values**:
|
||||
```typescript
|
||||
// Dark theme
|
||||
gradients: {
|
||||
brand: [
|
||||
"43 100% 54%", // Yellow
|
||||
"25 95% 61%", // Orange
|
||||
"270 91% 65%", // Purple
|
||||
"188 86% 53%", // Cyan
|
||||
],
|
||||
}
|
||||
|
||||
// Light theme (adjusted for contrast)
|
||||
gradients: {
|
||||
brand: [
|
||||
"43 100% 45%", // Darker yellow
|
||||
"25 95% 50%", // Darker orange
|
||||
"270 91% 50%", // Darker purple
|
||||
"188 86% 40%", // Darker cyan
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```css
|
||||
.text-grimoire-gradient {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
hsl(var(--gradient-brand-1)),
|
||||
hsl(var(--gradient-brand-2)),
|
||||
hsl(var(--gradient-brand-3)),
|
||||
hsl(var(--gradient-brand-4))
|
||||
);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Syntax Highlighting
|
||||
Code syntax highlighting colors for Prism.js integration.
|
||||
|
||||
**Structure**:
|
||||
```typescript
|
||||
syntax: {
|
||||
// General syntax tokens
|
||||
comment: HSL;
|
||||
keyword: HSL;
|
||||
string: HSL;
|
||||
function: HSL;
|
||||
number: HSL;
|
||||
operator: HSL;
|
||||
"operator-opacity"?: Opacity;
|
||||
|
||||
// Diff-specific tokens (git diffs in code blocks)
|
||||
deleted: HSL; // Removed lines
|
||||
"deleted-bg": HSL;
|
||||
"deleted-bg-opacity": Opacity;
|
||||
|
||||
inserted: HSL; // Added lines
|
||||
"inserted-bg": HSL;
|
||||
"inserted-bg-opacity": Opacity;
|
||||
|
||||
coord: HSL; // Hunk headers (@@ -1,5 +1,7 @@)
|
||||
"coord-bg": HSL;
|
||||
"coord-bg-opacity": Opacity;
|
||||
}
|
||||
```
|
||||
|
||||
**Current Values**:
|
||||
```typescript
|
||||
// Dark theme
|
||||
syntax: {
|
||||
comment: "215 20.2% 70%", // Medium gray
|
||||
keyword: "210 40% 98%", // Bright
|
||||
deleted: "0 70% 75%", // Light red
|
||||
"deleted-bg": "0 70% 60%",
|
||||
"deleted-bg-opacity": 0.1,
|
||||
// ... etc
|
||||
}
|
||||
|
||||
// Light theme (inverted lightness)
|
||||
syntax: {
|
||||
comment: "222 30% 35%", // Dark gray
|
||||
keyword: "222 47% 11%", // Very dark
|
||||
deleted: "0 70% 40%", // Dark red
|
||||
"deleted-bg": "0 70% 60%",
|
||||
"deleted-bg-opacity": 0.1,
|
||||
// ... etc
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```css
|
||||
.token.deleted {
|
||||
color: hsl(var(--syntax-deleted));
|
||||
background: hsl(var(--syntax-deleted-bg) / var(--syntax-deleted-bg-opacity));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Effects & Transparency
|
||||
Visual effects like shadows, overlays, and opacity values.
|
||||
|
||||
**Structure**:
|
||||
```typescript
|
||||
effects: {
|
||||
"scrollbar-opacity": Opacity; // Scrollbar thumb opacity
|
||||
"scrollbar-hover-opacity": Opacity; // Hover state
|
||||
"overlay-opacity": Opacity; // Modal/fullscreen overlays
|
||||
"preview-opacity": Opacity; // Mosaic drag preview
|
||||
"shadow-color": HSL; // Shadow base color
|
||||
"shadow-blur": CSSLength; // Shadow blur radius
|
||||
}
|
||||
```
|
||||
|
||||
**Current Values** (theme-agnostic):
|
||||
```typescript
|
||||
effects: {
|
||||
"scrollbar-opacity": 0.2,
|
||||
"scrollbar-hover-opacity": 0.3,
|
||||
"overlay-opacity": 0.92,
|
||||
"preview-opacity": 0.3,
|
||||
"shadow-color": "0 0% 0%", // Black for shadows
|
||||
"shadow-blur": "40px",
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```css
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--foreground) / var(--scrollbar-opacity));
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--foreground) / var(--scrollbar-hover-opacity));
|
||||
}
|
||||
|
||||
[data-rmiz-modal-overlay] {
|
||||
background-color: hsl(var(--background) / var(--overlay-opacity)) !important;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Spacing & Sizing
|
||||
Layout dimensions and component sizes.
|
||||
|
||||
**Structure**:
|
||||
```typescript
|
||||
spacing: {
|
||||
"toolbar-height": CSSLength; // Mosaic window toolbar
|
||||
"split-width": CSSLength; // Window split handle
|
||||
"scrollbar-width": CSSLength; // Scrollbar thickness
|
||||
}
|
||||
```
|
||||
|
||||
**Current Values** (theme-agnostic):
|
||||
```typescript
|
||||
spacing: {
|
||||
"toolbar-height": "30px",
|
||||
"split-width": "4px",
|
||||
"scrollbar-width": "8px",
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```css
|
||||
.mosaic-window .mosaic-window-toolbar {
|
||||
height: var(--toolbar-height);
|
||||
}
|
||||
|
||||
.mosaic-split.-row {
|
||||
width: var(--split-width);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Typography
|
||||
Font families, sizes, and line heights.
|
||||
|
||||
**Structure**:
|
||||
```typescript
|
||||
typography: {
|
||||
"font-family-mono": string; // Monospace font stack
|
||||
"font-size-base": CSSLength; // Base text size
|
||||
"font-size-code": CSSLength; // Code block text size
|
||||
"line-height-base": string; // Base line height
|
||||
"line-height-code": string; // Code block line height
|
||||
}
|
||||
```
|
||||
|
||||
**Current Values** (theme-agnostic):
|
||||
```typescript
|
||||
typography: {
|
||||
"font-family-mono": "'Oxygen Mono', monospace",
|
||||
"font-size-base": "1rem",
|
||||
"font-size-code": "0.75rem",
|
||||
"line-height-base": "1.5",
|
||||
"line-height-code": "1.5",
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```css
|
||||
body {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: var(--font-size-code);
|
||||
line-height: var(--line-height-code);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Geometry
|
||||
Border radius and shape properties.
|
||||
|
||||
**Structure**:
|
||||
```typescript
|
||||
geometry: {
|
||||
radius: CSSLength; // Standard border radius
|
||||
}
|
||||
```
|
||||
|
||||
**Current Values** (theme-agnostic):
|
||||
```typescript
|
||||
geometry: {
|
||||
radius: "0.5rem", // 8px at default font size
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```css
|
||||
.button {
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Derived values */
|
||||
.card {
|
||||
border-radius: calc(var(--radius) - 2px); /* Slightly smaller */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. Charts (Optional)
|
||||
Data visualization color palette.
|
||||
|
||||
**Structure**:
|
||||
```typescript
|
||||
charts: {
|
||||
1: HSL;
|
||||
2: HSL;
|
||||
3: HSL;
|
||||
4: HSL;
|
||||
5: HSL;
|
||||
}
|
||||
```
|
||||
|
||||
**Current Values**:
|
||||
```typescript
|
||||
// Dark theme
|
||||
charts: {
|
||||
1: "220 70% 50%", // Blue
|
||||
2: "160 60% 45%", // Green
|
||||
3: "30 80% 55%", // Orange
|
||||
4: "280 65% 60%", // Purple
|
||||
5: "340 75% 55%", // Pink
|
||||
}
|
||||
|
||||
// Light theme (adjusted)
|
||||
charts: {
|
||||
1: "220 70% 45%",
|
||||
2: "160 60% 40%",
|
||||
3: "30 80% 50%",
|
||||
4: "280 65% 50%",
|
||||
5: "340 75% 50%",
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TypeScript Schema
|
||||
|
||||
Complete type definitions for theme system:
|
||||
|
||||
```typescript
|
||||
// src/types/theme.ts
|
||||
|
||||
/**
|
||||
* HSL color format: "hue saturation% lightness%"
|
||||
* Example: "270 100% 70%"
|
||||
* Note: No hsl() wrapper - used as: hsl(var(--token))
|
||||
*/
|
||||
export type HSL = string;
|
||||
|
||||
/**
|
||||
* Opacity value between 0 and 1
|
||||
*/
|
||||
export type Opacity = number;
|
||||
|
||||
/**
|
||||
* CSS length value (px, rem, em, etc.)
|
||||
*/
|
||||
export type CSSLength = string;
|
||||
|
||||
/**
|
||||
* Complete Grimoire theme definition
|
||||
*/
|
||||
export interface GrimoireTheme {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
|
||||
/** Display name */
|
||||
name: string;
|
||||
|
||||
/** Base type for contrast calculations */
|
||||
type: "light" | "dark";
|
||||
|
||||
/** Optional metadata */
|
||||
author?: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
|
||||
/** Color tokens */
|
||||
colors: {
|
||||
background: HSL;
|
||||
foreground: HSL;
|
||||
card: HSL;
|
||||
"card-foreground": HSL;
|
||||
popover: HSL;
|
||||
"popover-foreground": HSL;
|
||||
primary: HSL;
|
||||
"primary-foreground": HSL;
|
||||
secondary: HSL;
|
||||
"secondary-foreground": HSL;
|
||||
muted: HSL;
|
||||
"muted-foreground": HSL;
|
||||
accent: HSL;
|
||||
"accent-foreground": HSL;
|
||||
destructive: HSL;
|
||||
"destructive-foreground": HSL;
|
||||
border: HSL;
|
||||
input: HSL;
|
||||
ring: HSL;
|
||||
};
|
||||
|
||||
/** Gradient definitions */
|
||||
gradients: {
|
||||
brand: [HSL, HSL, HSL, HSL];
|
||||
};
|
||||
|
||||
/** Syntax highlighting */
|
||||
syntax: {
|
||||
comment: HSL;
|
||||
keyword: HSL;
|
||||
string: HSL;
|
||||
function: HSL;
|
||||
number: HSL;
|
||||
operator: HSL;
|
||||
"operator-opacity"?: Opacity;
|
||||
deleted: HSL;
|
||||
"deleted-bg": HSL;
|
||||
"deleted-bg-opacity": Opacity;
|
||||
inserted: HSL;
|
||||
"inserted-bg": HSL;
|
||||
"inserted-bg-opacity": Opacity;
|
||||
coord: HSL;
|
||||
"coord-bg": HSL;
|
||||
"coord-bg-opacity": Opacity;
|
||||
};
|
||||
|
||||
/** Visual effects */
|
||||
effects: {
|
||||
"scrollbar-opacity": Opacity;
|
||||
"scrollbar-hover-opacity": Opacity;
|
||||
"overlay-opacity": Opacity;
|
||||
"preview-opacity": Opacity;
|
||||
"shadow-color": HSL;
|
||||
"shadow-blur": CSSLength;
|
||||
};
|
||||
|
||||
/** Spacing & sizing */
|
||||
spacing: {
|
||||
"toolbar-height": CSSLength;
|
||||
"split-width": CSSLength;
|
||||
"scrollbar-width": CSSLength;
|
||||
};
|
||||
|
||||
/** Typography */
|
||||
typography: {
|
||||
"font-family-mono": string;
|
||||
"font-size-base": CSSLength;
|
||||
"font-size-code": CSSLength;
|
||||
"line-height-base": string;
|
||||
"line-height-code": string;
|
||||
};
|
||||
|
||||
/** Geometry */
|
||||
geometry: {
|
||||
radius: CSSLength;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Chart colors (optional, separate for flexibility)
|
||||
*/
|
||||
export interface ChartColors {
|
||||
1: HSL;
|
||||
2: HSL;
|
||||
3: HSL;
|
||||
4: HSL;
|
||||
5: HSL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete theme with chart colors
|
||||
*/
|
||||
export interface GrimoireThemeComplete extends GrimoireTheme {
|
||||
charts: ChartColors;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Guidelines
|
||||
|
||||
### HSL Format Requirements
|
||||
|
||||
**Correct Format**:
|
||||
```typescript
|
||||
"270 100% 70%" // Basic color
|
||||
"270 100% 70% / 0.5" // With inline opacity
|
||||
```
|
||||
|
||||
**❌ Wrong**:
|
||||
```typescript
|
||||
"hsl(270 100% 70%)" // Don't wrap in hsl()
|
||||
"270, 100%, 70%" // Don't use commas
|
||||
"#B388FF" // Don't use hex
|
||||
"rgb(179, 136, 255)" // Don't use RGB
|
||||
```
|
||||
|
||||
**Rationale**: CSS variables need unwrapped values for flexible composition:
|
||||
```css
|
||||
/* ✅ This works */
|
||||
color: hsl(var(--accent));
|
||||
background: hsl(var(--accent) / 0.1);
|
||||
|
||||
/* ❌ This doesn't */
|
||||
color: var(--accent); /* Missing hsl() */
|
||||
```
|
||||
|
||||
### Token Naming Conventions
|
||||
|
||||
1. **Semantic over Visual**:
|
||||
- ✅ `accent` (semantic: highlight/emphasis)
|
||||
- ❌ `purple` (visual: describes appearance)
|
||||
|
||||
2. **Purpose over Position**:
|
||||
- ✅ `card-foreground` (purpose: text on cards)
|
||||
- ❌ `text-2` (position: arbitrary numbering)
|
||||
|
||||
3. **Component-Specific when Needed**:
|
||||
- ✅ `toolbar-height` (specific to toolbars)
|
||||
- ❌ `height-1` (generic, unclear usage)
|
||||
|
||||
### CSS Variable Usage
|
||||
|
||||
**Colors**:
|
||||
```css
|
||||
/* ✅ Correct */
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
|
||||
/* With opacity */
|
||||
background: hsl(var(--accent) / 0.1);
|
||||
color: hsl(var(--foreground) / var(--scrollbar-opacity));
|
||||
|
||||
/* ❌ Wrong */
|
||||
color: var(--foreground); /* Missing hsl() */
|
||||
background: hsl(var(--background), 0.9); /* Wrong opacity syntax */
|
||||
```
|
||||
|
||||
**Gradients**:
|
||||
```css
|
||||
/* ✅ Correct */
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
hsl(var(--gradient-brand-1)),
|
||||
hsl(var(--gradient-brand-2)),
|
||||
hsl(var(--gradient-brand-3)),
|
||||
hsl(var(--gradient-brand-4))
|
||||
);
|
||||
|
||||
/* ❌ Wrong */
|
||||
background: var(--gradient-brand); /* No single gradient variable exists */
|
||||
```
|
||||
|
||||
**Spacing & Sizing**:
|
||||
```css
|
||||
/* ✅ Correct - direct usage */
|
||||
height: var(--toolbar-height);
|
||||
width: var(--split-width);
|
||||
|
||||
/* ✅ Correct - with calc */
|
||||
padding: calc(var(--toolbar-height) / 2);
|
||||
```
|
||||
|
||||
### Contrast Requirements
|
||||
|
||||
**WCAG AA Standards** (minimum):
|
||||
- **Normal text** (<18pt): 4.5:1 contrast ratio
|
||||
- **Large text** (≥18pt or bold ≥14pt): 3:1 contrast ratio
|
||||
- **UI components**: 3:1 contrast ratio
|
||||
|
||||
**Testing**:
|
||||
```typescript
|
||||
import { calculateContrast } from "@/lib/theme-utils";
|
||||
|
||||
// Check contrast
|
||||
const ratio = calculateContrast(
|
||||
theme.colors.foreground,
|
||||
theme.colors.background
|
||||
);
|
||||
|
||||
if (ratio < 4.5) {
|
||||
console.warn("Insufficient contrast for normal text");
|
||||
}
|
||||
```
|
||||
|
||||
**Common Fixes**:
|
||||
- Light themes: Darken accent colors (50% instead of 70% lightness)
|
||||
- Dark themes: Lighten accent colors (70% instead of 50% lightness)
|
||||
- Always test with actual text, not just theory
|
||||
|
||||
---
|
||||
|
||||
## Theme Creation Workflow
|
||||
|
||||
### 1. Start from Existing Theme
|
||||
|
||||
```typescript
|
||||
import { GRIMOIRE_DARK } from "@/lib/themes/grimoire-dark";
|
||||
|
||||
const MY_THEME: GrimoireThemeComplete = {
|
||||
...GRIMOIRE_DARK, // Start with dark theme
|
||||
id: "my-custom-theme",
|
||||
name: "My Custom Theme",
|
||||
// Override specific tokens...
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Modify Colors
|
||||
|
||||
Focus on semantic tokens first:
|
||||
```typescript
|
||||
colors: {
|
||||
...GRIMOIRE_DARK.colors,
|
||||
background: "210 30% 10%", // Slightly different blue
|
||||
accent: "150 80% 60%", // Green accent instead of purple
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Adjust Syntax Highlighting
|
||||
|
||||
Match your color scheme:
|
||||
```typescript
|
||||
syntax: {
|
||||
...GRIMOIRE_DARK.syntax,
|
||||
keyword: "150 80% 60%", // Green keywords to match accent
|
||||
deleted: "0 80% 70%", // Brighter red
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Update Gradients
|
||||
|
||||
Keep brand consistency or create new:
|
||||
```typescript
|
||||
gradients: {
|
||||
brand: [
|
||||
"150 80% 60%", // Green
|
||||
"180 70% 50%", // Cyan
|
||||
"210 60% 50%", // Blue
|
||||
"240 70% 60%", // Purple
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Validate Theme
|
||||
|
||||
```typescript
|
||||
import { validateTheme } from "@/lib/theme-utils";
|
||||
|
||||
const validation = validateTheme(MY_THEME);
|
||||
|
||||
if (!validation.valid) {
|
||||
console.error("Theme validation failed:", validation.errors);
|
||||
}
|
||||
|
||||
if (validation.warnings.length > 0) {
|
||||
console.warn("Theme warnings:", validation.warnings);
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Test Visually
|
||||
|
||||
- Load theme in browser
|
||||
- Check all UI components
|
||||
- Test with real content
|
||||
- Verify contrast with browser DevTools
|
||||
- Test in different screen sizes
|
||||
|
||||
### 7. Export & Share
|
||||
|
||||
```typescript
|
||||
import { exportTheme } from "@/lib/theme-utils";
|
||||
|
||||
const json = exportTheme(MY_THEME);
|
||||
// Save to file or share with others
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── types/
|
||||
│ └── theme.ts TypeScript interfaces
|
||||
│
|
||||
├── lib/
|
||||
│ ├── themes/
|
||||
│ │ ├── index.ts Theme registry & utilities
|
||||
│ │ ├── grimoire-dark.ts Dark theme definition (const)
|
||||
│ │ └── grimoire-light.ts Light theme definition (const)
|
||||
│ │
|
||||
│ └── theme-utils.ts Utilities (validate, convert, etc.)
|
||||
│
|
||||
├── index.css CSS variables (all tokens)
|
||||
└── styles/
|
||||
└── prism-theme.css Syntax highlighting styles
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Type System ✅
|
||||
- [x] Create `src/types/theme.ts`
|
||||
- [x] Define all interfaces
|
||||
- [x] Document token types
|
||||
|
||||
### Phase 2: Theme Definitions
|
||||
- [ ] Create `src/lib/themes/grimoire-dark.ts`
|
||||
- [ ] Create `src/lib/themes/grimoire-light.ts`
|
||||
- [ ] Create `src/lib/themes/index.ts` (registry)
|
||||
- [ ] Add complete token values for both themes
|
||||
|
||||
### Phase 3: CSS Migration
|
||||
- [ ] Add all tokens to `:root` in `src/index.css`
|
||||
- [ ] Add all tokens to `.dark` in `src/index.css`
|
||||
- [ ] Replace hardcoded colors with token references
|
||||
- [ ] Update `src/styles/prism-theme.css` to use syntax tokens
|
||||
- [ ] Update gradient usage in components
|
||||
|
||||
### Phase 4: Utilities
|
||||
- [ ] Implement `themeToCSSVariables()` in `src/lib/theme-utils.ts`
|
||||
- [ ] Implement `applyTheme()` for dynamic theme switching
|
||||
- [ ] Implement `validateTheme()` with contrast checking
|
||||
- [ ] Implement `exportTheme()` / `importTheme()` for sharing
|
||||
- [ ] Implement `calculateContrast()` (WCAG compliance)
|
||||
|
||||
### Phase 5: Testing
|
||||
- [ ] Unit tests for validation functions
|
||||
- [ ] Unit tests for conversion utilities
|
||||
- [ ] Visual testing in both themes
|
||||
- [ ] Contrast testing with automated tools
|
||||
- [ ] Cross-browser compatibility testing
|
||||
|
||||
---
|
||||
|
||||
## Benefits of This System
|
||||
|
||||
1. **Complete Abstraction**: All visual properties tokenized, no hardcoded values
|
||||
2. **Type Safety**: TypeScript enforces token structure and catches errors
|
||||
3. **Validation**: Automated checks for completeness and contrast
|
||||
4. **Extensibility**: Easy to add new themes (just define tokens)
|
||||
5. **Portability**: Import/export themes as JSON
|
||||
6. **Maintainability**: Single source of truth for visual properties
|
||||
7. **Consistency**: Enforced visual coherence across all components
|
||||
8. **Accessibility**: Built-in contrast validation (WCAG compliance)
|
||||
9. **Future-Proof**: Architecture supports user-defined themes
|
||||
10. **Developer Experience**: IntelliSense, autocomplete, documentation built-in
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Review this design** - Confirm token structure meets requirements
|
||||
2. **Approve naming conventions** - Ensure token names are clear and semantic
|
||||
3. **Begin Phase 2** - Create TypeScript theme definition files
|
||||
4. **Validate with real data** - Test token system with actual component usage
|
||||
5. **Iterate** - Refine token structure based on implementation feedback
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- **WCAG 2.1 Guidelines**: https://www.w3.org/WAI/WCAG21/Understanding/
|
||||
- **Design Tokens W3C Community**: https://design-tokens.github.io/community-group/
|
||||
- **HSL Color Model**: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/hsl
|
||||
- **CSS Custom Properties**: https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties
|
||||
- **Tailwind Color System**: https://tailwindcss.com/docs/customizing-colors
|
||||
- **Radix Themes**: https://www.radix-ui.com/themes/docs/theme/overview
|
||||
|
||||
---
|
||||
|
||||
**Document Version**: 1.0
|
||||
**Created**: 2025-12-15
|
||||
**Author**: Claude (ultrathink analysis)
|
||||
**Status**: Design Complete - Ready for Implementation
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,148 +0,0 @@
|
||||
# Kind 1337 Code Snippet Renderer (NIP-C0)
|
||||
|
||||
## Overview
|
||||
Added complete support for kind 1337 (Code Snippet) events from NIP-C0, with both feed and detail renderers.
|
||||
|
||||
## Files Created
|
||||
|
||||
### 1. Helper Functions (`src/lib/nip-c0-helpers.ts`)
|
||||
Tag extraction utilities using applesauce-core:
|
||||
- `getCodeLanguage()` - Programming language (l tag)
|
||||
- `getCodeName()` - Filename (name tag)
|
||||
- `getCodeExtension()` - File extension without dot (extension tag)
|
||||
- `getCodeDescription()` - Description text (description tag)
|
||||
- `getCodeRuntime()` - Runtime specification (runtime tag)
|
||||
- `getCodeLicenses()` - Array of license identifiers (license tags)
|
||||
- `getCodeDependencies()` - Array of dependencies (dep tags)
|
||||
- `getCodeRepo()` - Repository reference with type detection (URL or NIP-34 address)
|
||||
|
||||
### 2. Feed Renderer (`src/components/nostr/kinds/Kind1337Renderer.tsx`)
|
||||
Compact view showing:
|
||||
- **Clickable title** - Opens detail view, uses filename or "Code Snippet"
|
||||
- **Language badge** - Shows programming language in styled chip
|
||||
- **Description** - Truncated to 2 lines if present
|
||||
- **Code preview** - First 5 lines with line-clamp and "..." indicator
|
||||
- Wrapped in `BaseEventContainer` for consistency
|
||||
|
||||
### 3. Detail Renderer (`src/components/nostr/kinds/Kind1337DetailRenderer.tsx`)
|
||||
Full view with:
|
||||
- **Header** - Title with FileCode icon
|
||||
- **Metadata section** (before code):
|
||||
- Language and Extension
|
||||
- Description
|
||||
- Runtime (if present)
|
||||
- Licenses (if present)
|
||||
- Dependencies list (if present)
|
||||
- Repository link:
|
||||
- NIP-34 address → clickable, opens repository event
|
||||
- URL → external link with icon
|
||||
- **Code section**:
|
||||
- Full code in `<pre>` with `font-mono` styling
|
||||
- Copy button inline (absolute positioned top-right)
|
||||
- Matches JsonViewer pattern
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Kinds Registry
|
||||
Already existed in `src/constants/kinds.ts`:
|
||||
```typescript
|
||||
1337: {
|
||||
kind: 1337,
|
||||
name: "Code",
|
||||
description: "Code Snippet",
|
||||
nip: "C0",
|
||||
icon: FileCode,
|
||||
}
|
||||
```
|
||||
|
||||
### Event Title System
|
||||
Added to `src/lib/event-title.ts`:
|
||||
```typescript
|
||||
case 1337: // Code snippet
|
||||
title = getCodeName(event);
|
||||
break;
|
||||
```
|
||||
Window titles show filename or fall back to "Code Snippet"
|
||||
|
||||
### Renderer Registry
|
||||
Added to `src/components/nostr/kinds/index.tsx`:
|
||||
```typescript
|
||||
1337: Kind1337Renderer, // Code Snippet (NIP-C0)
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Feed View
|
||||
- Clean, compact display
|
||||
- Language identification at a glance
|
||||
- Quick code preview without opening
|
||||
- Clickable to open full view
|
||||
|
||||
### Detail View
|
||||
- Complete metadata display
|
||||
- NIP-34 repository integration (handles both URLs and Nostr addresses)
|
||||
- One-click copy functionality
|
||||
- Clean, readable code display
|
||||
- Professional layout with metadata organized before code
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### No Syntax Highlighting (MVP)
|
||||
- Uses plain `<pre>` with `font-mono` styling (matching JsonViewer)
|
||||
- No new dependencies added
|
||||
- Can add syntax highlighting later (react-syntax-highlighter) as enhancement
|
||||
|
||||
### No Download Button
|
||||
- Simplified to just Copy functionality
|
||||
- Users can copy and save manually
|
||||
- Reduces UI complexity
|
||||
|
||||
### Metadata Before Code
|
||||
- More scannable - users see what the code is before reading it
|
||||
- Follows natural information hierarchy
|
||||
- Easier to understand context
|
||||
|
||||
### Copy Button Position
|
||||
- Inline in code section (top-right absolute)
|
||||
- Matches existing JsonViewer pattern
|
||||
- Consistent UX across app
|
||||
|
||||
## NIP-C0 Compliance
|
||||
|
||||
Supports all NIP-C0 tags:
|
||||
- ✅ `l` - Programming language
|
||||
- ✅ `name` - Filename
|
||||
- ✅ `extension` - File extension
|
||||
- ✅ `description` - Description
|
||||
- ✅ `runtime` - Runtime specification
|
||||
- ✅ `license` - License(s) (supports multiple)
|
||||
- ✅ `dep` - Dependencies (supports multiple)
|
||||
- ✅ `repo` - Repository reference (URL or NIP-34)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements for Phase 2:
|
||||
1. **Syntax Highlighting** - Add react-syntax-highlighter for color-coded display
|
||||
2. **Line Numbers** - Optional line numbers for code blocks
|
||||
3. **Code Formatting** - Auto-format/prettify code
|
||||
4. **Run Functionality** - Execute supported languages (complex, low priority)
|
||||
5. **Download Button** - Add back if users request it
|
||||
6. **Diff View** - For patches or code changes
|
||||
|
||||
## Testing
|
||||
|
||||
- ✅ Type check passes
|
||||
- ✅ Integrates with existing event title system
|
||||
- ✅ Follows established component patterns
|
||||
- ✅ Uses applesauce-core helpers consistently
|
||||
- ✅ NIP-34 repository links handled correctly
|
||||
|
||||
## Usage
|
||||
|
||||
Users can now:
|
||||
1. View code snippets in feeds with preview
|
||||
2. Click to open full detail view
|
||||
3. See all metadata (language, runtime, deps, etc.)
|
||||
4. Copy code with one click
|
||||
5. Navigate to referenced repositories (NIP-34 or URLs)
|
||||
6. See proper window titles with filenames
|
||||
@@ -1,690 +0,0 @@
|
||||
# NIP-65 Outbox Implementation: Future Improvements
|
||||
|
||||
This document outlines performance and UX improvements identified during the deep review of the outbox implementation. The "quick wins" (single author special case, in-memory LRU cache, relay selection progress indicator) have been implemented. These are the remaining optimizations for future consideration.
|
||||
|
||||
---
|
||||
|
||||
## 1. Request Deduplication
|
||||
|
||||
**Problem**: Multiple simultaneous queries for the same relay list create redundant network requests.
|
||||
|
||||
**Current Behavior**:
|
||||
```typescript
|
||||
// If 3 components request same relay list simultaneously:
|
||||
async function fetchRelayList(pubkey: string) {
|
||||
return await fetch(`wss://relay/kind:10002/${pubkey}`);
|
||||
}
|
||||
|
||||
// Result: 3 identical network requests
|
||||
```
|
||||
|
||||
**Proposed Solution**:
|
||||
```typescript
|
||||
// Map of in-flight promises to prevent redundant fetches
|
||||
private inFlightRequests = new Map<string, Promise<NostrEvent | null>>();
|
||||
|
||||
async fetchRelayList(pubkey: string): Promise<NostrEvent | null> {
|
||||
// Check if request already in flight
|
||||
const existing = this.inFlightRequests.get(pubkey);
|
||||
if (existing) {
|
||||
console.debug(`[RelayListCache] Deduplicating request for ${pubkey.slice(0, 8)}`);
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Create new promise and store it
|
||||
const promise = this.fetchFromNetwork(pubkey);
|
||||
this.inFlightRequests.set(pubkey, promise);
|
||||
|
||||
// Clean up when done
|
||||
promise.finally(() => {
|
||||
this.inFlightRequests.delete(pubkey);
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Impact**:
|
||||
- Reduce redundant network requests by ~60-80%
|
||||
- Lower bandwidth usage and relay load
|
||||
- Faster response times when multiple components need same data
|
||||
|
||||
**Implementation Location**: `src/services/relay-list-cache.ts`
|
||||
|
||||
---
|
||||
|
||||
## 2. Performance Metrics Collection
|
||||
|
||||
**Problem**: No telemetry to track cache hit rates, timing, or degradation patterns in production.
|
||||
|
||||
**Proposed Solution**:
|
||||
```typescript
|
||||
// In src/services/relay-list-cache.ts
|
||||
interface PerformanceMetrics {
|
||||
memoryHits: number;
|
||||
dexieHits: number;
|
||||
networkFetches: number;
|
||||
totalRequests: number;
|
||||
avgMemoryTime: number;
|
||||
avgDexieTime: number;
|
||||
avgNetworkTime: number;
|
||||
lastReset: number;
|
||||
}
|
||||
|
||||
class RelayListCache {
|
||||
private metrics: PerformanceMetrics = {
|
||||
memoryHits: 0,
|
||||
dexieHits: 0,
|
||||
networkFetches: 0,
|
||||
totalRequests: 0,
|
||||
avgMemoryTime: 0,
|
||||
avgDexieTime: 0,
|
||||
avgNetworkTime: 0,
|
||||
lastReset: Date.now(),
|
||||
};
|
||||
|
||||
async getOutboxRelays(pubkey: string): Promise<string[] | null> {
|
||||
const start = performance.now();
|
||||
this.metrics.totalRequests++;
|
||||
|
||||
// Check memory cache
|
||||
const memCached = this.memoryCache.get(pubkey);
|
||||
if (memCached && Date.now() - memCached.updatedAt < CACHE_TTL) {
|
||||
this.metrics.memoryHits++;
|
||||
this.updateAvgTime('memory', performance.now() - start);
|
||||
return memCached.write;
|
||||
}
|
||||
|
||||
// Check Dexie
|
||||
const cached = await this.get(pubkey);
|
||||
if (cached) {
|
||||
this.metrics.dexieHits++;
|
||||
this.updateAvgTime('dexie', performance.now() - start);
|
||||
return cached.write;
|
||||
}
|
||||
|
||||
// Network fetch
|
||||
this.metrics.networkFetches++;
|
||||
this.updateAvgTime('network', performance.now() - start);
|
||||
return null;
|
||||
}
|
||||
|
||||
getMetrics(): PerformanceMetrics & {
|
||||
memoryCacheHitRate: number;
|
||||
dexieCacheHitRate: number;
|
||||
overallCacheHitRate: number;
|
||||
} {
|
||||
const total = this.metrics.totalRequests;
|
||||
return {
|
||||
...this.metrics,
|
||||
memoryCacheHitRate: total > 0 ? this.metrics.memoryHits / total : 0,
|
||||
dexieCacheHitRate: total > 0 ? this.metrics.dexieHits / total : 0,
|
||||
overallCacheHitRate: total > 0
|
||||
? (this.metrics.memoryHits + this.metrics.dexieHits) / total
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Impact**:
|
||||
- Visibility into cache effectiveness
|
||||
- Data-driven optimization decisions
|
||||
- Production performance monitoring
|
||||
- Identify degradation patterns early
|
||||
|
||||
**Implementation Location**: `src/services/relay-list-cache.ts`
|
||||
|
||||
---
|
||||
|
||||
## 3. Fallback Warning System
|
||||
|
||||
**Problem**: Users don't know when their queries fall back to aggregator relays, causing confusion about incomplete results.
|
||||
|
||||
**Current Behavior**: Silent fallback with only console.debug logs
|
||||
|
||||
**Proposed Solution**:
|
||||
```typescript
|
||||
// In src/services/relay-selection.ts
|
||||
interface RelaySelectionResult {
|
||||
relays: string[];
|
||||
reasoning: RelaySelectionReasoning[];
|
||||
isOptimized: boolean;
|
||||
fallbacksUsed?: {
|
||||
pubkey: string;
|
||||
reason: 'no-relay-list' | 'timeout' | 'invalid-list';
|
||||
}[];
|
||||
}
|
||||
|
||||
// In selectRelaysForFilter:
|
||||
if (!cachedRelayList) {
|
||||
console.warn(`[RelaySelection] No relay list for ${pubkey.slice(0, 8)}, using fallback`);
|
||||
|
||||
result.fallbacksUsed = result.fallbacksUsed || [];
|
||||
result.fallbacksUsed.push({
|
||||
pubkey,
|
||||
reason: 'no-relay-list'
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**UI Component** (`src/components/ReqViewer.tsx`):
|
||||
```tsx
|
||||
{reasoning && reasoning.some(r => r.isFallback) && (
|
||||
<div className="flex items-center gap-2 text-yellow-600 text-sm mt-2">
|
||||
<AlertTriangle className="size-4" />
|
||||
<span>
|
||||
Using fallback relays for {reasoning.filter(r => r.isFallback).length} users
|
||||
(relay lists unavailable)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**Expected Impact**:
|
||||
- Users understand why results may be incomplete
|
||||
- Encourages fixing relay list issues
|
||||
- Better debugging experience
|
||||
- Transparency about query execution
|
||||
|
||||
**Implementation Locations**:
|
||||
- `src/services/relay-selection.ts`
|
||||
- `src/components/ReqViewer.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 4. Speculative Prefetching
|
||||
|
||||
**Problem**: Cold start delays occur frequently because relay lists aren't cached until needed.
|
||||
|
||||
**Proposed Solution**:
|
||||
```typescript
|
||||
// In src/services/relay-list-cache.ts
|
||||
class RelayListCache {
|
||||
/**
|
||||
* Prefetch relay lists for a set of pubkeys in the background
|
||||
* Useful for warming cache with user's follows
|
||||
*/
|
||||
async prefetch(pubkeys: string[]): Promise<void> {
|
||||
console.log(`[RelayListCache] Prefetching ${pubkeys.length} relay lists`);
|
||||
|
||||
// Filter out already cached
|
||||
const uncached = await Promise.all(
|
||||
pubkeys.map(async (pubkey) => {
|
||||
const has = await this.has(pubkey);
|
||||
return has ? null : pubkey;
|
||||
})
|
||||
);
|
||||
|
||||
const toPrefetch = uncached.filter((p): p is string => p !== null);
|
||||
|
||||
if (toPrefetch.length === 0) {
|
||||
console.debug('[RelayListCache] All relay lists already cached');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch in background (don't await - fire and forget)
|
||||
const eventStore = getEventStore();
|
||||
eventStore.query({ kinds: [10002], authors: toPrefetch });
|
||||
}
|
||||
}
|
||||
|
||||
// Hook for automatic prefetching
|
||||
// In src/hooks/usePrefetchRelayLists.ts
|
||||
export function usePrefetchRelayLists() {
|
||||
const profile = useCurrentProfile();
|
||||
|
||||
useEffect(() => {
|
||||
if (!profile) return;
|
||||
|
||||
// Get user's follows from contact list (kind 3)
|
||||
const contacts = profile.tags
|
||||
.filter(tag => tag[0] === 'p')
|
||||
.map(tag => tag[1]);
|
||||
|
||||
if (contacts.length > 0) {
|
||||
console.log(`[Prefetch] Warming cache with ${contacts.length} follows`);
|
||||
relayListCache.prefetch(contacts.slice(0, 50)); // Limit to top 50
|
||||
}
|
||||
}, [profile]);
|
||||
}
|
||||
```
|
||||
|
||||
**Integration**: Call `usePrefetchRelayLists()` in App.tsx or after login
|
||||
|
||||
**Expected Impact**:
|
||||
- Reduce cold start delays by ~80% for common queries
|
||||
- Better UX for new users
|
||||
- Proactive cache warming
|
||||
- Minimal bandwidth cost (background fetch)
|
||||
|
||||
**Implementation Locations**:
|
||||
- `src/services/relay-list-cache.ts`
|
||||
- `src/hooks/usePrefetchRelayLists.ts`
|
||||
|
||||
---
|
||||
|
||||
## 5. Adaptive Timeout
|
||||
|
||||
**Problem**: Fixed 1000ms timeout is too long for consistently slow relays but may be too short for slow networks.
|
||||
|
||||
**Proposed Solution**:
|
||||
```typescript
|
||||
// In src/services/relay-selection.ts
|
||||
interface RelayHealthMetrics {
|
||||
avgResponseTime: number;
|
||||
successRate: number;
|
||||
lastSuccess: number;
|
||||
failureCount: number;
|
||||
}
|
||||
|
||||
class RelayHealthTracker {
|
||||
private metrics = new Map<string, RelayHealthMetrics>();
|
||||
|
||||
recordSuccess(pubkey: string, responseTime: number) {
|
||||
const existing = this.metrics.get(pubkey) || {
|
||||
avgResponseTime: 0,
|
||||
successRate: 1,
|
||||
lastSuccess: Date.now(),
|
||||
failureCount: 0,
|
||||
};
|
||||
|
||||
// Exponential moving average
|
||||
existing.avgResponseTime =
|
||||
0.7 * existing.avgResponseTime + 0.3 * responseTime;
|
||||
existing.successRate =
|
||||
0.9 * existing.successRate + 0.1 * 1;
|
||||
existing.lastSuccess = Date.now();
|
||||
|
||||
this.metrics.set(pubkey, existing);
|
||||
}
|
||||
|
||||
recordFailure(pubkey: string) {
|
||||
const existing = this.metrics.get(pubkey) || {
|
||||
avgResponseTime: 1000,
|
||||
successRate: 0,
|
||||
lastSuccess: 0,
|
||||
failureCount: 0,
|
||||
};
|
||||
|
||||
existing.successRate = 0.9 * existing.successRate + 0.1 * 0;
|
||||
existing.failureCount++;
|
||||
|
||||
this.metrics.set(pubkey, existing);
|
||||
}
|
||||
|
||||
getTimeout(pubkey: string): number {
|
||||
const metrics = this.metrics.get(pubkey);
|
||||
if (!metrics) return 1000; // Default
|
||||
|
||||
// Adaptive: 2x average response time, minimum 300ms, maximum 2000ms
|
||||
const adaptive = Math.max(300, Math.min(2000, metrics.avgResponseTime * 2));
|
||||
|
||||
// Reduce timeout for consistently slow relays
|
||||
if (metrics.avgResponseTime > 800 && metrics.successRate < 0.5) {
|
||||
return Math.min(500, adaptive);
|
||||
}
|
||||
|
||||
return adaptive;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Impact**:
|
||||
- Faster queries for reliable relays (300-500ms vs 1000ms)
|
||||
- Reduce wasted time on slow relays
|
||||
- Better resource utilization
|
||||
- Adaptive to network conditions
|
||||
|
||||
**Implementation Location**: `src/services/relay-selection.ts`
|
||||
|
||||
---
|
||||
|
||||
## 6. Incremental Relay Selection
|
||||
|
||||
**Problem**: Users wait for all relay lists before seeing any results, even if some are cached.
|
||||
|
||||
**Proposed Solution**:
|
||||
```typescript
|
||||
// In src/services/relay-selection.ts
|
||||
export async function selectRelaysIncremental(
|
||||
eventStore: IEventStore,
|
||||
filter: NostrFilter,
|
||||
options?: RelaySelectionOptions,
|
||||
onUpdate?: (partial: RelaySelectionResult) => void
|
||||
): Promise<RelaySelectionResult> {
|
||||
const authors = filter.authors || [];
|
||||
const pTags = filter["#p"] || [];
|
||||
|
||||
// Phase 1: Return cached relays immediately
|
||||
const cachedPointers = await Promise.all(
|
||||
authors.map(async (pubkey) => {
|
||||
const cached = await relayListCache.getOutboxRelays(pubkey);
|
||||
return cached ? { pubkey, relays: cached } : null;
|
||||
})
|
||||
);
|
||||
|
||||
const initialRelays = cachedPointers
|
||||
.filter((p): p is NonNullable<typeof p> => p !== null)
|
||||
.flatMap(p => p.relays);
|
||||
|
||||
if (initialRelays.length > 0 && onUpdate) {
|
||||
onUpdate({
|
||||
relays: initialRelays,
|
||||
reasoning: [],
|
||||
isOptimized: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 2: Fetch missing relay lists
|
||||
const uncachedAuthors = authors.filter((_, i) => !cachedPointers[i]);
|
||||
|
||||
if (uncachedAuthors.length > 0) {
|
||||
// Fetch and update as they arrive
|
||||
const subscription = eventStore
|
||||
.query({ kinds: [10002], authors: uncachedAuthors })
|
||||
.subscribe((event) => {
|
||||
relayListCache.set(event);
|
||||
|
||||
// Trigger incremental update
|
||||
if (onUpdate) {
|
||||
selectRelaysForFilter(eventStore, filter, options)
|
||||
.then(onUpdate);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for timeout, then complete
|
||||
await new Promise(resolve =>
|
||||
setTimeout(resolve, options?.timeout || 1000)
|
||||
);
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
|
||||
// Phase 3: Final selection
|
||||
return selectRelaysForFilter(eventStore, filter, options);
|
||||
}
|
||||
```
|
||||
|
||||
**Hook Integration**:
|
||||
```typescript
|
||||
// In src/hooks/useOutboxRelays.ts
|
||||
export function useOutboxRelaysIncremental(
|
||||
filter: NostrFilter,
|
||||
options?: RelaySelectionOptions
|
||||
) {
|
||||
const [result, setResult] = useState<RelaySelectionResult>({
|
||||
relays: options?.fallbackRelays || [],
|
||||
reasoning: [],
|
||||
isOptimized: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
selectRelaysIncremental(
|
||||
eventStore,
|
||||
filter,
|
||||
options,
|
||||
setResult // Update as relay lists arrive
|
||||
);
|
||||
}, [filter, options]);
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Impact**:
|
||||
- Show initial results within 10-50ms (cached relays)
|
||||
- Progressive enhancement as more relay lists arrive
|
||||
- Better perceived performance
|
||||
- Users can start seeing events immediately
|
||||
|
||||
**Implementation Locations**:
|
||||
- `src/services/relay-selection.ts`
|
||||
- `src/hooks/useOutboxRelays.ts`
|
||||
|
||||
---
|
||||
|
||||
## 7. Cache Warming UI
|
||||
|
||||
**Problem**: Users have no way to manually refresh stale relay lists or warm the cache proactively.
|
||||
|
||||
**Proposed Solution**:
|
||||
```tsx
|
||||
// In src/components/settings/RelayListSettings.tsx
|
||||
export function RelayListSettings() {
|
||||
const [stats, setStats] = useState<CacheStats | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
relayListCache.getStats().then(setStats);
|
||||
}, []);
|
||||
|
||||
const handleRefreshAll = async () => {
|
||||
setRefreshing(true);
|
||||
|
||||
// Clear cache
|
||||
await relayListCache.clear();
|
||||
|
||||
// Prefetch follows
|
||||
const profile = await getCurrentProfile();
|
||||
if (profile) {
|
||||
const follows = getFollows(profile);
|
||||
await relayListCache.prefetch(follows.slice(0, 100));
|
||||
}
|
||||
|
||||
setRefreshing(false);
|
||||
|
||||
// Update stats
|
||||
const newStats = await relayListCache.getStats();
|
||||
setStats(newStats);
|
||||
};
|
||||
|
||||
const handleRefreshStale = async () => {
|
||||
// Only refresh entries older than 12 hours
|
||||
const allEntries = await db.relayLists.toArray();
|
||||
const stale = allEntries
|
||||
.filter(entry => Date.now() - entry.updatedAt > 12 * 60 * 60 * 1000)
|
||||
.map(entry => entry.pubkey);
|
||||
|
||||
if (stale.length > 0) {
|
||||
await relayListCache.prefetch(stale);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Relay List Cache</h3>
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-muted-foreground">Cached Users</div>
|
||||
<div className="text-2xl font-bold">{stats.count}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Memory Cache</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats.memoryCacheSize} / {stats.memoryCacheLimit}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleRefreshAll}
|
||||
disabled={refreshing}
|
||||
>
|
||||
{refreshing ? "Refreshing..." : "Refresh All"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRefreshStale}
|
||||
variant="outline"
|
||||
>
|
||||
Refresh Stale Only
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cache entries expire after 24 hours. Refresh to get latest relay lists.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Impact**:
|
||||
- User control over cache freshness
|
||||
- Manual warming for important follows
|
||||
- Visibility into cache state
|
||||
- Proactive performance management
|
||||
|
||||
**Implementation Location**: `src/components/settings/RelayListSettings.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 8. Diagnostic Panel
|
||||
|
||||
**Problem**: When queries fail or perform poorly, users and developers have no visibility into relay selection reasoning.
|
||||
|
||||
**Proposed Solution**:
|
||||
```tsx
|
||||
// In src/components/ReqViewer.tsx
|
||||
interface RelayDiagnosticsProps {
|
||||
reasoning: RelaySelectionReasoning[];
|
||||
isOptimized: boolean;
|
||||
phase: RelaySelectionPhase;
|
||||
}
|
||||
|
||||
function RelayDiagnostics({ reasoning, isOptimized, phase }: RelayDiagnosticsProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const metrics = relayListCache.getMetrics();
|
||||
|
||||
return (
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-2 text-sm font-semibold hover:underline"
|
||||
>
|
||||
<ChevronRight className={`size-4 transition-transform ${expanded ? 'rotate-90' : ''}`} />
|
||||
Relay Selection Diagnostics
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-4 space-y-4 text-sm">
|
||||
{/* Selection Status */}
|
||||
<div>
|
||||
<div className="font-semibold">Selection Status</div>
|
||||
<div className="text-muted-foreground">
|
||||
Phase: {phase} • Optimized: {isOptimized ? 'Yes' : 'No (using fallbacks)'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cache Performance */}
|
||||
<div>
|
||||
<div className="font-semibold">Cache Performance</div>
|
||||
<div className="grid grid-cols-3 gap-2 mt-2">
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">Memory Hits</div>
|
||||
<div className="font-mono">{(metrics.memoryCacheHitRate * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">Dexie Hits</div>
|
||||
<div className="font-mono">{(metrics.dexieCacheHitRate * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">Network Fetches</div>
|
||||
<div className="font-mono">{metrics.networkFetches}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Relays */}
|
||||
<div>
|
||||
<div className="font-semibold">Selected Relays</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{reasoning.map((r, i) => (
|
||||
<div key={i} className="flex items-center gap-2 font-mono text-xs">
|
||||
<span className={r.isFallback ? 'text-yellow-500' : 'text-green-500'}>
|
||||
{r.isFallback ? '⚠' : '✓'}
|
||||
</span>
|
||||
<span className="truncate">{r.relay}</span>
|
||||
<span className="text-muted-foreground">
|
||||
({r.writers.length}w {r.readers.length}r)
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coverage Analysis */}
|
||||
<div>
|
||||
<div className="font-semibold">Coverage Analysis</div>
|
||||
<div className="text-muted-foreground">
|
||||
{reasoning.filter(r => !r.isFallback).length} optimized relays,
|
||||
{' '}{reasoning.filter(r => r.isFallback).length} fallback relays
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Impact**:
|
||||
- Visibility into relay selection process
|
||||
- Easier debugging of query issues
|
||||
- Performance metrics at a glance
|
||||
- Educational for understanding NIP-65
|
||||
|
||||
**Implementation Location**: `src/components/ReqViewer.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Priority Recommendations
|
||||
|
||||
Based on impact vs. effort analysis:
|
||||
|
||||
### High Priority (Implement Next)
|
||||
1. **Request Deduplication** - Low effort, high impact on redundant queries
|
||||
2. **Fallback Warning System** - Low effort, significant UX improvement
|
||||
3. **Performance Metrics Collection** - Medium effort, critical for production monitoring
|
||||
|
||||
### Medium Priority
|
||||
4. **Speculative Prefetching** - Medium effort, large impact for cold start reduction
|
||||
5. **Diagnostic Panel** - Medium effort, valuable for debugging and transparency
|
||||
|
||||
### Lower Priority (Nice to Have)
|
||||
6. **Adaptive Timeout** - High effort, moderate impact
|
||||
7. **Incremental Relay Selection** - High effort, moderate UX improvement
|
||||
8. **Cache Warming UI** - Low effort, but user-initiated edge case
|
||||
|
||||
---
|
||||
|
||||
## Performance Impact Summary
|
||||
|
||||
| Improvement | Expected Gain | Current | Target |
|
||||
|-------------|---------------|---------|--------|
|
||||
| Request Deduplication | -60% redundant requests | N/A | N/A |
|
||||
| Speculative Prefetching | -80% cold start delays | 1040ms | ~200ms |
|
||||
| Adaptive Timeout | -40% wasted time | 1000ms | 300-500ms |
|
||||
| Incremental Selection | Perceived perf | 1040ms | 10-50ms first response |
|
||||
| Performance Metrics | Monitoring | None | Full telemetry |
|
||||
|
||||
---
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
For each improvement:
|
||||
1. Add unit tests for core logic
|
||||
2. Add integration tests for timing/caching behavior
|
||||
3. Manual testing with slow networks (throttle to 3G)
|
||||
4. Measure before/after metrics with realistic data
|
||||
5. Test fallback scenarios (cache miss, timeout, error)
|
||||
|
||||
---
|
||||
|
||||
*Document created: 2025-01-XX*
|
||||
*Quick wins implemented: Single author special case, in-memory LRU cache, relay selection progress indicator*
|
||||
*Future work: These improvements are prioritized but not yet scheduled for implementation*
|
||||
Reference in New Issue
Block a user