From 4f2f66163b8bc8cbea189e465cf795ffaacdf074 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Fri, 2 Jan 2026 23:12:30 -0300 Subject: [PATCH 1/3] feat: notebook modal, contextual navigation, and private repo updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Notebook Creation Modal - Add NotebookCreateModal component with Inkdrop-style design - Modal state lives in Sidebar (not NotebookList) - NotebookList is now a pure list component - Create notebooks as children when inside a notebook context ## Contextual Navigation - Add goToAllInCurrentContext() to stay in notebook when filtering - Notes created inside a notebook now stay in that notebook - Pass notebookId through entire stack (core → IPC → renderer) ## Auto-Updater for Private Repo - Add private: true to publish config - Update release workflow to use secrets.GH_TOKEN - Document current setup and future evolution path 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/release.yml | 6 +- apps/desktop/package.json | 3 +- apps/desktop/src/main/index.ts | 2 +- apps/desktop/src/preload/index.ts | 1 + apps/desktop/src/renderer/App.tsx | 10 +- .../sidebar/NotebookCreateModal.module.css | 134 ++++++++++++++++++ .../sidebar/NotebookCreateModal.tsx | 98 +++++++++++++ .../components/sidebar/NotebookList.tsx | 49 ++----- .../renderer/components/sidebar/Sidebar.tsx | 61 +++++++- .../src/renderer/hooks/useNavigation.ts | 2 + apps/desktop/src/renderer/hooks/useNotes.ts | 2 +- .../src/renderer/stores/navigationStore.ts | 13 +- packages/core/src/contracts/NoteInput.ts | 3 + packages/core/src/operations/createNote.ts | 3 +- plan.md | 33 +++++ 15 files changed, 364 insertions(+), 56 deletions(-) create mode 100644 apps/desktop/src/renderer/components/sidebar/NotebookCreateModal.module.css create mode 100644 apps/desktop/src/renderer/components/sidebar/NotebookCreateModal.tsx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 867f30f..ca2da4b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,7 +64,7 @@ jobs: if: matrix.platform == 'mac' working-directory: apps/desktop env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GH_TOKEN }} # Code signing CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} @@ -78,14 +78,14 @@ jobs: if: matrix.platform == 'win' working-directory: apps/desktop env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GH_TOKEN }} run: pnpm dist:win --publish always - name: Build distributables (Linux) if: matrix.platform == 'linux' working-directory: apps/desktop env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GH_TOKEN }} run: pnpm dist:linux --publish always - name: Upload artifacts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ee0c0e8..99f6449 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -139,7 +139,8 @@ "publish": { "provider": "github", "owner": "tomymaritano", - "repo": "readide" + "repo": "readide", + "private": true } } } diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 96d0f4a..7946f49 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -217,7 +217,7 @@ function registerIpcHandlers(): void { const repo = noteRepository; // Create note - ipcMain.handle('notes:create', async (_event, input: { content: string; id?: string }) => { + ipcMain.handle('notes:create', async (_event, input: { content: string; id?: string; notebookId?: string }) => { return createNoteOperation(input, repo); }); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 6457fc6..8e453e7 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -151,6 +151,7 @@ export interface ReadiedAPI { content: string; id?: string; title?: string; + notebookId?: string; }) => Promise>; /** Get a note by ID */ get: (id: string) => Promise>; diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 151e27d..9b08c10 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -109,14 +109,16 @@ function NotesApp() { }, 300); }, []); - // Create new note + // Create new note (respects current navigation context) const handleNewNote = useCallback(async () => { - const newNote = await createNote.mutateAsync({ content: '# Untitled\n\n' }); + const newNote = await createNote.mutateAsync({ + content: '# Untitled\n\n', + notebookId: selectedNotebookId ?? undefined, + }); setSelectedNote(newNote); - goToAllNotes(); setSearchQuery(''); setDebouncedSearch(''); - }, [createNote, goToAllNotes]); + }, [createNote, selectedNotebookId]); // Select note const handleSelectNote = useCallback(async (id: string) => { diff --git a/apps/desktop/src/renderer/components/sidebar/NotebookCreateModal.module.css b/apps/desktop/src/renderer/components/sidebar/NotebookCreateModal.module.css new file mode 100644 index 0000000..7c13634 --- /dev/null +++ b/apps/desktop/src/renderer/components/sidebar/NotebookCreateModal.module.css @@ -0,0 +1,134 @@ +/** + * NotebookCreateModal CSS Module + * + * Inkdrop-style modal for creating notebooks. + */ + +/* Overlay backdrop */ +.overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 120px; + background: rgba(0, 0, 0, 0.5); + animation: fadeIn 0.1s ease-out; +} + +.modal { + width: 100%; + max-width: 420px; + background: var(--bg-inset, #1a1d23); + border-radius: 12px; + border: none; + box-shadow: 0 16px 64px rgba(0, 0, 0, 0.5); + overflow: hidden; + animation: slideIn 0.15s ease-out; + padding: 20px; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Header */ +.header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 20px; +} + +.headerIcon { + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); +} + +.headerTitle { + font-size: 16px; + font-weight: 500; + color: var(--text-primary, rgba(255, 255, 255, 0.9)); +} + +/* Input wrapper */ +.inputWrapper { + margin-bottom: 20px; +} + +.input { + width: 100%; + padding: 14px 16px; + background: transparent; + border: 1px solid var(--accent-muted, rgba(107, 159, 255, 0.4)); + border-radius: 8px; + color: var(--text-primary, rgba(255, 255, 255, 0.9)); + font-size: 14px; + font-weight: 400; + outline: none; + transition: border-color 0.15s ease; +} + +.input:focus { + border-color: var(--accent, #6b9fff); + outline: none; + box-shadow: none; +} + +.input::placeholder { + color: var(--text-muted, rgba(255, 255, 255, 0.4)); +} + +/* Actions */ +.actions { + display: flex; + gap: 12px; +} + +.cancelBtn, +.createBtn { + flex: 1; + padding: 12px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s ease, opacity 0.15s ease; +} + +.cancelBtn { + background: var(--bg-surface, #2a2d35); + border: none; + color: var(--text-primary, rgba(255, 255, 255, 0.9)); +} + +.cancelBtn:hover { + background: var(--bg-hover, #35383f); +} + +.createBtn { + background: var(--accent, #6b9fff); + border: none; + color: #fff; +} + +.createBtn:hover:not(:disabled) { + background: var(--accent-hover, #5a8fee); +} + +.createBtn:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/apps/desktop/src/renderer/components/sidebar/NotebookCreateModal.tsx b/apps/desktop/src/renderer/components/sidebar/NotebookCreateModal.tsx new file mode 100644 index 0000000..ed8bef0 --- /dev/null +++ b/apps/desktop/src/renderer/components/sidebar/NotebookCreateModal.tsx @@ -0,0 +1,98 @@ +import { useState, useCallback, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { BookOpen } from 'lucide-react'; +import styles from './NotebookCreateModal.module.css'; + +interface NotebookCreateModalProps { + readonly parentId?: string | null; + readonly onSubmit: (name: string, parentId: string | null) => void; + readonly onCancel: () => void; +} + +export function NotebookCreateModal({ parentId, onSubmit, onCancel }: NotebookCreateModalProps) { + const [name, setName] = useState(''); + const inputRef = useRef(null); + + // Focus input on mount + useEffect(() => { + inputRef.current?.focus(); + }, []); + + // Close on escape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onCancel(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [onCancel]); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + const trimmedName = name.trim(); + if (trimmedName) { + onSubmit(trimmedName, parentId ?? null); + } + }, + [name, parentId, onSubmit] + ); + + // Close when clicking overlay (outside modal) + const handleOverlayClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onCancel(); + } + }, + [onCancel] + ); + + return createPortal( +
+
+
+ + Add New Notebook +
+ +
+
+ setName(e.target.value)} + placeholder="Enter notebook name" + className={styles.input} + aria-label="New notebook name" + /> +
+ +
+ + +
+
+
+
, + document.body + ); +} diff --git a/apps/desktop/src/renderer/components/sidebar/NotebookList.tsx b/apps/desktop/src/renderer/components/sidebar/NotebookList.tsx index 8e9ee01..aa35782 100644 --- a/apps/desktop/src/renderer/components/sidebar/NotebookList.tsx +++ b/apps/desktop/src/renderer/components/sidebar/NotebookList.tsx @@ -1,13 +1,14 @@ -import { useState, useCallback, useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useNotebookTree, useNotebookMutations, getAncestorIds } from '../../hooks/useNotebooks'; import type { NotebookTreeNode } from '../../../preload/index'; import { NotebookItem } from './NotebookItem'; -import { NotebookCreateForm } from './NotebookCreateForm'; interface NotebookListProps { readonly selectedNotebookId: string | null; readonly onSelectNotebook: (id: string) => void; readonly filterParentId?: string | null; + /** Request to create a child notebook */ + readonly onRequestCreateChild: (parentId: string) => void; } function NotebookListSkeleton() { @@ -36,15 +37,20 @@ function NotebookListError({ message }: { message: string }) { ); } +/** + * NotebookList - Pure list component + * + * Only renders notebooks and emits events. + * Does NOT manage modals or overlays. + */ export function NotebookList({ selectedNotebookId, onSelectNotebook, filterParentId, + onRequestCreateChild, }: NotebookListProps) { const { data: tree, isLoading, error } = useNotebookTree(); - const { createNotebook, renameNotebook, deleteNotebook } = useNotebookMutations(); - const [isCreating, setIsCreating] = useState(false); - const [createParentId, setCreateParentId] = useState(null); + const { renameNotebook, deleteNotebook } = useNotebookMutations(); // Calculate ancestor IDs for breadcrumb-style highlighting const ancestorIds = useMemo( @@ -56,7 +62,6 @@ export function NotebookList({ const displayedTree = useMemo(() => { if (!filterParentId || !tree) return tree ?? []; - // Find children of the selected notebook function findChildren(nodes: NotebookTreeNode[]): NotebookTreeNode[] { for (const node of nodes) { if (node.notebook.id === filterParentId) { @@ -70,28 +75,6 @@ export function NotebookList({ return findChildren(tree); }, [tree, filterParentId]); - const handleStartCreate = useCallback((parentId?: string) => { - setCreateParentId(parentId ?? null); - setIsCreating(true); - }, []); - - const handleCreate = useCallback( - async (name: string, parentId: string | null) => { - await createNotebook.mutateAsync({ - name, - parentId: parentId ?? undefined, - }); - setIsCreating(false); - setCreateParentId(null); - }, - [createNotebook] - ); - - const handleCancelCreate = useCallback(() => { - setIsCreating(false); - setCreateParentId(null); - }, []); - const handleRename = useCallback( async (id: string, name: string) => { await renameNotebook.mutateAsync({ id, name }); @@ -126,13 +109,6 @@ export function NotebookList({ return (
- {isCreating && ( - - )}
    {displayedTree.map(node => ( ))}
@@ -154,5 +130,4 @@ export function NotebookList({ ); } -// Export for external use to trigger creation export type { NotebookListProps }; diff --git a/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx b/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx index 37d64be..f9da8a6 100644 --- a/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx +++ b/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx @@ -1,3 +1,4 @@ +import { useState, useCallback } from 'react'; import { useIsNotebookContext, useSelectedNotebookId, @@ -8,6 +9,7 @@ import { useStatusFilter, useTagFilter, } from '../../hooks/useNavigation'; +import { useNotebookMutations } from '../../hooks/useNotebooks'; import { SidebarHeader } from './SidebarHeader'; import { SidebarBreadcrumb } from './SidebarBreadcrumb'; import { SidebarQuickFilters } from './SidebarQuickFilters'; @@ -16,14 +18,19 @@ import { NotebookList } from './NotebookList'; import { TagsList } from './TagsList'; import { StatusFilters } from './StatusFilters'; import { SidebarFooter } from './SidebarFooter'; +import { NotebookCreateModal } from './NotebookCreateModal'; /** * Sidebar component - Pure render of NavigationState from Zustand * * Uses granular selectors to minimize re-renders. - * Does NOT manage state - only renders UI and emits actions. + * Owns modal state for notebook creation. */ export function Sidebar() { + // Modal state - lives HERE, not in NotebookList + const [isCreateNotebookOpen, setIsCreateNotebookOpen] = useState(false); + const [createParentId, setCreateParentId] = useState(null); + // Granular selectors const isNotebookContext = useIsNotebookContext(); const selectedNotebookId = useSelectedNotebookId(); @@ -33,9 +40,12 @@ export function Sidebar() { const statusFilter = useStatusFilter(); const tagFilter = useTagFilter(); - // Actions + // Mutations + const { createNotebook } = useNotebookMutations(); + + // Navigation actions const { - goToAllNotes, + goToAllInCurrentContext, goToPinned, goToTrash, goToNotebook, @@ -44,6 +54,35 @@ export function Sidebar() { setTagFilter, } = useNavigationActions(); + // Modal handlers + // When in notebook context, create child of current notebook + // When at root level, create at root + const openCreateInContext = useCallback(() => { + setCreateParentId(selectedNotebookId); + setIsCreateNotebookOpen(true); + }, [selectedNotebookId]); + + const openCreateChild = useCallback((parentId: string) => { + setCreateParentId(parentId); + setIsCreateNotebookOpen(true); + }, []); + + const closeCreate = useCallback(() => { + setIsCreateNotebookOpen(false); + setCreateParentId(null); + }, []); + + const handleCreateNotebook = useCallback( + async (name: string, parentId: string | null) => { + await createNotebook.mutateAsync({ + name, + parentId: parentId ?? undefined, + }); + closeCreate(); + }, + [createNotebook, closeCreate] + ); + return ( ); } diff --git a/apps/desktop/src/renderer/hooks/useNavigation.ts b/apps/desktop/src/renderer/hooks/useNavigation.ts index fd388ed..9a5efd5 100644 --- a/apps/desktop/src/renderer/hooks/useNavigation.ts +++ b/apps/desktop/src/renderer/hooks/useNavigation.ts @@ -73,6 +73,7 @@ export const useSortOrder = () => useNavigationStore(selectSortOrder); /** Get all navigation actions (stable references) */ export function useNavigationActions() { const goToAllNotes = useNavigationStore(s => s.goToAllNotes); + const goToAllInCurrentContext = useNavigationStore(s => s.goToAllInCurrentContext); const goToPinned = useNavigationStore(s => s.goToPinned); const goToTrash = useNavigationStore(s => s.goToTrash); const goToNotebook = useNavigationStore(s => s.goToNotebook); @@ -86,6 +87,7 @@ export function useNavigationActions() { return { goToAllNotes, + goToAllInCurrentContext, goToPinned, goToTrash, goToNotebook, diff --git a/apps/desktop/src/renderer/hooks/useNotes.ts b/apps/desktop/src/renderer/hooks/useNotes.ts index acf8a62..2c9076a 100644 --- a/apps/desktop/src/renderer/hooks/useNotes.ts +++ b/apps/desktop/src/renderer/hooks/useNotes.ts @@ -112,7 +112,7 @@ export function useNoteMutations() { }; const createNote = useMutation({ - mutationFn: async (input: { content: string; id?: string }) => { + mutationFn: async (input: { content: string; id?: string; notebookId?: string }) => { const result = await window.readied.notes.create(input); if (!result.ok) throw new Error(result.error.type); return result.data; diff --git a/apps/desktop/src/renderer/stores/navigationStore.ts b/apps/desktop/src/renderer/stores/navigationStore.ts index 48957dc..4e5b015 100644 --- a/apps/desktop/src/renderer/stores/navigationStore.ts +++ b/apps/desktop/src/renderer/stores/navigationStore.ts @@ -44,6 +44,7 @@ interface NavigationStore { // Actions - Navigation goToAllNotes: () => void; + goToAllInCurrentContext: () => void; goToPinned: () => void; goToTrash: () => void; goToNotebook: (id: string) => void; @@ -67,7 +68,7 @@ interface NavigationStore { const DEFAULT_NAVIGATION: NavigationState = { kind: 'global', filter: 'all' }; -export const useNavigationStore = create(set => ({ +export const useNavigationStore = create((set, get) => ({ // Initial state navigation: DEFAULT_NAVIGATION, statusFilter: null, @@ -78,6 +79,16 @@ export const useNavigationStore = create(set => ({ // Navigation actions (clear filters when changing context) goToAllNotes: () => set({ navigation: { kind: 'global', filter: 'all' }, statusFilter: null, tagFilter: null }), + goToAllInCurrentContext: () => { + const { navigation } = get(); + if (navigation.kind === 'notebook') { + // Stay in notebook, just clear filters + set({ statusFilter: null, tagFilter: null }); + } else { + // Go to global "all notes" + set({ navigation: { kind: 'global', filter: 'all' }, statusFilter: null, tagFilter: null }); + } + }, goToPinned: () => set({ navigation: { kind: 'global', filter: 'pinned' }, statusFilter: null, tagFilter: null }), goToTrash: () => diff --git a/packages/core/src/contracts/NoteInput.ts b/packages/core/src/contracts/NoteInput.ts index 0725d96..9dfab9f 100644 --- a/packages/core/src/contracts/NoteInput.ts +++ b/packages/core/src/contracts/NoteInput.ts @@ -13,6 +13,9 @@ export interface CreateNoteInput { /** Optional ID (auto-generated if not provided) */ id?: string; + + /** Optional notebook ID (defaults to Inbox) */ + notebookId?: string; } /** Input for updating an existing note's content */ diff --git a/packages/core/src/operations/createNote.ts b/packages/core/src/operations/createNote.ts index fd902c2..67c72ce 100644 --- a/packages/core/src/operations/createNote.ts +++ b/packages/core/src/operations/createNote.ts @@ -5,7 +5,7 @@ */ import { createNote } from '../domain/note.js'; -import { createNoteId } from '../domain/types.js'; +import { createNoteId, createNotebookId } from '../domain/types.js'; import { validateContent, validateNoteId } from '../domain/invariants.js'; import { success, validationError, alreadyExists, type Result } from '../contracts/CoreResult.js'; import { toSnapshot, type NoteSnapshot } from '../contracts/NoteSnapshot.js'; @@ -44,6 +44,7 @@ export async function createNoteOperation( const note = createNote({ id: noteId, content: input.content, + notebookId: input.notebookId ? createNotebookId(input.notebookId) : undefined, }); // Save to repository diff --git a/plan.md b/plan.md index 16f8edd..cc318e5 100644 --- a/plan.md +++ b/plan.md @@ -401,6 +401,39 @@ import { autoUpdater } from 'electron-updater'; autoUpdater.checkForUpdatesAndNotify(); ``` +### 9.3.1 Private Repo Updates (Current Setup) + +El repo es privado, por lo que los releases requieren autenticación. + +**Configuración actual:** +- `publish.private: true` en package.json +- `GH_TOKEN` secret con PAT de solo lectura +- electron-updater maneja auth automáticamente + +**Riesgos aceptados:** +- Token recuperable del binario (inevitable para repos privados) +- Si token expira → auto-update roto +- Dependencia de GitHub infra + +### 9.3.2 Evolución Futura (Post-Monetización) + +Cuando el producto tenga usuarios pagos, churn tracking, y soporte activo, la arquitectura evoluciona a: + +| Actual (Pre-revenue) | Futuro (Post-revenue) | +|----------------------|-----------------------| +| Repo privado | Releases públicos (o CDN) | +| Updater con auth | Updater sin auth | +| N/A | Licencia validada aparte | +| N/A | Features gated por licencia | + +**Principio:** Updates accesibles para todos. Monetización via licencia, no via acceso a updates. + +**Implementación futura:** +1. Mover releases a GitHub público o CDN propio +2. Eliminar `private: true` y token +3. Validar licencia al iniciar app +4. Gating de features Pro via capabilities system + ### 9.4 Versioning Strategy ```typescript From 0762300c2947ac7de5ecda5928988a835b86356f Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Fri, 2 Jan 2026 23:35:52 -0300 Subject: [PATCH 2/3] style: apply prettier formatting + feat: wikilinks UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: - Apply prettier formatting to fix CI lint errors Features: - Add [[wikilink]] syntax highlighting in editor (CodeMirror) - Add [[wikilink]] rendering in preview (remark plugin) - Add click-to-navigate for wikilinks (best-effort by title) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/package.json | 2 + apps/desktop/src/main/index.ts | 9 ++- apps/desktop/src/renderer/App.tsx | 17 +++++ .../renderer/components/MarkdownEditor.tsx | 4 + .../src/renderer/components/NoteEditor.tsx | 3 + .../components/editor/MarkdownPreview.tsx | 25 ++++++- .../components/editor/remark-wikilink.ts | 74 +++++++++++++++++++ .../components/editor/wikilink-extension.ts | 61 +++++++++++++++ .../sidebar/NotebookCreateModal.tsx | 12 +-- .../renderer/components/sidebar/Sidebar.tsx | 6 +- apps/desktop/src/renderer/styles/preview.css | 12 +++ plan.md | 15 ++-- pnpm-lock.yaml | 6 ++ 13 files changed, 218 insertions(+), 28 deletions(-) create mode 100644 apps/desktop/src/renderer/components/editor/remark-wikilink.ts create mode 100644 apps/desktop/src/renderer/components/editor/wikilink-extension.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 99f6449..aa3929a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -49,10 +49,12 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "^4.2.0", "remark-gfm": "^4.0.1", + "unist-util-visit": "^5.0.0", "zustand": "^5.0.9" }, "devDependencies": { "@types/better-sqlite3": "^7.6.12", + "@types/mdast": "^4.0.4", "@types/react": "^18.2.79", "@types/react-dom": "^18.2.25", "@vitejs/plugin-react": "^4.2.1", diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 7946f49..4292bc1 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -217,9 +217,12 @@ function registerIpcHandlers(): void { const repo = noteRepository; // Create note - ipcMain.handle('notes:create', async (_event, input: { content: string; id?: string; notebookId?: string }) => { - return createNoteOperation(input, repo); - }); + ipcMain.handle( + 'notes:create', + async (_event, input: { content: string; id?: string; notebookId?: string }) => { + return createNoteOperation(input, repo); + } + ); // Get note ipcMain.handle('notes:get', async (_event, id: string) => { diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 9b08c10..9a711d4 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -128,6 +128,22 @@ function NotesApp() { } }, []); + // Handle wikilink click - best-effort navigation by title + const handleWikilinkClick = useCallback( + async (title: string) => { + const notes = await window.readied.notes.search(title); + if (notes.length > 0) { + // Find exact match (case-insensitive) + const match = notes.find(n => n.title.toLowerCase() === title.toLowerCase()); + if (match) { + handleSelectNote(match.id); + } + } + // No-op if note doesn't exist (future: could show toast or create note) + }, + [handleSelectNote] + ); + // Update note content const handleUpdateNote = useCallback( async (content: string) => { @@ -313,6 +329,7 @@ function NotesApp() { onStatusChange={handleStatusChange} onDuplicate={selectedNote ? () => handleDuplicateNote(selectedNote.id) : undefined} onDelete={selectedNote ? () => handleDeleteNote(selectedNote.id) : undefined} + onWikilinkClick={handleWikilinkClick} /> diff --git a/apps/desktop/src/renderer/components/MarkdownEditor.tsx b/apps/desktop/src/renderer/components/MarkdownEditor.tsx index c03b93b..eb2dca6 100644 --- a/apps/desktop/src/renderer/components/MarkdownEditor.tsx +++ b/apps/desktop/src/renderer/components/MarkdownEditor.tsx @@ -38,6 +38,7 @@ import { undoChange, redoChange, } from './editor/toolbar-commands'; +import { wikilinkExtension } from './editor/wikilink-extension'; /** Dark theme matching Readied's design */ const darkTheme = EditorView.theme( @@ -300,6 +301,9 @@ export const MarkdownEditor = forwardRef void; onDuplicate?: () => void; onDelete?: () => void; + onWikilinkClick?: (target: string) => void; } export function NoteEditor({ @@ -48,6 +49,7 @@ export function NoteEditor({ onStatusChange, onDuplicate, onDelete, + onWikilinkClick, }: NoteEditorProps) { const queryClient = useQueryClient(); const debounceRef = useRef(null); @@ -273,6 +275,7 @@ export function NoteEditor({ createdAt={note.createdAt} updatedAt={note.updatedAt} onReady={handlePreviewReady} + onWikilinkClick={onWikilinkClick} />
)} diff --git a/apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx b/apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx index 2cf45b4..de6379c 100644 --- a/apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx +++ b/apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx @@ -1,12 +1,14 @@ -import { useMemo, useRef, useImperativeHandle, forwardRef, useEffect } from 'react'; +import { useMemo, useRef, useImperativeHandle, forwardRef, useEffect, useCallback } from 'react'; import Markdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import { remarkWikilink } from './remark-wikilink'; interface MarkdownPreviewProps { readonly content: string; readonly createdAt?: string; readonly updatedAt?: string; readonly onReady?: () => void; + readonly onWikilinkClick?: (target: string) => void; } /** Imperative handle for scroll sync */ @@ -24,9 +26,24 @@ export interface MarkdownPreviewHandle { * Exposes scroll methods via ref for sync with editor. */ export const MarkdownPreview = forwardRef( - function MarkdownPreview({ content, createdAt, updatedAt, onReady }, ref) { + function MarkdownPreview({ content, createdAt, updatedAt, onReady, onWikilinkClick }, ref) { const containerRef = useRef(null); + // Delegated click handler for wikilinks + const handleClick = useCallback( + (e: React.MouseEvent) => { + const target = (e.target as HTMLElement).closest('.wikilink'); + if (target) { + const noteTitle = target.getAttribute('data-target'); + if (noteTitle && onWikilinkClick) { + e.preventDefault(); + onWikilinkClick(noteTitle); + } + } + }, + [onWikilinkClick] + ); + // Notify parent when mounted useEffect(() => { onReady?.(); @@ -86,9 +103,9 @@ export const MarkdownPreview = forwardRef +
; +} + +export function remarkWikilink() { + return (tree: Root) => { + visit(tree, 'text', (node: Text, index: number | undefined, parent: Parent | undefined) => { + if (index === undefined || !parent) return; + + const value = node.value; + if (!value.includes('[[')) return; + + const children: Array<{ type: string; value?: string } | WikilinkNode> = []; + let lastIndex = 0; + + // IMPORTANTE: resetear lastIndex para regex global + WIKILINK_PATTERN.lastIndex = 0; + + let match; + while ((match = WIKILINK_PATTERN.exec(value)) !== null) { + // Text before match + if (match.index > lastIndex) { + children.push({ type: 'text', value: value.slice(lastIndex, match.index) }); + } + + // Wikilink element + const target = match[1]!.trim(); + const display = match[2]?.trim() || target; + children.push({ + type: 'element', + tagName: 'span', + properties: { + className: ['wikilink'], + 'data-target': target, + }, + children: [{ type: 'text', value: display }], + } as WikilinkNode); + + lastIndex = match.index + match[0].length; + } + + // Remaining text + if (lastIndex < value.length) { + children.push({ type: 'text', value: value.slice(lastIndex) }); + } + + if (children.length > 0) { + // @ts-ignore - unist types don't match hast nodes but this works with rehype + parent.children.splice(index, 1, ...children); + // IMPORTANTE: retornar nuevo index para no romper traversal + return index + children.length; + } + }); + }; +} diff --git a/apps/desktop/src/renderer/components/editor/wikilink-extension.ts b/apps/desktop/src/renderer/components/editor/wikilink-extension.ts new file mode 100644 index 0000000..ffbe7e6 --- /dev/null +++ b/apps/desktop/src/renderer/components/editor/wikilink-extension.ts @@ -0,0 +1,61 @@ +/** + * CodeMirror extension for wikilink [[note]] syntax highlighting + * + * Highlights [[target]] and [[target|display]] patterns in the editor. + * Does NOT handle click navigation - that's the preview's job. + */ + +import { ViewPlugin, Decoration, DecorationSet, EditorView } from '@codemirror/view'; +import type { ViewUpdate } from '@codemirror/view'; +import { RangeSetBuilder } from '@codemirror/state'; + +// Pattern: [[target]] or [[target|display]] +const WIKILINK_PATTERN = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g; + +const wikilinkMark = Decoration.mark({ class: 'cm-wikilink' }); + +const wikilinkTheme = EditorView.baseTheme({ + '.cm-wikilink': { + color: '#5eead4', + borderBottom: '1px solid rgba(94, 234, 212, 0.3)', + borderRadius: '2px', + }, +}); + +class WikilinkHighlighter { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = this.build(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = this.build(update.view); + } + } + + build(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + const { from, to } = view.viewport; + const text = view.state.sliceDoc(from, to); + + // IMPORTANTE: resetear lastIndex para regex global + WIKILINK_PATTERN.lastIndex = 0; + + let match; + while ((match = WIKILINK_PATTERN.exec(text)) !== null) { + const start = from + match.index; + const end = start + match[0].length; + builder.add(start, end, wikilinkMark); + } + + return builder.finish(); + } +} + +const wikilinkHighlighter = ViewPlugin.fromClass(WikilinkHighlighter, { + decorations: v => v.decorations, +}); + +export const wikilinkExtension = [wikilinkTheme, wikilinkHighlighter]; diff --git a/apps/desktop/src/renderer/components/sidebar/NotebookCreateModal.tsx b/apps/desktop/src/renderer/components/sidebar/NotebookCreateModal.tsx index ed8bef0..54660e8 100644 --- a/apps/desktop/src/renderer/components/sidebar/NotebookCreateModal.tsx +++ b/apps/desktop/src/renderer/components/sidebar/NotebookCreateModal.tsx @@ -75,18 +75,10 @@ export function NotebookCreateModal({ parentId, onSubmit, onCancel }: NotebookCr
- -
diff --git a/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx b/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx index f9da8a6..d59e4d4 100644 --- a/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx +++ b/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx @@ -106,11 +106,7 @@ export function Sidebar() { isNotebookContext={isNotebookContext} /> - + Date: Fri, 2 Jan 2026 23:38:34 -0300 Subject: [PATCH 3/3] fix: replace @ts-ignore with proper type cast in remark-wikilink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/renderer/components/editor/remark-wikilink.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/components/editor/remark-wikilink.ts b/apps/desktop/src/renderer/components/editor/remark-wikilink.ts index f7df2f4..3f5ca11 100644 --- a/apps/desktop/src/renderer/components/editor/remark-wikilink.ts +++ b/apps/desktop/src/renderer/components/editor/remark-wikilink.ts @@ -64,8 +64,9 @@ export function remarkWikilink() { } if (children.length > 0) { - // @ts-ignore - unist types don't match hast nodes but this works with rehype - parent.children.splice(index, 1, ...children); + // Cast needed: we're inserting hast-like nodes into mdast parent + // This works because react-markdown handles the hybrid tree + (parent.children as unknown[]).splice(index, 1, ...children); // IMPORTANTE: retornar nuevo index para no romper traversal return index + children.length; }