From 5a136e6e52707980a39461216966589ca5b24d81 Mon Sep 17 00:00:00 2001 From: Tomas Maritano Date: Wed, 19 Aug 2026 01:29:13 -0300 Subject: [PATCH 01/14] feat(desktop): add editor back, forward, and zen Match the note-list header with a chrome row: pop-out, distraction-free, and note history. Zen hides sidebar and list so the editor can take the frame. --- apps/desktop/src/renderer/App.tsx | 107 +++++++++++++--- .../renderer/components/CommandPalette.tsx | 8 ++ .../renderer/components/NoteEditor.module.css | 22 +++- .../src/renderer/components/NoteEditor.tsx | 115 +++++++++++------- .../renderer/components/NoteList.module.css | 1 + .../components/editor/EditorChrome.tsx | 66 ++++++++++ .../src/renderer/components/editor/index.ts | 1 + .../src/renderer/hooks/useAppCommands.ts | 12 ++ .../renderer/hooks/useRegisterAppCommands.ts | 8 ++ .../src/renderer/hooks/useResizableLayout.ts | 15 ++- apps/desktop/src/renderer/styles/global.css | 5 + .../utils/__tests__/noteHistory.test.ts | 43 +++++++ .../desktop/src/renderer/utils/noteHistory.ts | 43 +++++++ docs/NOW.md | 1 + .../command-registry/src/definitions/app.ts | 34 ++++++ 15 files changed, 415 insertions(+), 66 deletions(-) create mode 100644 apps/desktop/src/renderer/components/editor/EditorChrome.tsx create mode 100644 apps/desktop/src/renderer/utils/__tests__/noteHistory.test.ts create mode 100644 apps/desktop/src/renderer/utils/noteHistory.ts diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 67c09907..a9b833fd 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -39,6 +39,14 @@ import { useAppearanceSettings } from './hooks/useAppearanceSettings'; import { useOfficialThemes } from './hooks/useOfficialThemes'; import { useResizableLayout } from './hooks/useResizableLayout'; import { useSyncStore } from './stores/syncStore'; +import { + canGoBack, + canGoForward, + emptyNoteHistory, + historyBack, + historyForward, + visitNote, +} from './utils/noteHistory'; import { useDeepLinks } from './hooks/useDeepLinks'; import { useAutoSave } from './hooks/useAutoSave'; import { useNoteActions } from './hooks/useNoteActions'; @@ -75,20 +83,25 @@ function NotesApp() { sidebarWidth, notelistWidth, sidebarCollapsed, + distractionFree, toggleSidebar, + toggleDistractionFree, startResizeSidebar, startResizeNotelist, } = useResizableLayout(); + const hideSidebar = sidebarCollapsed || distractionFree; + const hideNoteList = distractionFree; + useEffect(() => { const setVisibility = window.dripnex.windows.setButtonVisibility; if (typeof setVisibility !== 'function') return; - // Collapse hides the native traffic lights so the note-list can sit at x=0. - void setVisibility(!sidebarCollapsed); + // Hide native traffic lights when the first column is gone. + void setVisibility(!hideSidebar); return () => { void setVisibility(true); }; - }, [sidebarCollapsed]); + }, [hideSidebar]); // Navigation state from Zustand const navigation = useNavigation(); @@ -130,6 +143,11 @@ function NotesApp() { const [selectedNote, setSelectedNote] = useState(null); const selectedNoteRef = useRef(null); selectedNoteRef.current = selectedNote; + const [noteHistory, setNoteHistory] = useState(emptyNoteHistory); + const setSelectedNoteAndVisit = useCallback((note: NoteSnapshot | null) => { + setSelectedNote(note); + if (note) setNoteHistory(prev => visitNote(prev, note.id)); + }, []); const { appAPI, dataAPI, pluginSlot } = usePluginRuntime(selectedNoteRef); const { searchQuery, debouncedSearch, handleSearch, clearSearch } = useDebouncedSearch(300); const [isGraphOpen, setIsGraphOpen] = useState(false); @@ -251,7 +269,7 @@ function NotesApp() { handleStatusChange, } = useNoteActions({ selectedNote, - setSelectedNote, + setSelectedNote: setSelectedNoteAndVisit, selectedNotebookId, clearSearch, appAPI, @@ -293,9 +311,31 @@ function NotesApp() { setIsGraphOpen, searchQuery, clearSearch, - setSelectedNote, + setSelectedNote: setSelectedNoteAndVisit, displayedNotes, onSelectNote: handleSelectNote, + onNoteBack: () => { + const { state, id } = historyBack(noteHistory); + if (!id) return; + setNoteHistory(state); + void window.dripnex.notes.get(id).then(result => { + if (result.ok) setSelectedNote(result.data); + }); + }, + onNoteForward: () => { + const { state, id } = historyForward(noteHistory); + if (!id) return; + setNoteHistory(state); + void window.dripnex.notes.get(id).then(result => { + if (result.ok) setSelectedNote(result.data); + }); + }, + onToggleZen: toggleDistractionFree, + onOpenInWindow: () => { + const note = selectedNoteRef.current; + if (!note) return; + void window.dripnex.windows.openNote(note.id, note.title || 'Note'); + }, }); // Determine selected quick filter for NoteList header @@ -372,14 +412,14 @@ function NotesApp() {
- {!sidebarCollapsed ? ( + {!hideSidebar ? (
) : null} -
+
-
+ {!hideNoteList ? ( +
+ ) : null}
{isGraphOpen ? ( @@ -463,6 +511,33 @@ function NotesApp() { onWikilinkClick={handleWikilinkClick} onNavigateToNote={handleSelectNote} onNoteUpdate={setSelectedNote} + canBack={canGoBack(noteHistory)} + canForward={canGoForward(noteHistory)} + distractionFree={distractionFree} + onBack={() => { + const { state, id } = historyBack(noteHistory); + if (!id) return; + setNoteHistory(state); + void window.dripnex.notes.get(id).then(result => { + if (result.ok) setSelectedNote(result.data); + }); + }} + onForward={() => { + const { state, id } = historyForward(noteHistory); + if (!id) return; + setNoteHistory(state); + void window.dripnex.notes.get(id).then(result => { + if (result.ok) setSelectedNote(result.data); + }); + }} + onToggleZen={toggleDistractionFree} + onOpenWindow={() => { + if (!selectedNote) return; + void window.dripnex.windows.openNote( + selectedNote.id, + selectedNote.title || 'Note' + ); + }} /> )}
diff --git a/apps/desktop/src/renderer/components/CommandPalette.tsx b/apps/desktop/src/renderer/components/CommandPalette.tsx index 9b07085a..27257ef9 100644 --- a/apps/desktop/src/renderer/components/CommandPalette.tsx +++ b/apps/desktop/src/renderer/components/CommandPalette.tsx @@ -28,6 +28,10 @@ import { RefreshCw, Hash, BookMarked, + ArrowLeft, + ArrowRight, + Maximize2, + SquareArrowOutUpRight, } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import type { CommandCategory } from '@dripnex/command-registry'; @@ -95,6 +99,10 @@ const ICON_MAP: Record = { RefreshCw, Hash, BookMarked, + ArrowLeft, + ArrowRight, + Maximize2, + SquareArrowOutUpRight, }; const CATEGORY_ORDER: { category: CommandCategory; label: string }[] = [ diff --git a/apps/desktop/src/renderer/components/NoteEditor.module.css b/apps/desktop/src/renderer/components/NoteEditor.module.css index a1723ea4..c38540d1 100644 --- a/apps/desktop/src/renderer/components/NoteEditor.module.css +++ b/apps/desktop/src/renderer/components/NoteEditor.module.css @@ -63,12 +63,28 @@ animation: editor-spin 0.8s linear infinite; } -.note-editor-header { +.note-editor-chrome { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; - column-gap: 12px; - padding: 6px 16px; + column-gap: 8px; + padding: 8px 10px; + min-height: 44px; + border-bottom: 1px solid var(--border-subtle); + -webkit-app-region: drag; +} + +.note-editor-chrome-left { + display: flex; + align-items: center; + gap: 2px; + -webkit-app-region: no-drag; +} + +.note-editor-header { + display: flex; + align-items: center; + padding: 8px 16px 4px; border-bottom: none; -webkit-app-region: drag; } diff --git a/apps/desktop/src/renderer/components/NoteEditor.tsx b/apps/desktop/src/renderer/components/NoteEditor.tsx index 1fad60ed..7364d034 100644 --- a/apps/desktop/src/renderer/components/NoteEditor.tsx +++ b/apps/desktop/src/renderer/components/NoteEditor.tsx @@ -1,6 +1,7 @@ import { useRef, useCallback, useState, useEffect, useMemo, lazy, Suspense } from 'react'; import { FileText, MoreVertical, Link2, Hash } from 'lucide-react'; import { LayoutZone } from '@dripnex/plugin-api'; +import type { MarkdownHeading } from '@dripnex/markdown'; import type { NoteSnapshot, NoteStatus } from '../../preload/index'; import { useEditorPreferencesStore } from '../stores/editorPreferencesStore'; import { @@ -14,23 +15,22 @@ import { useManualTags } from '../hooks/useManualTags'; import { useEmbedResolver } from '../hooks/useEmbedResolver'; import { useBacklinks } from '../hooks/useLinks'; import { useNotebook } from '../hooks/useNotebooks'; +import { isKindTag, normalizeTag, type NoteKind } from '../lib/knowledge'; import type { MarkdownEditorHandle } from './MarkdownEditor'; import type { MarkdownPreviewHandle, ToolbarVisibility } from './editor'; -import type { MarkdownHeading } from '@dripnex/markdown'; import { ImageLightbox } from './ImageLightbox'; import { BacklinksPanel } from './editor/BacklinksPanel'; import { RevisionHistoryPanel } from './editor/RevisionHistoryPanel'; import { ActionsPanel, + EditorChrome, EditorHeader, - EditorViewToggle, OutlinePanel, SelectionToolbar, MarkdownPreview, } from './editor'; import { TitleInput } from './TitleInput'; import { useToast } from './Toast'; -import { isKindTag, normalizeTag, type NoteKind } from '../lib/knowledge'; import { sc } from './noteEditorSc'; // Lazy load the markdown editor for better initial load performance @@ -63,6 +63,13 @@ interface NoteEditorProps { onNavigateToNote?: (noteId: string) => void; /** Called when note is updated (e.g., tags changed) */ onNoteUpdate?: (note: NoteSnapshot) => void; + canBack?: boolean; + canForward?: boolean; + distractionFree?: boolean; + onBack?: () => void; + onForward?: () => void; + onToggleZen?: () => void; + onOpenWindow?: () => void; } export function NoteEditor({ @@ -80,6 +87,13 @@ export function NoteEditor({ onWikilinkClick, onNavigateToNote, onNoteUpdate, + canBack = false, + canForward = false, + distractionFree = false, + onBack, + onForward, + onToggleZen, + onOpenWindow, }: NoteEditorProps) { const { showToast } = useToast(); const debounceRef = useRef(null); @@ -342,19 +356,17 @@ export function NoteEditor({ return (
- {/* Title row with actions buttons */} -
- -
- {saveStatus && ( - - {saveStatus} - - )} - + onBack?.()} + onForward={() => onForward?.()} + onToggleZen={() => onToggleZen?.()} + onOpenWindow={() => onOpenWindow?.()} + onModeChange={setViewMode} + outlineButton={
-
-
- {onUseTemplate ? ( + } + actions={ + <> + {saveStatus ? ( + + {saveStatus} + + ) : null} + {onUseTemplate ? ( + + ) : null} - ) : null} - - - -
+ + + + } + /> +
+
{/* Metadata row: Notebook, Status, Tags */} {onMoveToNotebook && onStatusChange && ( diff --git a/apps/desktop/src/renderer/components/NoteList.module.css b/apps/desktop/src/renderer/components/NoteList.module.css index 64b28ac9..a0677515 100644 --- a/apps/desktop/src/renderer/components/NoteList.module.css +++ b/apps/desktop/src/renderer/components/NoteList.module.css @@ -24,6 +24,7 @@ align-items: center; gap: 6px; padding: 8px 10px; + min-height: 44px; border-bottom: 1px solid var(--border-subtle); -webkit-app-region: drag; } diff --git a/apps/desktop/src/renderer/components/editor/EditorChrome.tsx b/apps/desktop/src/renderer/components/editor/EditorChrome.tsx new file mode 100644 index 00000000..52801141 --- /dev/null +++ b/apps/desktop/src/renderer/components/editor/EditorChrome.tsx @@ -0,0 +1,66 @@ +import type { ReactNode } from 'react'; +import { ArrowLeft, ArrowRight, Maximize2, Minimize2, SquareArrowOutUpRight } from 'lucide-react'; +import type { EditorViewMode } from '../../stores/editorPreferencesStore'; +import { IconButton } from '../../ui/primitives'; +import { sc } from '../noteEditorSc'; +import { EditorViewToggle } from './EditorViewToggle'; + +interface EditorChromeProps { + canBack: boolean; + canForward: boolean; + distractionFree: boolean; + viewMode: EditorViewMode; + onBack: () => void; + onForward: () => void; + onToggleZen: () => void; + onOpenWindow: () => void; + onModeChange: (mode: EditorViewMode) => void; + outlineButton: ReactNode; + actions: ReactNode; +} + +export function EditorChrome({ + canBack, + canForward, + distractionFree, + viewMode, + onBack, + onForward, + onToggleZen, + onOpenWindow, + onModeChange, + outlineButton, + actions, +}: EditorChromeProps) { + return ( +
+
+ + + + {distractionFree ? ( + + + + + +
+
+ + {outlineButton} +
+
{actions}
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/editor/index.ts b/apps/desktop/src/renderer/components/editor/index.ts index 0147f68c..5da7f51b 100644 --- a/apps/desktop/src/renderer/components/editor/index.ts +++ b/apps/desktop/src/renderer/components/editor/index.ts @@ -1,6 +1,7 @@ export { ActionsPanel } from './ActionsPanel'; export { EditorHeader } from './EditorHeader'; export { EditorViewToggle } from './EditorViewToggle'; +export { EditorChrome } from './EditorChrome'; export { OutlinePanel } from './OutlinePanel'; export { UrlPastePicker } from './UrlPastePicker'; export { FormattingToolbar } from './FormattingToolbar'; diff --git a/apps/desktop/src/renderer/hooks/useAppCommands.ts b/apps/desktop/src/renderer/hooks/useAppCommands.ts index 5d0cf65f..c017cc14 100644 --- a/apps/desktop/src/renderer/hooks/useAppCommands.ts +++ b/apps/desktop/src/renderer/hooks/useAppCommands.ts @@ -38,6 +38,10 @@ interface UseAppCommandsOptions { setSelectedNote: (note: NoteSnapshot | null) => void; displayedNotes: ReadonlyArray<{ id: string }>; onSelectNote: (id: string) => void; + onNoteBack: () => void; + onNoteForward: () => void; + onToggleZen: () => void; + onOpenInWindow: () => void; } export function useAppCommands({ @@ -54,6 +58,10 @@ export function useAppCommands({ setSelectedNote, displayedNotes, onSelectNote, + onNoteBack, + onNoteForward, + onToggleZen, + onOpenInWindow, }: UseAppCommandsOptions) { const cycleViewMode = useEditorPreferencesStore(state => state.cycleViewMode); const togglePreview = useEditorPreferencesStore(state => state.togglePreview); @@ -150,6 +158,10 @@ export function useAppCommands({ onReloadPlugins: useCallback(() => { window.dripnex.plugins.requestReload(); }, []), + onNoteBack, + onNoteForward, + onToggleZen, + onOpenInWindow, }); // AI panel toggle/modes diff --git a/apps/desktop/src/renderer/hooks/useRegisterAppCommands.ts b/apps/desktop/src/renderer/hooks/useRegisterAppCommands.ts index 6e828570..7df66c3e 100644 --- a/apps/desktop/src/renderer/hooks/useRegisterAppCommands.ts +++ b/apps/desktop/src/renderer/hooks/useRegisterAppCommands.ts @@ -25,6 +25,10 @@ interface AppCommandHandlers { onOpenUserStyles: () => void; onOpenKeymap: () => void; onReloadPlugins: () => void; + onNoteBack: () => void; + onNoteForward: () => void; + onToggleZen: () => void; + onOpenInWindow: () => void; } /** @@ -59,6 +63,10 @@ export function useRegisterAppCommands(handlers: AppCommandHandlers): void { 'app:open-user-styles': () => handlersRef.current.onOpenUserStyles(), 'app:open-keymap': () => handlersRef.current.onOpenKeymap(), 'app:reload-plugins': () => handlersRef.current.onReloadPlugins(), + 'app:note-back': () => handlersRef.current.onNoteBack(), + 'app:note-forward': () => handlersRef.current.onNoteForward(), + 'app:toggle-zen': () => handlersRef.current.onToggleZen(), + 'app:open-in-window': () => handlersRef.current.onOpenInWindow(), }; const unregisters: Array<() => void> = []; diff --git a/apps/desktop/src/renderer/hooks/useResizableLayout.ts b/apps/desktop/src/renderer/hooks/useResizableLayout.ts index 70d1b01b..8ea62432 100644 --- a/apps/desktop/src/renderer/hooks/useResizableLayout.ts +++ b/apps/desktop/src/renderer/hooks/useResizableLayout.ts @@ -6,12 +6,14 @@ interface LayoutState { sidebarWidth: number; notelistWidth: number; sidebarCollapsed: boolean; + distractionFree: boolean; } const DEFAULT_LAYOUT: LayoutState = { sidebarWidth: 220, notelistWidth: 280, sidebarCollapsed: false, + distractionFree: false, }; const MIN_SIDEBAR = 200; @@ -40,6 +42,7 @@ export function useResizableLayout() { MAX_NOTELIST ), sidebarCollapsed: Boolean(parsed.sidebarCollapsed), + distractionFree: Boolean(parsed.distractionFree), }; } } catch { @@ -121,11 +124,19 @@ export function useResizableLayout() { setLayout(prev => ({ ...prev, sidebarCollapsed: !prev.sidebarCollapsed })); }, []); + const toggleDistractionFree = useCallback(() => { + setLayout(prev => ({ ...prev, distractionFree: !prev.distractionFree })); + }, []); + + const hideChrome = layout.distractionFree; + return { - sidebarWidth: layout.sidebarCollapsed ? 0 : layout.sidebarWidth, - notelistWidth: layout.notelistWidth, + sidebarWidth: hideChrome || layout.sidebarCollapsed ? 0 : layout.sidebarWidth, + notelistWidth: hideChrome ? 0 : layout.notelistWidth, sidebarCollapsed: layout.sidebarCollapsed, + distractionFree: layout.distractionFree, toggleSidebar, + toggleDistractionFree, startResizeSidebar, startResizeNotelist, }; diff --git a/apps/desktop/src/renderer/styles/global.css b/apps/desktop/src/renderer/styles/global.css index 8dd876a2..eb829082 100644 --- a/apps/desktop/src/renderer/styles/global.css +++ b/apps/desktop/src/renderer/styles/global.css @@ -71,6 +71,11 @@ body { .app__notelist { flex-shrink: 0; overflow: hidden; + transition: width var(--transition-normal); +} + +.app__notelist[data-collapsed='true'] { + pointer-events: none; } .app__editor { diff --git a/apps/desktop/src/renderer/utils/__tests__/noteHistory.test.ts b/apps/desktop/src/renderer/utils/__tests__/noteHistory.test.ts new file mode 100644 index 00000000..54c37e3c --- /dev/null +++ b/apps/desktop/src/renderer/utils/__tests__/noteHistory.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { + canGoBack, + canGoForward, + emptyNoteHistory, + historyBack, + historyForward, + visitNote, +} from '../noteHistory'; + +describe('noteHistory', () => { + it('visits and walks back and forward', () => { + let state = emptyNoteHistory(); + state = visitNote(state, 'a'); + state = visitNote(state, 'b'); + state = visitNote(state, 'c'); + + expect(canGoBack(state)).toBe(true); + expect(canGoForward(state)).toBe(false); + + const back = historyBack(state); + expect(back.id).toBe('b'); + expect(canGoForward(back.state)).toBe(true); + + const fwd = historyForward(back.state); + expect(fwd.id).toBe('c'); + }); + + it('does not record the same note twice in a row', () => { + let state = visitNote(emptyNoteHistory(), 'a'); + state = visitNote(state, 'a'); + expect(state.stack).toEqual(['a']); + }); + + it('drops forward entries after a new visit', () => { + let state = visitNote(emptyNoteHistory(), 'a'); + state = visitNote(state, 'b'); + state = historyBack(state).state; + state = visitNote(state, 'c'); + expect(state.stack).toEqual(['a', 'c']); + expect(canGoForward(state)).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/utils/noteHistory.ts b/apps/desktop/src/renderer/utils/noteHistory.ts new file mode 100644 index 00000000..93c47a22 --- /dev/null +++ b/apps/desktop/src/renderer/utils/noteHistory.ts @@ -0,0 +1,43 @@ +export interface NoteHistoryState { + stack: string[]; + index: number; +} + +export const emptyNoteHistory = (): NoteHistoryState => ({ stack: [], index: -1 }); + +const MAX = 50; + +export function visitNote(state: NoteHistoryState, id: string): NoteHistoryState { + if (state.stack[state.index] === id) return state; + const trimmed = state.stack.slice(0, state.index + 1); + const stack = [...trimmed, id].slice(-MAX); + return { stack, index: stack.length - 1 }; +} + +export function historyBack(state: NoteHistoryState): { + state: NoteHistoryState; + id: string | null; +} { + if (state.index <= 0) return { state, id: null }; + const index = state.index - 1; + return { state: { ...state, index }, id: state.stack[index] ?? null }; +} + +export function historyForward(state: NoteHistoryState): { + state: NoteHistoryState; + id: string | null; +} { + if (state.index < 0 || state.index >= state.stack.length - 1) { + return { state, id: null }; + } + const index = state.index + 1; + return { state: { ...state, index }, id: state.stack[index] ?? null }; +} + +export function canGoBack(state: NoteHistoryState): boolean { + return state.index > 0; +} + +export function canGoForward(state: NoteHistoryState): boolean { + return state.index >= 0 && state.index < state.stack.length - 1; +} diff --git a/docs/NOW.md b/docs/NOW.md index 9cfc2614..f752302d 100644 --- a/docs/NOW.md +++ b/docs/NOW.md @@ -33,6 +33,7 @@ Shipped: - Command palette Quick Open. - Sync conflicts: one screen, keep this device / keep other / open both. - Chrome: IconButton, sidebar collapse animates, Sync History stays inside the card. +- Editor chrome: back/forward, distraction-free, open in window. - Emergency Kit + 1Password integration (`Settings → Integrations`). - Dev renderer waits for Vite (no `chrome-error` hop). diff --git a/packages/command-registry/src/definitions/app.ts b/packages/command-registry/src/definitions/app.ts index c41913e3..92eb4eb8 100644 --- a/packages/command-registry/src/definitions/app.ts +++ b/packages/command-registry/src/definitions/app.ts @@ -89,6 +89,40 @@ export const appCommands: CommandDefinition[] = [ icon: 'Save', showInPalette: true, }, + { + id: 'app:note-back', + name: 'Back', + category: 'navigation', + context: 'app', + defaultKeybinding: { key: '[', modifiers: ['Mod'] }, + icon: 'ArrowLeft', + showInPalette: true, + }, + { + id: 'app:note-forward', + name: 'Forward', + category: 'navigation', + context: 'app', + defaultKeybinding: { key: ']', modifiers: ['Mod'] }, + icon: 'ArrowRight', + showInPalette: true, + }, + { + id: 'app:toggle-zen', + name: 'Toggle Distraction Free Mode', + category: 'view', + context: 'app', + icon: 'Maximize2', + showInPalette: true, + }, + { + id: 'app:open-in-window', + name: 'Open in New Window', + category: 'note', + context: 'app', + icon: 'SquareArrowOutUpRight', + showInPalette: true, + }, { id: 'app:toggle-outline', name: 'Toggle Outline', From 276ce66418c8f177e02133e5096bdbb4d12947cd Mon Sep 17 00:00:00 2001 From: Tomas Maritano Date: Wed, 19 Aug 2026 01:39:08 -0300 Subject: [PATCH 02/14] feat(desktop): round the sidebar and fix note windows Sidebar is a padded card with a Detail control for workspace view. Detached note windows get a drag strip and skip the main-chrome cluster. Motion is a plugin that scales CSS tokens; custom keyframes stay in styles.css. --- apps/desktop/src/main/userHackFiles.ts | 11 +++- .../renderer/components/NoteEditor.module.css | 5 ++ .../src/renderer/components/NoteEditor.tsx | 3 ++ .../src/renderer/components/NoteWindow.css | 17 +++++- .../src/renderer/components/NoteWindow.tsx | 1 + .../components/editor/EditorChrome.tsx | 50 +++++++++-------- .../components/sidebar/NotebookItem.tsx | 24 +++++++++ .../components/sidebar/NotebookList.tsx | 3 ++ .../renderer/components/sidebar/Sidebar.tsx | 54 ++++++++++++------- .../components/sidebar/sidebar.module.css | 47 +++++++++++++++- apps/desktop/src/renderer/plugins/index.ts | 3 ++ apps/desktop/src/renderer/plugins/motion.ts | 50 +++++++++++++++++ apps/desktop/src/renderer/styles/global.css | 5 +- docs/NOW.md | 1 + 14 files changed, 229 insertions(+), 45 deletions(-) create mode 100644 apps/desktop/src/renderer/plugins/motion.ts diff --git a/apps/desktop/src/main/userHackFiles.ts b/apps/desktop/src/main/userHackFiles.ts index 75c2c411..f74f5e4c 100644 --- a/apps/desktop/src/main/userHackFiles.ts +++ b/apps/desktop/src/main/userHackFiles.ts @@ -45,12 +45,21 @@ export const STYLES_CSS_TEMPLATE = `/* Dripnex user stylesheet * Accent: --accent --accent-muted * Borders: --border --border-subtle * - * Example: + * Motion (no JS library). Durations: + * --transition-fast --transition-normal --transition-slow + * Settings → Plugins → Motion scales those. Or override here: * * :root { * --accent: #f59e0b; + * --transition-normal: 280ms cubic-bezier(0.22, 1, 0.36, 1); * } * + * @keyframes dripnex-fade { + * from { opacity: 0; } + * to { opacity: 1; } + * } + * .app__sidebar { animation: dripnex-fade 200ms ease; } + * * .note-list-item[data-selected="true"] { * background: color-mix(in srgb, var(--accent) 18%, transparent); * } diff --git a/apps/desktop/src/renderer/components/NoteEditor.module.css b/apps/desktop/src/renderer/components/NoteEditor.module.css index c38540d1..0f73f2fd 100644 --- a/apps/desktop/src/renderer/components/NoteEditor.module.css +++ b/apps/desktop/src/renderer/components/NoteEditor.module.css @@ -78,9 +78,14 @@ display: flex; align-items: center; gap: 2px; + min-width: 0; -webkit-app-region: no-drag; } +.note-editor-chrome-drag { + display: none; +} + .note-editor-header { display: flex; align-items: center; diff --git a/apps/desktop/src/renderer/components/NoteEditor.tsx b/apps/desktop/src/renderer/components/NoteEditor.tsx index 7364d034..61d07a04 100644 --- a/apps/desktop/src/renderer/components/NoteEditor.tsx +++ b/apps/desktop/src/renderer/components/NoteEditor.tsx @@ -70,6 +70,7 @@ interface NoteEditorProps { onForward?: () => void; onToggleZen?: () => void; onOpenWindow?: () => void; + chromeVariant?: 'main' | 'window'; } export function NoteEditor({ @@ -94,6 +95,7 @@ export function NoteEditor({ onForward, onToggleZen, onOpenWindow, + chromeVariant = 'main', }: NoteEditorProps) { const { showToast } = useToast(); const debounceRef = useRef(null); @@ -357,6 +359,7 @@ export function NoteEditor({ return (
); diff --git a/apps/desktop/src/renderer/components/editor/EditorChrome.tsx b/apps/desktop/src/renderer/components/editor/EditorChrome.tsx index 52801141..c2976118 100644 --- a/apps/desktop/src/renderer/components/editor/EditorChrome.tsx +++ b/apps/desktop/src/renderer/components/editor/EditorChrome.tsx @@ -6,6 +6,7 @@ import { sc } from '../noteEditorSc'; import { EditorViewToggle } from './EditorViewToggle'; interface EditorChromeProps { + variant?: 'main' | 'window'; canBack: boolean; canForward: boolean; distractionFree: boolean; @@ -20,6 +21,7 @@ interface EditorChromeProps { } export function EditorChrome({ + variant = 'main', canBack, canForward, distractionFree, @@ -33,28 +35,34 @@ export function EditorChrome({ actions, }: EditorChromeProps) { return ( -
+
- - - - {distractionFree ? ( - - - - - + {variant === 'main' ? ( + <> + + + + {distractionFree ? ( + + + + + + + ) : ( +
diff --git a/apps/desktop/src/renderer/components/sidebar/NotebookItem.tsx b/apps/desktop/src/renderer/components/sidebar/NotebookItem.tsx index de6215f0..93b9398e 100644 --- a/apps/desktop/src/renderer/components/sidebar/NotebookItem.tsx +++ b/apps/desktop/src/renderer/components/sidebar/NotebookItem.tsx @@ -12,6 +12,7 @@ import { } from 'lucide-react'; import type { NotebookTreeNode } from '../../../preload/index'; import { useNotebookExpandStore } from '../../stores/notebookExpandStore'; +import { useWorkspaceRootId } from '../../hooks/useNavigation'; import { CommitHistory } from '../git/CommitHistory'; import { sc } from './sc'; @@ -26,6 +27,7 @@ interface NotebookItemProps { readonly ancestorIds: Set; readonly selectedNotebookId: string | null; readonly onSelect: (id: string) => void; + readonly onEnterWorkspace?: (id: string) => void; readonly onRename: (id: string, name: string) => void; readonly onDelete: (id: string) => void; readonly onCreateChild: (parentId: string) => void; @@ -56,6 +58,7 @@ export const NotebookItem = memo(function NotebookItem({ ancestorIds, selectedNotebookId, onSelect, + onEnterWorkspace, onRename, onDelete, onCreateChild, @@ -63,6 +66,7 @@ export const NotebookItem = memo(function NotebookItem({ onReorder, siblingIds, }: NotebookItemProps) { + const workspaceRootId = useWorkspaceRootId(); const isExpanded = useNotebookExpandStore(s => !s.collapsedIds.includes(node.notebook.id)); const toggleExpanded = useNotebookExpandStore(s => s.toggle); const expandNotebook = useNotebookExpandStore(s => s.expand); @@ -106,6 +110,14 @@ export const NotebookItem = memo(function NotebookItem({ [node.notebook.id, onSelect] ); + const handleDetail = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onEnterWorkspace?.(node.notebook.id); + }, + [node.notebook.id, onEnterWorkspace] + ); + const handleToggle = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); @@ -412,6 +424,17 @@ export const NotebookItem = memo(function NotebookItem({ )} + {onEnterWorkspace && workspaceRootId !== node.notebook.id ? ( + + ) : null} + {!isInbox && (
+
+ + +
)} @@ -263,6 +276,7 @@ export function Sidebar({ onOpenGraph }: SidebarProps) { ('scale') ?? 1); + const unobserve = context.config.observe('scale', value => { + applyScale(Number(value)); + }); + return { + dispose() { + unobserve(); + document.getElementById(STYLE_ID)?.remove(); + }, + }; + }, +}; diff --git a/apps/desktop/src/renderer/styles/global.css b/apps/desktop/src/renderer/styles/global.css index eb829082..ff8af2cb 100644 --- a/apps/desktop/src/renderer/styles/global.css +++ b/apps/desktop/src/renderer/styles/global.css @@ -61,10 +61,13 @@ body { .app__sidebar { flex-shrink: 0; overflow: hidden; - transition: width var(--transition-normal); + padding: 8px 4px 8px 8px; + background: transparent; + transition: width var(--transition-normal), padding var(--transition-normal); } .app__sidebar[data-collapsed='true'] { + padding: 8px 0; pointer-events: none; } diff --git a/docs/NOW.md b/docs/NOW.md index f752302d..1ae4486e 100644 --- a/docs/NOW.md +++ b/docs/NOW.md @@ -34,6 +34,7 @@ Shipped: - Sync conflicts: one screen, keep this device / keep other / open both. - Chrome: IconButton, sidebar collapse animates, Sync History stays inside the card. - Editor chrome: back/forward, distraction-free, open in window. +- Sidebar is a rounded card. Detail opens workspace. Motion plugin scales CSS tokens. - Emergency Kit + 1Password integration (`Settings → Integrations`). - Dev renderer waits for Vite (no `chrome-error` hop). From 1cd2c12477b67b129dec27a4fa55d96f4f241415 Mon Sep 17 00:00:00 2001 From: Tomas Maritano Date: Wed, 19 Aug 2026 01:41:59 -0300 Subject: [PATCH 03/14] fix(desktop): keep the sidebar flush The padded card fought the window chrome. Flush column, filled selection, Detail still on hover. --- .../components/sidebar/sidebar.module.css | 41 ++++++++----------- apps/desktop/src/renderer/styles/global.css | 5 +-- 2 files changed, 17 insertions(+), 29 deletions(-) diff --git a/apps/desktop/src/renderer/components/sidebar/sidebar.module.css b/apps/desktop/src/renderer/components/sidebar/sidebar.module.css index d4627ea2..1e681b49 100644 --- a/apps/desktop/src/renderer/components/sidebar/sidebar.module.css +++ b/apps/desktop/src/renderer/components/sidebar/sidebar.module.css @@ -12,8 +12,6 @@ padding-top: 0; -webkit-app-region: drag; overflow: hidden; - border-radius: var(--radius-xl); - border: 1px solid var(--border-subtle); } .sidebar-content { @@ -21,7 +19,7 @@ overflow-y: auto; overflow-x: hidden; -webkit-app-region: no-drag; - padding: 12px 10px 22px; + padding: 8px 10px 16px; } .sidebar-content::-webkit-scrollbar { @@ -394,7 +392,7 @@ display: flex; align-items: center; justify-content: flex-end; - padding: 8px 8px 0 64px; + padding: 8px 8px 0 72px; height: 36px; -webkit-app-region: drag; flex-shrink: 0; @@ -436,11 +434,11 @@ align-items: center; gap: 8px; width: 100%; - min-height: 28px; - padding: 7px 10px; + min-height: 32px; + padding: 6px 10px; background: transparent; border: none; - border-radius: 6px; + border-radius: 8px; color: var(--text-primary); font-size: 13px; line-height: 1.2; @@ -455,7 +453,6 @@ .sidebar-row.selected { background: color-mix(in srgb, var(--text-primary) 10%, transparent); color: var(--text-primary); - box-shadow: inset 2px 0 0 var(--accent); } .sidebar-row-icon { @@ -672,35 +669,29 @@ display: flex; align-items: center; gap: 6px; - padding: 4px 8px; - margin: 0 4px; - border-radius: var(--radius-sm); + padding: 6px 10px; + margin: 0; + border-radius: 8px; cursor: pointer; - transition: all var(--transition-fast); + transition: background var(--transition-fast); } .notebook-item-row:hover { - background: var(--bg-elevated); + background: color-mix(in srgb, var(--text-primary) 6%, transparent); } .notebook-item-row.selected { background: color-mix(in srgb, var(--text-primary) 10%, transparent); - box-shadow: inset 2px 0 0 var(--accent); } -/* Breadcrumb path highlighting - subtle style for ancestors */ .notebook-item-row.in-path { - background: rgba(94, 234, 212, 0.06); -} - -.notebook-item-row.in-path .notebook-item-icon { - color: var(--accent); - opacity: 0.7; + background: transparent; } +.notebook-item-row.in-path .notebook-item-icon, .notebook-item-row.in-path .notebook-item-name { - color: var(--accent); - opacity: 0.8; + color: var(--text-secondary); + opacity: 1; } /* Drag handle — hidden by default, shown on hover */ @@ -783,7 +774,7 @@ } .notebook-item-row.selected .notebook-item-icon { - color: var(--accent); + color: var(--text-primary); } .notebook-item-name { @@ -796,7 +787,7 @@ } .notebook-item-row.selected .notebook-item-name { - color: var(--accent); + color: var(--text-primary); } .notebook-item-count { diff --git a/apps/desktop/src/renderer/styles/global.css b/apps/desktop/src/renderer/styles/global.css index ff8af2cb..eb829082 100644 --- a/apps/desktop/src/renderer/styles/global.css +++ b/apps/desktop/src/renderer/styles/global.css @@ -61,13 +61,10 @@ body { .app__sidebar { flex-shrink: 0; overflow: hidden; - padding: 8px 4px 8px 8px; - background: transparent; - transition: width var(--transition-normal), padding var(--transition-normal); + transition: width var(--transition-normal); } .app__sidebar[data-collapsed='true'] { - padding: 8px 0; pointer-events: none; } From f1bc79d708bf42411efc28042b7d337bcc6cc7b8 Mon Sep 17 00:00:00 2001 From: Tomas Maritano Date: Wed, 19 Aug 2026 02:01:24 -0300 Subject: [PATCH 04/14] fix(desktop): pull sidebar rows to the edge Inbox sat inside stacked padding. Root notebooks now share the same inset as All Notes. --- .../renderer/components/sidebar/NotebookItem.tsx | 2 +- .../components/sidebar/sidebar.module.css | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/renderer/components/sidebar/NotebookItem.tsx b/apps/desktop/src/renderer/components/sidebar/NotebookItem.tsx index 93b9398e..fb9a9af0 100644 --- a/apps/desktop/src/renderer/components/sidebar/NotebookItem.tsx +++ b/apps/desktop/src/renderer/components/sidebar/NotebookItem.tsx @@ -346,7 +346,7 @@ export const NotebookItem = memo(function NotebookItem({ dropPosition && `drop-${dropPosition}`, isDragging && 'dragging' )} - style={{ paddingLeft: `${depth * 16 + 8}px` }} + style={depth > 0 ? { paddingLeft: `${depth * 14 + 8}px` } : undefined} onClick={handleClick} onDoubleClick={handleDoubleClick} role="button" diff --git a/apps/desktop/src/renderer/components/sidebar/sidebar.module.css b/apps/desktop/src/renderer/components/sidebar/sidebar.module.css index 1e681b49..c52ef2fc 100644 --- a/apps/desktop/src/renderer/components/sidebar/sidebar.module.css +++ b/apps/desktop/src/renderer/components/sidebar/sidebar.module.css @@ -19,7 +19,7 @@ overflow-y: auto; overflow-x: hidden; -webkit-app-region: no-drag; - padding: 8px 10px 16px; + padding: 4px 4px 12px; } .sidebar-content::-webkit-scrollbar { @@ -422,7 +422,7 @@ /* Quick Filters */ .sidebar-quick-filters { - padding: 6px 4px 2px; + padding: 2px 0; display: flex; flex-direction: column; gap: 2px; @@ -435,7 +435,7 @@ gap: 8px; width: 100%; min-height: 32px; - padding: 6px 10px; + padding: 6px 8px; background: transparent; border: none; border-radius: 8px; @@ -499,11 +499,11 @@ } .sidebar-templates { - padding: 2px 4px 0; + padding: 0; } .sidebar-trash { - margin: 22px 4px 8px; + margin: 18px 0 4px; } /* Sidebar Section */ @@ -517,7 +517,7 @@ align-items: center; justify-content: space-between; gap: 6px; - padding: 0 10px 4px; + padding: 0 8px 4px; min-height: 22px; } @@ -619,7 +619,7 @@ /* Notebook List */ .notebook-list { - padding: 4px 0; + padding: 0; } .notebook-list-tree { @@ -669,7 +669,7 @@ display: flex; align-items: center; gap: 6px; - padding: 6px 10px; + padding: 6px 8px; margin: 0; border-radius: 8px; cursor: pointer; From affa58cfefcb957821224276a8e5ce8fb55b8bdf Mon Sep 17 00:00:00 2001 From: Tomas Maritano Date: Wed, 19 Aug 2026 02:06:11 -0300 Subject: [PATCH 05/14] fix(desktop): drop search operator chrome Operators still work. The list does not need to advertise them. --- apps/desktop/src/renderer/components/NoteList.module.css | 8 -------- apps/desktop/src/renderer/components/NoteList.tsx | 5 +---- apps/desktop/src/renderer/utils/parseNoteSearch.ts | 3 --- 3 files changed, 1 insertion(+), 15 deletions(-) diff --git a/apps/desktop/src/renderer/components/NoteList.module.css b/apps/desktop/src/renderer/components/NoteList.module.css index a0677515..8895df04 100644 --- a/apps/desktop/src/renderer/components/NoteList.module.css +++ b/apps/desktop/src/renderer/components/NoteList.module.css @@ -167,14 +167,6 @@ color: var(--text-primary); } -.search-hint { - margin: 6px 2px 0; - font-size: 11px; - line-height: 1.35; - color: var(--text-faint); - user-select: none; -} - /* ============================================================================ Note List Content ============================================================================ */ diff --git a/apps/desktop/src/renderer/components/NoteList.tsx b/apps/desktop/src/renderer/components/NoteList.tsx index ddd9282a..9c1186f4 100644 --- a/apps/desktop/src/renderer/components/NoteList.tsx +++ b/apps/desktop/src/renderer/components/NoteList.tsx @@ -284,7 +284,7 @@ export function NoteList({ )} -

- tag:work · status:active · notebook:inbox · is:pinned · is:trash -

{/* Note list content */} diff --git a/apps/desktop/src/renderer/utils/parseNoteSearch.ts b/apps/desktop/src/renderer/utils/parseNoteSearch.ts index 4ab2b7a1..1ef4c8e0 100644 --- a/apps/desktop/src/renderer/utils/parseNoteSearch.ts +++ b/apps/desktop/src/renderer/utils/parseNoteSearch.ts @@ -24,9 +24,6 @@ const STATUS_ALIASES: Record = { const OPERATOR = /(?:^|\s)(?:(tag):([^\s]+)|#([^\s]+)|status:([^\s]+)|(?:notebook|in):([^\s]+)|is:(pinned|trash|deleted|archived))/gi; -export const SEARCH_OPERATOR_HINT = - 'tag:work · status:active · notebook:inbox · is:pinned · is:trash'; - export function parseNoteSearch(raw: string): ParsedNoteSearch { const tags: string[] = []; let status: NoteStatus | null = null; From a6f5b4e8ddd3151c89778eca3099bae9566d71c7 Mon Sep 17 00:00:00 2001 From: Tomas Maritano Date: Wed, 19 Aug 2026 02:09:54 -0300 Subject: [PATCH 06/14] fix(desktop): drop sidebar hover fill, call search Filter Hover already reveals Detail. The list field is a filter. --- apps/desktop/src/renderer/components/NoteList.tsx | 9 +++++---- .../src/renderer/components/sidebar/sidebar.module.css | 10 +--------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/renderer/components/NoteList.tsx b/apps/desktop/src/renderer/components/NoteList.tsx index 9c1186f4..c92f113f 100644 --- a/apps/desktop/src/renderer/components/NoteList.tsx +++ b/apps/desktop/src/renderer/components/NoteList.tsx @@ -3,6 +3,7 @@ import { Sparkles, Archive, Search, + Filter, X, SquarePen, FileStack, @@ -277,14 +278,14 @@ export function NoteList({ {/* Search bar with icon + filter toggle */}
-
diff --git a/apps/desktop/src/renderer/components/sidebar/sidebar.module.css b/apps/desktop/src/renderer/components/sidebar/sidebar.module.css index f4c51198..3299e775 100644 --- a/apps/desktop/src/renderer/components/sidebar/sidebar.module.css +++ b/apps/desktop/src/renderer/components/sidebar/sidebar.module.css @@ -805,7 +805,8 @@ display: none; align-items: center; margin-left: auto; - padding: 1px 7px; + gap: 2px; + padding: 1px 5px 1px 7px; border: 1px solid transparent; border-radius: 999px; background: var(--bg-elevated); @@ -821,6 +822,12 @@ border-color var(--transition-fast); } +.notebook-item-detail-chevron, +.sidebar-row-detail-chevron { + flex-shrink: 0; + transition: transform var(--transition-fast); +} + .notebook-item-row:hover .notebook-item-detail, .sidebar-row:hover .sidebar-row-detail { display: inline-flex; @@ -833,6 +840,11 @@ color: var(--bg-base); } +.notebook-item-detail:hover .notebook-item-detail-chevron, +.sidebar-row-detail:hover .sidebar-row-detail-chevron { + transform: translateX(2px); +} + .notebook-item-actions { display: flex; gap: 2px; From ccf42723145078a2476b2ad19480236767fc9c61 Mon Sep 17 00:00:00 2001 From: Tomas Maritano Date: Wed, 19 Aug 2026 02:18:25 -0300 Subject: [PATCH 10/14] fix(desktop): put Inbox on the same icon column Leaf notebooks no longer reserve a chevron gutter. Toggle and grip overlay the 16px icon slot so All Notes and Inbox line up. --- .../components/sidebar/NotebookItem.tsx | 60 +++++++++---------- .../components/sidebar/sidebar.module.css | 60 ++++++++++--------- 2 files changed, 62 insertions(+), 58 deletions(-) diff --git a/apps/desktop/src/renderer/components/sidebar/NotebookItem.tsx b/apps/desktop/src/renderer/components/sidebar/NotebookItem.tsx index 4c8c976c..b1c57d2b 100644 --- a/apps/desktop/src/renderer/components/sidebar/NotebookItem.tsx +++ b/apps/desktop/src/renderer/components/sidebar/NotebookItem.tsx @@ -359,37 +359,35 @@ export const NotebookItem = memo(function NotebookItem({ onDragLeave={handleDragLeave} onDrop={handleDrop} > - {/* Drag handle — only visible on hover, enables dragging */} - {!isInbox && !isEditing && ( -
{hasChildren && isExpanded && ( @@ -512,6 +471,64 @@ export const NotebookItem = memo(function NotebookItem({ )} + {menu + ? createPortal( +
event.stopPropagation()} + > + {canHaveChildren ? ( + + ) : null} + + {isGitEnabled ? ( + + ) : null} + +
, + document.body + ) + : null} + {showCommitHistory && ( Date: Wed, 19 Aug 2026 11:47:24 -0300 Subject: [PATCH 13/14] feat(desktop): close plugin and export gaps Ship remaining Inkdrop P0/P1 plus notebook/tag plugin writes and durable HTTP _changes. History nav loads the note before moving the cursor; Detail stays keyboard-reachable. --- apps/desktop/electron.vite.config.ts | 4 + apps/desktop/package.json | 1 + .../desktop/src/main/handlers/dataHandlers.ts | 75 +++- .../src/main/handlers/localServerHandlers.ts | 131 ++++++- .../src/main/handlers/notebookHandlers.ts | 17 + apps/desktop/src/main/index.ts | 21 +- apps/desktop/src/main/network/navigation.ts | 10 + apps/desktop/src/main/pluginScanner.ts | 28 ++ .../plugins/__tests__/pluginScanner.test.ts | 40 +++ .../main/services/__tests__/changeLog.test.ts | 40 +++ apps/desktop/src/main/services/localServer.ts | 208 +++++++++++ apps/desktop/src/main/services/printNote.ts | 67 ++++ .../src/main/services/sync/SyncService.ts | 4 + .../main/windows/__tests__/deepLink.test.ts | 37 ++ .../src/main/windows/applicationMenu.ts | 42 +++ apps/desktop/src/main/windows/authDeepLink.ts | 78 +++-- apps/desktop/src/main/windows/deepLink.ts | 48 +++ apps/desktop/src/preload/api/data.ts | 9 + apps/desktop/src/preload/api/notebooks.ts | 2 + apps/desktop/src/preload/api/types.ts | 3 + apps/desktop/src/renderer/App.tsx | 46 ++- .../ColorPicker/ColorPickerModal.module.css | 22 +- .../ColorPicker/ColorPickerModal.tsx | 34 +- .../renderer/components/ColorPicker/index.ts | 1 + .../renderer/components/MarkdownEditor.tsx | 18 +- .../src/renderer/components/NoteEditor.tsx | 86 ++++- .../renderer/components/NoteList.module.css | 50 +++ .../src/renderer/components/NoteList.tsx | 36 +- .../NoteListContextMenu.tsx | 24 ++ .../editor/ActionsPanel/ActionsPanel.tsx | 2 +- .../editor/MarkdownPreview.module.css | 48 ++- .../components/editor/MarkdownPreview.tsx | 182 ++++++---- .../components/editor/OutlinePanel.module.css | 9 +- .../components/editor/OutlinePanel.tsx | 26 +- .../components/sidebar/NotebookIconPicker.tsx | 88 +++++ .../components/sidebar/NotebookItem.tsx | 65 +++- .../components/sidebar/NotebookList.tsx | 11 +- .../renderer/components/sidebar/Sidebar.tsx | 1 + .../components/sidebar/StatusGlyph.tsx | 33 +- .../TagsContextMenu/TagsContextMenu.tsx | 24 ++ .../renderer/components/sidebar/TagsList.tsx | 58 ++-- .../components/sidebar/notebookIcons.ts | 176 ++++++++++ .../components/sidebar/sidebar.module.css | 116 ++++++- .../src/renderer/hooks/useAppCommands.ts | 9 + .../src/renderer/hooks/useCommandRegistry.ts | 6 +- .../src/renderer/hooks/useDeepLinks.ts | 65 +++- .../src/renderer/hooks/useMcpLocalPath.ts | 5 +- .../src/renderer/hooks/useNotebooks.ts | 12 + .../src/renderer/hooks/usePluginRuntime.tsx | 83 ++++- .../renderer/hooks/useRegisterAppCommands.ts | 11 +- .../lib/__tests__/remarkGithubAlert.test.ts | 61 ++++ .../src/renderer/lib/coreRemarkPlugins.ts | 3 +- .../src/renderer/lib/remarkGithubAlert.ts | 77 +++++ .../renderer/pages/settings/SettingsApp.tsx | 7 +- .../settings/sections/IntegrationsSection.tsx | 3 + .../pages/settings/sections/LocalHttpCard.tsx | 154 +++++++++ .../src/renderer/plugins/exportDocument.ts | 56 +++ .../src/renderer/plugins/exportMarkdown.ts | 80 ++++- apps/desktop/src/renderer/plugins/tables.tsx | 265 +++++++------- .../src/renderer/stores/pluginRuntimeStore.ts | 15 +- .../settings/__tests__/settingsStore.test.ts | 1 + .../src/renderer/stores/settings/schema.ts | 3 + .../utils/__tests__/notebookTree.test.ts | 1 + .../utils/__tests__/outlineActive.test.ts | 29 ++ .../utils/__tests__/parseDripnexUrl.test.ts | 12 + apps/desktop/src/renderer/utils/motion.ts | 5 + .../src/renderer/utils/outlineActive.ts | 22 ++ .../src/renderer/utils/parseDripnexUrl.ts | 52 +++ .../command-registry/src/definitions/app.ts | 8 + packages/command-registry/src/index.ts | 1 + packages/command-registry/src/keymap.ts | 3 +- packages/command-registry/src/registry.ts | 18 +- packages/command-registry/src/types.ts | 5 +- .../command-registry/tests/keymap.test.ts | 4 + .../command-registry/tests/registry.test.ts | 39 +++ packages/core/src/contracts/NoteSnapshot.ts | 8 + packages/core/src/domain/metadata.ts | 11 + packages/core/src/domain/note.ts | 7 + packages/core/src/domain/notebook.ts | 20 ++ packages/core/tests/metadata.test.ts | 10 + packages/core/tests/notebook.test.ts | 10 + packages/markdown/src/index.ts | 2 +- packages/markdown/src/scan.ts | 42 +++ packages/markdown/tests/scan.test.ts | 11 +- packages/plugin-api/src/data/createDataAPI.ts | 78 +++++ .../plugin-api/src/editor/createEditorAPI.ts | 12 + packages/plugin-api/src/index.ts | 36 +- packages/plugin-api/src/layout/LayoutZone.tsx | 7 +- packages/plugin-api/src/layout/types.ts | 5 + .../plugin-api/src/lifecycle/PluginHost.tsx | 28 +- .../src/lifecycle/PluginRegistry.ts | 95 ++++- .../plugin-api/src/loader/createInitApi.ts | 16 +- packages/plugin-api/src/loader/hostBridges.ts | 23 +- .../src/menu/pluginContextMenuStore.ts | 32 ++ .../packageFiles/applyPluginPackageFiles.ts | 86 +++++ .../plugin-api/src/packageFiles/chords.ts | 40 +++ .../src/packageFiles/parsePluginKeymap.ts | 94 +++++ .../src/packageFiles/parsePluginMenus.ts | 157 +++++++++ .../src/preview/previewEventStore.ts | 64 ++++ packages/plugin-api/src/types.ts | 35 +- .../plugin-api/tests/createDataAPI.test.ts | 35 ++ .../plugin-api/tests/createEditorAPI.test.ts | 5 + packages/plugin-api/tests/hostBridges.test.ts | 3 +- .../plugin-api/tests/packageFiles.test.ts | 129 +++++++ .../tests/previewEventStore.test.ts | 27 ++ packages/plugin-api/tests/registry.test.ts | 36 ++ packages/plugin-cli/src/commands/init.ts | 10 +- packages/plugin-cli/src/commands/pack.ts | 2 + packages/plugin-cli/src/templates/index.ts.ts | 12 +- .../src/templates/keymaps.default.json.ts | 9 + .../src/templates/menus.main.json.ts | 12 + packages/plugin-cli/tests/init.test.ts | 15 +- .../src/migrations/020_notebook_icons.ts | 13 + .../src/migrations/021_note_tasks.ts | 15 + .../storage-sqlite/src/migrations/index.ts | 6 + .../src/repositories/SQLiteNoteRepository.ts | 32 +- .../repositories/SQLiteNotebookRepository.ts | 34 +- .../src/repositories/noteMapping.ts | 10 + packages/tables/package.json | 26 ++ packages/tables/src/edit.ts | 324 ++++++++++++++++++ packages/tables/src/generate.ts | 23 ++ packages/tables/src/index.ts | 11 + packages/tables/src/parse.ts | 89 +++++ packages/tables/src/types.ts | 16 + packages/tables/tests/edit.test.ts | 57 +++ packages/tables/tests/parse.test.ts | 54 +++ packages/tables/tsconfig.json | 10 + packages/tables/vitest.config.ts | 10 + pnpm-lock.yaml | 12 + 129 files changed, 4638 insertions(+), 464 deletions(-) create mode 100644 apps/desktop/src/main/plugins/__tests__/pluginScanner.test.ts create mode 100644 apps/desktop/src/main/services/__tests__/changeLog.test.ts create mode 100644 apps/desktop/src/main/services/printNote.ts create mode 100644 apps/desktop/src/main/windows/__tests__/deepLink.test.ts create mode 100644 apps/desktop/src/main/windows/deepLink.ts create mode 100644 apps/desktop/src/renderer/components/sidebar/NotebookIconPicker.tsx create mode 100644 apps/desktop/src/renderer/components/sidebar/notebookIcons.ts create mode 100644 apps/desktop/src/renderer/lib/__tests__/remarkGithubAlert.test.ts create mode 100644 apps/desktop/src/renderer/lib/remarkGithubAlert.ts create mode 100644 apps/desktop/src/renderer/pages/settings/sections/LocalHttpCard.tsx create mode 100644 apps/desktop/src/renderer/plugins/exportDocument.ts create mode 100644 apps/desktop/src/renderer/utils/__tests__/outlineActive.test.ts create mode 100644 apps/desktop/src/renderer/utils/__tests__/parseDripnexUrl.test.ts create mode 100644 apps/desktop/src/renderer/utils/motion.ts create mode 100644 apps/desktop/src/renderer/utils/outlineActive.ts create mode 100644 apps/desktop/src/renderer/utils/parseDripnexUrl.ts create mode 100644 packages/plugin-api/src/menu/pluginContextMenuStore.ts create mode 100644 packages/plugin-api/src/packageFiles/applyPluginPackageFiles.ts create mode 100644 packages/plugin-api/src/packageFiles/chords.ts create mode 100644 packages/plugin-api/src/packageFiles/parsePluginKeymap.ts create mode 100644 packages/plugin-api/src/packageFiles/parsePluginMenus.ts create mode 100644 packages/plugin-api/src/preview/previewEventStore.ts create mode 100644 packages/plugin-api/tests/packageFiles.test.ts create mode 100644 packages/plugin-api/tests/previewEventStore.test.ts create mode 100644 packages/plugin-cli/src/templates/keymaps.default.json.ts create mode 100644 packages/plugin-cli/src/templates/menus.main.json.ts create mode 100644 packages/storage-sqlite/src/migrations/020_notebook_icons.ts create mode 100644 packages/storage-sqlite/src/migrations/021_note_tasks.ts create mode 100644 packages/tables/package.json create mode 100644 packages/tables/src/edit.ts create mode 100644 packages/tables/src/generate.ts create mode 100644 packages/tables/src/index.ts create mode 100644 packages/tables/src/parse.ts create mode 100644 packages/tables/src/types.ts create mode 100644 packages/tables/tests/edit.test.ts create mode 100644 packages/tables/tests/parse.test.ts create mode 100644 packages/tables/tsconfig.json create mode 100644 packages/tables/vitest.config.ts diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index b610cfa2..adff2503 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -97,6 +97,10 @@ export default defineConfig({ resolve: { alias: [ { find: '@', replacement: resolve(__dirname, 'src/renderer') }, + { + find: '@dripnex/tables', + replacement: resolve(__dirname, '../../packages/tables/src/index.ts'), + }, // highlight@1.2.3 nests common@1.5.0; language/markdown nest 1.5.2. // Two NodeProp identities → HighlightStyle.style(undefined) → // "tags is not iterable". Pin every import to the desktop copy. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 676e1464..23b20c4e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -79,6 +79,7 @@ "@dripnex/storage-core": "workspace:*", "@dripnex/storage-sqlite": "workspace:*", "@dripnex/sync-core": "workspace:*", + "@dripnex/tables": "workspace:*", "@dripnex/wikilinks": "workspace:*", "@playwright/test": "^1.49.1", "@types/better-sqlite3": "^7.6.12", diff --git a/apps/desktop/src/main/handlers/dataHandlers.ts b/apps/desktop/src/main/handlers/dataHandlers.ts index 803f5734..c15b222f 100644 --- a/apps/desktop/src/main/handlers/dataHandlers.ts +++ b/apps/desktop/src/main/handlers/dataHandlers.ts @@ -29,8 +29,22 @@ import { type NoteStatus, } from '@dripnex/core'; import { defineIpcHandler } from '../ipc/registry.js'; +import { htmlToPdfBuffer, printHtml } from '../services/printNote.js'; import type { SQLiteNoteRepository, Database } from './types.js'; +function safeExportName(suggestedName: string): string { + let safeName = + suggestedName + .normalize('NFC') + // eslint-disable-next-line no-control-regex + .replace(/[/\\:*?"<>|\x00-\x1f.]/g, '') + .substring(0, 80) + .trim() || 'note'; + const WINDOWS_RESERVED = /^(con|prn|aux|nul|com\d|lpt\d)$/i; + if (WINDOWS_RESERVED.test(safeName)) safeName = `_${safeName}`; + return safeName; +} + export interface DataHandlerDeps { dataPaths: DataPaths; noteRepository: SQLiteNoteRepository; @@ -164,6 +178,8 @@ export function registerDataHandlers(deps: DataHandlerDeps): void { updatedAt: note.metadata.updatedAt, tags: [...note.metadata.tags], wordCount: note.metadata.wordCount, + taskCount: note.metadata.taskCount, + checkedTaskCount: note.metadata.checkedTaskCount, archivedAt: note.metadata.archivedAt, notebookId: note.notebookId, isArchived: note.metadata.archivedAt !== null, @@ -190,15 +206,7 @@ export function registerDataHandlers(deps: DataHandlerDeps): void { channel: 'data:exportNote', args: z.tuple([z.string().max(1024 * 1024), z.string().max(512)]), handler: async (content, suggestedName) => { - let safeName = - suggestedName - .normalize('NFC') - // eslint-disable-next-line no-control-regex - .replace(/[/\\:*?"<>|\x00-\x1f.]/g, '') - .substring(0, 80) - .trim() || 'note'; - const WINDOWS_RESERVED = /^(con|prn|aux|nul|com\d|lpt\d)$/i; - if (WINDOWS_RESERVED.test(safeName)) safeName = `_${safeName}`; + const safeName = safeExportName(suggestedName); const { filePath, canceled } = await dialog.showSaveDialog({ title: 'Export Note', defaultPath: join(app.getPath('documents'), `${safeName}.md`), @@ -222,6 +230,55 @@ export function registerDataHandlers(deps: DataHandlerDeps): void { }, }); + defineIpcHandler({ + channel: 'data:exportFile', + args: z.tuple([ + z.string().max(5 * 1024 * 1024), + z.string().max(512), + z.enum(['md', 'html', 'pdf']), + ]), + handler: async (content, suggestedName, kind) => { + const safeName = safeExportName(suggestedName); + const filters = + kind === 'pdf' + ? [{ name: 'PDF', extensions: ['pdf'] }] + : kind === 'html' + ? [{ name: 'HTML', extensions: ['html'] }] + : [{ name: 'Markdown', extensions: ['md'] }]; + const { filePath, canceled } = await dialog.showSaveDialog({ + title: 'Export Note', + defaultPath: join(app.getPath('documents'), `${safeName}.${kind}`), + buttonLabel: 'Export', + filters, + }); + + if (canceled || !filePath) { + return { success: false, error: 'Export cancelled' }; + } + + try { + if (kind === 'pdf') { + const pdf = await htmlToPdfBuffer(content); + await writeFile(filePath, pdf); + } else { + await writeFile(filePath, content, 'utf-8'); + } + return { success: true, path: filePath }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to write file', + }; + } + }, + }); + + defineIpcHandler({ + channel: 'note:printHtml', + args: z.tuple([z.string().max(5 * 1024 * 1024)]), + handler: html => printHtml(html), + }); + defineIpcHandler({ channel: 'data:import', args: z.tuple([]), diff --git a/apps/desktop/src/main/handlers/localServerHandlers.ts b/apps/desktop/src/main/handlers/localServerHandlers.ts index 50d3c21c..e6f0f8a9 100644 --- a/apps/desktop/src/main/handlers/localServerHandlers.ts +++ b/apps/desktop/src/main/handlers/localServerHandlers.ts @@ -5,11 +5,22 @@ * to the renderer (settings UI). */ -import { dirname } from 'path'; +import { dirname, join } from 'path'; import { app } from 'electron'; import { z } from 'zod'; -import { createNoteId, createNoteOperation, updateNoteOperation } from '@dripnex/core'; import { + createNoteId, + createNotebook, + createNotebookId, + createNoteOperation, + deleteNoteOperation, + renameNotebook, + setNotebookIcon, + trashNoteOperation, + updateNoteOperation, +} from '@dripnex/core'; +import { + ChangeLog, LocalServer, getOrCreateApiToken, type LocalServerHandlers, @@ -17,7 +28,7 @@ import { import { resolveMcpLaunch } from '../services/mcpLaunch.js'; import { writeMcpWritesConfig } from '../services/mcpWrites.js'; import { defineIpcHandler } from '../ipc/registry.js'; -import type { SQLiteNoteRepository, DataPaths } from './types.js'; +import type { SQLiteNoteRepository, SQLiteNotebookRepository, DataPaths } from './types.js'; // ============================================================================ // Types @@ -25,6 +36,7 @@ import type { SQLiteNoteRepository, DataPaths } from './types.js'; export interface LocalServerHandlerDeps { noteRepository: SQLiteNoteRepository; + notebookRepository: SQLiteNotebookRepository; dataPaths: DataPaths; noteToSnapshot: (note: { id: string; @@ -50,6 +62,8 @@ export interface LocalServerHandlerDeps { updatedAt: string; tags: string[]; wordCount: number; + taskCount?: number; + checkedTaskCount?: number; archivedAt: string | null; isArchived: boolean; isPinned: boolean; @@ -63,6 +77,7 @@ export interface LocalServerHandlerDeps { // ============================================================================ const server = new LocalServer(); +const changeLog = new ChangeLog(); let apiToken: string | null = null; // ============================================================================ @@ -70,7 +85,9 @@ let apiToken: string | null = null; // ============================================================================ export function registerLocalServerHandlers(deps: LocalServerHandlerDeps): void { - const { noteRepository: repo, dataPaths, noteToSnapshot } = deps; + const { noteRepository: repo, notebookRepository, dataPaths, noteToSnapshot } = deps; + changeLog.attach(join(dataPaths.root, 'changes.json')); + void changeLog.load(); // Build handler callbacks that bridge HTTP requests to the note repository const handlers: LocalServerHandlers = { @@ -99,6 +116,8 @@ export function registerLocalServerHandlers(deps: LocalServerHandlerDeps): void updatedAt: snap.updatedAt, tags: snap.tags, wordCount: snap.wordCount, + taskCount: snap.taskCount, + checkedTaskCount: snap.checkedTaskCount, isPinned: snap.isPinned, }; }, @@ -106,6 +125,7 @@ export function registerLocalServerHandlers(deps: LocalServerHandlerDeps): void async createNote(input) { const result = await createNoteOperation(input, repo); if (result.ok) { + changeLog.record('note', result.data.id); return { ok: true, data: { id: result.data.id } }; } return { ok: false, error: result.error }; @@ -114,6 +134,7 @@ export function registerLocalServerHandlers(deps: LocalServerHandlerDeps): void async updateNote(id, content) { const noteId = createNoteId(id); const result = await updateNoteOperation({ id: noteId, content }, repo); + if (result.ok) changeLog.record('note', id); return { ok: result.ok, error: result.ok ? undefined : result.error }; }, @@ -134,6 +155,108 @@ export function registerLocalServerHandlers(deps: LocalServerHandlerDeps): void getAppVersion() { return app.getVersion(); }, + + async listNotebooks() { + const notebooks = await notebookRepository.getAll(); + return notebooks.map(nb => ({ + id: nb.id, + name: nb.name, + parentId: nb.parentId, + icon: nb.icon, + })); + }, + + async listTags() { + return repo.listTags(); + }, + + async deleteNote(id, permanent) { + const noteId = createNoteId(id); + const result = permanent + ? await deleteNoteOperation({ id: noteId }, repo) + : await trashNoteOperation({ id: noteId }, repo); + if (result.ok) changeLog.record('note', id, true); + return { ok: result.ok, error: result.ok ? undefined : result.error }; + }, + + async createNotebook(input) { + try { + let parentDepth = 0; + if (input.parentId) { + const parent = await notebookRepository.get(createNotebookId(input.parentId)); + if (parent) parentDepth = parent.depth; + } + const nextOrder = await notebookRepository.getNextOrder( + input.parentId ? createNotebookId(input.parentId) : null + ); + const notebook = createNotebook({ + name: input.name, + parentId: input.parentId ? createNotebookId(input.parentId) : null, + parentDepth, + order: nextOrder, + }); + await notebookRepository.save(notebook); + changeLog.record('book', notebook.id); + return { ok: true, data: { id: notebook.id } }; + } catch (err) { + return { ok: false, error: err }; + } + }, + + async deleteNotebook(id) { + try { + await notebookRepository.delete(createNotebookId(id)); + changeLog.record('book', id, true); + return { ok: true }; + } catch (err) { + return { ok: false, error: err }; + } + }, + + async updateNotebook(id, patch) { + try { + const notebook = await notebookRepository.get(createNotebookId(id)); + if (!notebook) return { ok: false, error: 'not found' }; + let next = notebook; + if (typeof patch.name === 'string' && patch.name.trim()) { + next = renameNotebook(next, patch.name); + } + if (patch.icon !== undefined) { + next = setNotebookIcon(next, patch.icon); + } + await notebookRepository.save(next); + changeLog.record('book', id); + return { ok: true }; + } catch (err) { + return { ok: false, error: err }; + } + }, + + async putTag(name, patch) { + try { + const current = name.trim(); + if (!current) return { ok: false, error: 'empty name' }; + if (patch.color !== undefined) { + repo.setTagColor(current, patch.color); + } else if (!patch.newName) { + repo.setTagColor(current, null); + } + if (patch.newName && patch.newName.trim() && patch.newName.trim() !== current) { + const renamed = repo.renameTag(current, patch.newName); + if (!renamed.ok) return { ok: false, error: renamed.error }; + changeLog.record('tag', patch.newName.trim().toLowerCase()); + } else { + changeLog.record('tag', current.toLowerCase()); + } + return { ok: true }; + } catch (err) { + return { ok: false, error: err }; + } + }, + + getChanges(since) { + return changeLog.since(since); + }, }; defineIpcHandler({ diff --git a/apps/desktop/src/main/handlers/notebookHandlers.ts b/apps/desktop/src/main/handlers/notebookHandlers.ts index 65171061..918c59ec 100644 --- a/apps/desktop/src/main/handlers/notebookHandlers.ts +++ b/apps/desktop/src/main/handlers/notebookHandlers.ts @@ -10,6 +10,7 @@ import { createNotebook, createTemplatesNotebook, renameNotebook, + setNotebookIcon, moveNotebook, INBOX_NOTEBOOK_ID, TEMPLATES_NOTEBOOK_ID, @@ -35,6 +36,7 @@ export function registerNotebookHandlers(deps: NotebookHandlerDeps): void { order: number; createdAt: string; updatedAt: string; + icon: string | null; }) => ({ id: nb.id, name: nb.name, @@ -43,6 +45,7 @@ export function registerNotebookHandlers(deps: NotebookHandlerDeps): void { order: nb.order, createdAt: nb.createdAt, updatedAt: nb.updatedAt, + icon: nb.icon, }); defineIpcHandler({ @@ -140,6 +143,20 @@ export function registerNotebookHandlers(deps: NotebookHandlerDeps): void { }, }); + defineIpcHandler({ + channel: 'notebooks:setIcon', + args: z.tuple([IdSchema, z.string().min(1).max(64).nullable()]), + handler: async (id, icon) => { + const notebook = await repo.get(createNotebookId(id)); + if (!notebook) { + throw new Error('Notebook not found'); + } + const updated = setNotebookIcon(notebook, icon); + await repo.save(updated); + return serialize(updated); + }, + }); + defineIpcHandler({ channel: 'notebooks:move', args: z.tuple([IdSchema, IdSchema.nullable()]), diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index fe566638..2f9e8a9e 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -75,7 +75,7 @@ import { registerNavigationGuards } from './network/navigation.js'; import { broadcastToWindows } from './windows/broadcast.js'; import { flushOpenEditors } from './windows/flushEditors.js'; import { resolveDockIconPath } from './windows/icons.js'; -import { deliverAuthToken, parseAuthVerifyToken, queueAuthToken } from './windows/authDeepLink.js'; +import { deliverDripnexUrl, queueDeepLink, parseDripnexUrl } from './windows/authDeepLink.js'; import { createMainWindow, registerQuickCaptureShortcut, @@ -300,6 +300,7 @@ app registerWindowHandlers(); registerLocalServerHandlers({ noteRepository: noteRepository!, + notebookRepository: notebookRepository!, dataPaths, noteToSnapshot, }); @@ -639,19 +640,15 @@ app.on('open-url', (event, url) => { event.preventDefault(); const log = getLogger(); log.info({ url }, 'Deep link received'); - const token = parseAuthVerifyToken(url); - if (token) { - log.info('Auth verification token received via deep link'); - deliverAuthToken(token); - return; + if (!deliverDripnexUrl(url)) { + log.warn({ url }, 'Unknown deep link format'); } - log.warn({ url }, 'Unknown deep link format'); }); const startupDeepLink = process.argv.find(arg => arg.startsWith('dripnex://')); if (startupDeepLink) { - const token = parseAuthVerifyToken(startupDeepLink); - if (token) queueAuthToken(token); + const parsed = parseDripnexUrl(startupDeepLink); + if (parsed) queueDeepLink(parsed); } app.on('second-instance', (_event, commandLine) => { @@ -660,10 +657,8 @@ app.on('second-instance', (_event, commandLine) => { if (deepLinkUrl) { log.info({ url: deepLinkUrl }, 'Deep link received via second-instance (Windows/Linux)'); - const token = parseAuthVerifyToken(deepLinkUrl); - if (token) { - log.info('Auth verification token received via second-instance'); - deliverAuthToken(token); + if (!deliverDripnexUrl(deepLinkUrl)) { + log.warn({ url: deepLinkUrl }, 'Unknown deep link format'); } } diff --git a/apps/desktop/src/main/network/navigation.ts b/apps/desktop/src/main/network/navigation.ts index 7ecc71a9..fe6b5d51 100644 --- a/apps/desktop/src/main/network/navigation.ts +++ b/apps/desktop/src/main/network/navigation.ts @@ -2,6 +2,7 @@ import { join, relative, isAbsolute } from 'path'; import { fileURLToPath } from 'url'; import { app, shell } from 'electron'; import { rendererAllowedOrigins } from '../devRenderer.js'; +import { deliverDripnexUrl } from '../windows/authDeepLink.js'; export function isInternalNavigation(url: string): boolean { let parsed: URL; @@ -36,11 +37,20 @@ export function isSafeExternalUrl(url: string): boolean { export function registerNavigationGuards(): void { app.on('web-contents-created', (_event, contents) => { contents.setWindowOpenHandler(({ url }) => { + if (url.startsWith('dripnex://')) { + deliverDripnexUrl(url); + return { action: 'deny' }; + } if (isSafeExternalUrl(url)) void shell.openExternal(url); return { action: 'deny' }; }); contents.on('will-navigate', (event, url) => { + if (url.startsWith('dripnex://')) { + event.preventDefault(); + deliverDripnexUrl(url); + return; + } if (isInternalNavigation(url)) return; event.preventDefault(); if (isSafeExternalUrl(url)) void shell.openExternal(url); diff --git a/apps/desktop/src/main/pluginScanner.ts b/apps/desktop/src/main/pluginScanner.ts index c02b74bf..480367f4 100644 --- a/apps/desktop/src/main/pluginScanner.ts +++ b/apps/desktop/src/main/pluginScanner.ts @@ -19,6 +19,8 @@ export interface ScannedPlugin { configSchema?: Record; code: string; path: string; + keymaps: string[]; + menus: string[]; } interface PluginManifestJson { @@ -57,6 +59,10 @@ export async function scanPlugins(pluginsDir: string): Promise const entryPath = join(pluginDir, manifest.main); const code = await readFile(entryPath, 'utf-8'); + const [keymaps, menus] = await Promise.all([ + readJsonDir(join(pluginDir, 'keymaps')), + readJsonDir(join(pluginDir, 'menus')), + ]); results.push({ id: manifest.id, @@ -66,6 +72,8 @@ export async function scanPlugins(pluginsDir: string): Promise configSchema: manifest.configSchema, code, path: pluginDir, + keymaps, + menus, }); } catch { // Skip directories that don't have a valid manifest or entry file @@ -74,3 +82,23 @@ export async function scanPlugins(pluginsDir: string): Promise return results; } + +async function readJsonDir(dir: string): Promise { + let names: string[]; + try { + names = await readdir(dir); + } catch { + return []; + } + + const files: string[] = []; + for (const name of names.sort()) { + if (!name.endsWith('.json')) continue; + try { + files.push(await readFile(join(dir, name), 'utf-8')); + } catch { + // skip unreadable members + } + } + return files; +} diff --git a/apps/desktop/src/main/plugins/__tests__/pluginScanner.test.ts b/apps/desktop/src/main/plugins/__tests__/pluginScanner.test.ts new file mode 100644 index 00000000..b2d3fd5e --- /dev/null +++ b/apps/desktop/src/main/plugins/__tests__/pluginScanner.test.ts @@ -0,0 +1,40 @@ +import { mkdir, writeFile, rm } from 'fs/promises'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { describe, it, expect, afterEach } from 'vitest'; +import { scanPlugins } from '../../pluginScanner'; + +const ROOT = join(tmpdir(), `dripnex-scan-${Date.now()}`); + +afterEach(async () => { + await rm(ROOT, { recursive: true, force: true }); +}); + +describe('scanPlugins', () => { + it('loads keymaps and menus json from the plugin package', async () => { + const dir = join(ROOT, 'hello'); + await mkdir(join(dir, 'keymaps'), { recursive: true }); + await mkdir(join(dir, 'menus'), { recursive: true }); + await writeFile( + join(dir, 'manifest.json'), + JSON.stringify({ + id: 'hello', + name: 'Hello', + version: '1.0.0', + main: 'index.js', + }) + ); + await writeFile(join(dir, 'index.js'), 'module.exports = { id: "hello", activate() {} }'); + await writeFile(join(dir, 'keymaps', 'default.json'), '{ "say-hello": "Mod+H" }'); + await writeFile( + join(dir, 'menus', 'main.json'), + '{ "menu": [{ "label": "Hello", "command": "say-hello" }] }' + ); + + const scanned = await scanPlugins(ROOT); + expect(scanned).toHaveLength(1); + expect(scanned[0]?.id).toBe('hello'); + expect(scanned[0]?.keymaps[0]).toContain('say-hello'); + expect(scanned[0]?.menus[0]).toContain('"label": "Hello"'); + }); +}); diff --git a/apps/desktop/src/main/services/__tests__/changeLog.test.ts b/apps/desktop/src/main/services/__tests__/changeLog.test.ts new file mode 100644 index 00000000..d9d652f9 --- /dev/null +++ b/apps/desktop/src/main/services/__tests__/changeLog.test.ts @@ -0,0 +1,40 @@ +import { mkdir, rm } from 'fs/promises'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { describe, expect, it } from 'vitest'; +import { ChangeLog } from '../localServer'; + +describe('ChangeLog', () => { + it('increments seq and filters since', () => { + const log = new ChangeLog(); + log.record('note', 'n1'); + log.record('book', 'b1'); + log.record('note', 'n1', true); + expect(log.since(0).last_seq).toBe(3); + expect(log.since(1).results).toEqual([ + { seq: 2, id: 'b1', kind: 'book' }, + { seq: 3, id: 'n1', kind: 'note', deleted: true }, + ]); + }); + + it('reloads seq from disk', async () => { + const dir = join(tmpdir(), `dripnex-changes-${Date.now()}`); + await mkdir(dir, { recursive: true }); + const path = join(dir, 'changes.json'); + try { + const a = new ChangeLog(); + a.attach(path); + a.record('note', 'n1'); + a.record('tag', 'ship'); + await a.flush(); + + const b = new ChangeLog(); + b.attach(path); + await b.load(); + expect(b.since(0).last_seq).toBe(2); + expect(b.since(0).results.map(r => r.id)).toEqual(['n1', 'ship']); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/main/services/localServer.ts b/apps/desktop/src/main/services/localServer.ts index 1a2c4654..d22b69ad 100644 --- a/apps/desktop/src/main/services/localServer.ts +++ b/apps/desktop/src/main/services/localServer.ts @@ -29,6 +29,8 @@ export interface LocalServerHandlers { updatedAt: string; tags: string[]; wordCount: number; + taskCount?: number; + checkedTaskCount?: number; isPinned: boolean; } | null>; createNote: (input: { @@ -41,6 +43,98 @@ export interface LocalServerHandlers { ) => Promise>; getNoteCount: () => Promise; getAppVersion: () => string; + listNotebooks: () => Promise< + Array<{ id: string; name: string; parentId: string | null; icon: string | null }> + >; + listTags: () => Promise>; + deleteNote: (id: string, permanent?: boolean) => Promise<{ ok: boolean; error?: unknown }>; + createNotebook: (input: { + name: string; + parentId?: string | null; + }) => Promise<{ ok: boolean; data?: { id: string }; error?: unknown }>; + updateNotebook: ( + id: string, + patch: { name?: string; icon?: string | null } + ) => Promise<{ ok: boolean; error?: unknown }>; + deleteNotebook: (id: string) => Promise<{ ok: boolean; error?: unknown }>; + putTag: ( + name: string, + patch: { color?: string | null; newName?: string } + ) => Promise<{ ok: boolean; error?: unknown }>; + getChanges: (since: number) => { results: ChangeRecord[]; last_seq: number }; +} + +export interface ChangeRecord { + seq: number; + id: string; + kind: 'note' | 'book' | 'tag'; + deleted?: boolean; +} + +export class ChangeLog { + private seq = 0; + private readonly items: ChangeRecord[] = []; + private persistPath: string | null = null; + private persistChain = Promise.resolve(); + + attach(persistPath: string): void { + this.persistPath = persistPath; + } + + async load(): Promise { + if (!this.persistPath) return; + try { + const raw = await fs.readFile(this.persistPath, 'utf-8'); + const parsed = JSON.parse(raw) as { seq?: unknown; items?: unknown }; + if (typeof parsed.seq !== 'number' || !Array.isArray(parsed.items)) return; + const items: ChangeRecord[] = []; + for (const item of parsed.items) { + if (!item || typeof item !== 'object') continue; + const rec = item as Partial; + if (typeof rec.seq !== 'number' || typeof rec.id !== 'string') continue; + if (rec.kind !== 'note' && rec.kind !== 'book' && rec.kind !== 'tag') continue; + items.push({ + seq: rec.seq, + id: rec.id, + kind: rec.kind, + deleted: rec.deleted === true ? true : undefined, + }); + } + this.seq = parsed.seq; + this.items.splice(0, this.items.length, ...items.slice(-500)); + } catch { + // missing or corrupt — start empty + } + } + + record(kind: ChangeRecord['kind'], id: string, deleted = false): ChangeRecord { + this.seq += 1; + const rec: ChangeRecord = { seq: this.seq, id, kind, deleted: deleted || undefined }; + this.items.push(rec); + if (this.items.length > 500) this.items.shift(); + this.schedulePersist(); + return rec; + } + + since(n: number): { results: ChangeRecord[]; last_seq: number } { + return { + results: this.items.filter(item => item.seq > n), + last_seq: this.seq, + }; + } + + async flush(): Promise { + await this.persistChain; + } + + private schedulePersist(): void { + if (!this.persistPath) return; + const path = this.persistPath; + const payload = JSON.stringify({ seq: this.seq, items: this.items }); + this.persistChain = this.persistChain + .then(() => fs.writeFile(path, payload, { encoding: 'utf-8', mode: 0o600 })) + .catch(() => {}); + } } // ============================================================================ @@ -258,6 +352,120 @@ export class LocalServer { return; } + if (method === 'GET' && pathname === '/api/books') { + const books = await handlers.listNotebooks(); + this.sendJson(res, 200, books); + return; + } + + if (method === 'GET' && pathname === '/api/tags') { + const tags = await handlers.listTags(); + this.sendJson(res, 200, tags); + return; + } + + if (method === 'DELETE' && pathname.match(/^\/api\/notes\/[^/]+$/)) { + const noteId = pathname.split('/').pop()!; + const permanent = url.searchParams.get('permanent') === '1'; + const result = await handlers.deleteNote(noteId, permanent); + if (result.ok) { + this.sendJson(res, 200, { ok: true }); + } else { + this.sendJson(res, 404, { error: 'Note not found' }); + } + return; + } + + if (method === 'PUT' && pathname.match(/^\/api\/books\/[^/]+$/)) { + const bookId = pathname.split('/').pop()!; + const body = await this.readBody(req); + const patch: { name?: string; icon?: string | null } = {}; + if (typeof body.name === 'string') patch.name = body.name; + if (body.icon === null || typeof body.icon === 'string') patch.icon = body.icon; + if (patch.name === undefined && patch.icon === undefined) { + this.sendJson(res, 400, { error: 'Missing name or icon' }); + return; + } + const result = await handlers.updateNotebook(bookId, patch); + if (result.ok) { + this.sendJson(res, 200, { ok: true }); + } else { + this.sendJson(res, 404, { error: 'Notebook not found' }); + } + return; + } + + if (method === 'POST' && pathname === '/api/tags') { + const body = await this.readBody(req); + const name = typeof body.name === 'string' ? body.name : ''; + if (!name.trim()) { + this.sendJson(res, 400, { error: 'Missing name' }); + return; + } + const color = + body.color === null || typeof body.color === 'string' ? body.color : undefined; + const result = await handlers.putTag(name, { color }); + if (result.ok) { + this.sendJson(res, 201, { ok: true, name: name.trim().toLowerCase() }); + } else { + this.sendJson(res, 500, { error: 'Failed to save tag' }); + } + return; + } + + if (method === 'PUT' && pathname.match(/^\/api\/tags\/[^/]+$/)) { + const tagName = decodeURIComponent(pathname.split('/').pop()!); + const body = await this.readBody(req); + const patch: { color?: string | null; newName?: string } = {}; + if (body.color === null || typeof body.color === 'string') patch.color = body.color; + if (typeof body.name === 'string') patch.newName = body.name; + if (patch.color === undefined && patch.newName === undefined) { + this.sendJson(res, 400, { error: 'Missing color or name' }); + return; + } + const result = await handlers.putTag(tagName, patch); + if (result.ok) { + this.sendJson(res, 200, { ok: true }); + } else { + this.sendJson(res, 404, { error: 'Tag not found' }); + } + return; + } + + if (method === 'POST' && pathname === '/api/books') { + const body = await this.readBody(req); + const name = typeof body.name === 'string' ? body.name : ''; + if (!name.trim()) { + this.sendJson(res, 400, { error: 'Missing name' }); + return; + } + const parentId = typeof body.parentId === 'string' ? body.parentId : undefined; + const result = await handlers.createNotebook({ name, parentId }); + if (result.ok && result.data) { + this.sendJson(res, 201, { id: result.data.id }); + } else { + this.sendJson(res, 500, { error: 'Failed to create notebook' }); + } + return; + } + + if (method === 'DELETE' && pathname.match(/^\/api\/books\/[^/]+$/)) { + const bookId = pathname.split('/').pop()!; + const result = await handlers.deleteNotebook(bookId); + if (result.ok) { + this.sendJson(res, 200, { ok: true }); + } else { + this.sendJson(res, 404, { error: 'Notebook not found' }); + } + return; + } + + if (method === 'GET' && (pathname === '/api/_changes' || pathname === '/_changes')) { + const since = Number(url.searchParams.get('since') ?? '0'); + this.sendJson(res, 200, handlers.getChanges(Number.isFinite(since) ? since : 0)); + return; + } + // 404 for everything else this.sendJson(res, 404, { error: 'Not found' }); } catch (err) { diff --git a/apps/desktop/src/main/services/printNote.ts b/apps/desktop/src/main/services/printNote.ts new file mode 100644 index 00000000..f3afc1d0 --- /dev/null +++ b/apps/desktop/src/main/services/printNote.ts @@ -0,0 +1,67 @@ +import { writeFile, unlink } from 'fs/promises'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { BrowserWindow } from 'electron'; + +function tempHtmlPath(): string { + return join(tmpdir(), `dripnex-print-${Date.now()}-${Math.random().toString(16).slice(2)}.html`); +} + +async function loadHtmlWindow(html: string): Promise<{ + win: BrowserWindow; + cleanup: () => Promise; +}> { + const win = new BrowserWindow({ + show: false, + width: 800, + height: 1100, + webPreferences: { + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + }, + }); + const tmp = tempHtmlPath(); + await writeFile(tmp, html, 'utf-8'); + await win.loadFile(tmp); + return { + win, + async cleanup() { + if (!win.isDestroyed()) win.destroy(); + await unlink(tmp).catch(() => {}); + }, + }; +} + +export async function htmlToPdfBuffer(html: string): Promise { + const { win, cleanup } = await loadHtmlWindow(html); + try { + const pdf = await win.webContents.printToPDF({ + printBackground: true, + pageSize: 'A4', + }); + return Buffer.from(pdf); + } finally { + await cleanup(); + } +} + +export async function printHtml(html: string): Promise<{ success: boolean; error?: string }> { + const { win, cleanup } = await loadHtmlWindow(html); + try { + const result = await new Promise<{ success: boolean; error?: string }>(resolve => { + win.webContents.print({ printBackground: true }, (success, failureReason) => { + if (success) resolve({ success: true }); + else resolve({ success: false, error: failureReason || 'Print cancelled' }); + }); + }); + return result; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Print failed', + }; + } finally { + await cleanup(); + } +} diff --git a/apps/desktop/src/main/services/sync/SyncService.ts b/apps/desktop/src/main/services/sync/SyncService.ts index 5310a20d..1fc6f98a 100644 --- a/apps/desktop/src/main/services/sync/SyncService.ts +++ b/apps/desktop/src/main/services/sync/SyncService.ts @@ -15,6 +15,7 @@ import { createNotebook, createTag, createTimestamp, + extractTasks, } from '@dripnex/core'; import type { LocalNotePush } from '@dripnex/sync-core'; import type { @@ -964,6 +965,7 @@ export class SyncService { this.noteRepository.markAsSynced(noteId); } else { + const tasks = extractTasks(payload.content); await this.noteRepository.save({ id: noteId, notebookId: createNotebookId(payload.notebookId), @@ -978,6 +980,8 @@ export class SyncService { updatedAt: createTimestamp(new Date(change.createdAt)), tags: payload.tags.map(createTag), wordCount: payload.content.split(/\s+/).length, + taskCount: tasks.total, + checkedTaskCount: tasks.completed, archivedAt: null, }, }); diff --git a/apps/desktop/src/main/windows/__tests__/deepLink.test.ts b/apps/desktop/src/main/windows/__tests__/deepLink.test.ts new file mode 100644 index 00000000..2c3bd102 --- /dev/null +++ b/apps/desktop/src/main/windows/__tests__/deepLink.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { parseDripnexUrl } from '../deepLink'; + +describe('parseDripnexUrl', () => { + it('parses auth verify', () => { + expect(parseDripnexUrl('dripnex://auth/verify?token=abc')).toEqual({ + kind: 'auth-verify', + token: 'abc', + }); + }); + + it('parses note, notebook, book alias, and tag', () => { + expect(parseDripnexUrl('dripnex://note/n1#intro')).toEqual({ + kind: 'note', + noteId: 'n1', + heading: 'intro', + }); + expect(parseDripnexUrl('dripnex://notebook/nb1')).toEqual({ + kind: 'notebook', + notebookId: 'nb1', + }); + expect(parseDripnexUrl('dripnex://book/nb1')).toEqual({ + kind: 'notebook', + notebookId: 'nb1', + }); + expect(parseDripnexUrl('dripnex://tag/ship%20it')).toEqual({ + kind: 'tag', + tag: 'ship it', + }); + }); + + it('rejects unknown hosts and other schemes', () => { + expect(parseDripnexUrl('dripnex://unknown/x')).toBeNull(); + expect(parseDripnexUrl('https://dripnex.app')).toBeNull(); + expect(parseDripnexUrl('dripnex://note/')).toBeNull(); + }); +}); diff --git a/apps/desktop/src/main/windows/applicationMenu.ts b/apps/desktop/src/main/windows/applicationMenu.ts index 08832ea9..c4d405a9 100644 --- a/apps/desktop/src/main/windows/applicationMenu.ts +++ b/apps/desktop/src/main/windows/applicationMenu.ts @@ -80,6 +80,48 @@ function buildTemplate(): MenuItemConstructorOptions[] { const template: MenuItemConstructorOptions[] = [ ...(process.platform === 'darwin' ? [{ role: 'appMenu' as const }] : []), { role: 'fileMenu' }, + { + label: 'Note', + submenu: [ + { + label: 'Export as Markdown…', + click: (_item, browserWindow) => { + invokeIn( + targetWindow(browserWindow) ?? undefined, + 'plugin:dripnex-export-markdown:export-file' + ); + }, + }, + { + label: 'Export as HTML…', + click: (_item, browserWindow) => { + invokeIn( + targetWindow(browserWindow) ?? undefined, + 'plugin:dripnex-export-markdown:export-html' + ); + }, + }, + { + label: 'Export as PDF…', + click: (_item, browserWindow) => { + invokeIn( + targetWindow(browserWindow) ?? undefined, + 'plugin:dripnex-export-markdown:export-pdf' + ); + }, + }, + { type: 'separator' }, + { + label: 'Print…', + click: (_item, browserWindow) => { + invokeIn( + targetWindow(browserWindow) ?? undefined, + 'plugin:dripnex-export-markdown:print' + ); + }, + }, + ], + }, { role: 'editMenu' }, { role: 'viewMenu' }, { label: 'Plugins', submenu: pluginsSubmenu }, diff --git a/apps/desktop/src/main/windows/authDeepLink.ts b/apps/desktop/src/main/windows/authDeepLink.ts index 84776bc2..77665c53 100644 --- a/apps/desktop/src/main/windows/authDeepLink.ts +++ b/apps/desktop/src/main/windows/authDeepLink.ts @@ -1,53 +1,73 @@ import { BrowserWindow } from 'electron'; import { getLogger } from '../logger.js'; +import { parseDripnexUrl, type DripnexDeepLink } from './deepLink.js'; -let pendingAuthToken: string | null = null; +export { parseDripnexUrl } from './deepLink.js'; +export type { DripnexDeepLink } from './deepLink.js'; + +let pending: DripnexDeepLink | null = null; export function parseAuthVerifyToken(url: string): string | null { - try { - const parsed = new URL(url); - if (parsed.hostname === 'auth' && parsed.pathname === '/verify') { - return parsed.searchParams.get('token'); - } - } catch { - return null; - } - return null; + const parsed = parseDripnexUrl(url); + return parsed?.kind === 'auth-verify' ? parsed.token : null; } export function queueAuthToken(token: string): void { - pendingAuthToken = token; + pending = { kind: 'auth-verify', token }; +} + +export function queueDeepLink(link: DripnexDeepLink): void { + pending = link; } export function takePendingAuthToken(): string | null { - const token = pendingAuthToken; - pendingAuthToken = null; + if (pending?.kind !== 'auth-verify') return null; + const token = pending.token; + pending = null; return token; } -export function deliverAuthToken(token: string): void { - const mainWin = BrowserWindow.getAllWindows().find( +function targetWindow(): BrowserWindow | undefined { + return BrowserWindow.getAllWindows().find( win => !win.isDestroyed() && !win.webContents.isDestroyed() && win.webContents.isLoading() === false ); +} + +function sendDeepLink(win: BrowserWindow, link: DripnexDeepLink): void { + if (link.kind === 'auth-verify') { + win.webContents.send('auth:verify-token', link.token); + } + win.webContents.send('app:deep-link', link); + win.show(); + win.focus(); +} + +export function deliverAuthToken(token: string): void { + deliverDeepLink({ kind: 'auth-verify', token }); +} + +export function deliverDeepLink(link: DripnexDeepLink): void { + const mainWin = targetWindow(); if (mainWin && !mainWin.webContents.isDestroyed()) { - mainWin.webContents.send('auth:verify-token', token); - mainWin.show(); - mainWin.focus(); + sendDeepLink(mainWin, link); return; } - queueAuthToken(token); + queueDeepLink(link); +} + +export function deliverDripnexUrl(url: string): boolean { + const parsed = parseDripnexUrl(url); + if (!parsed) return false; + deliverDeepLink(parsed); + return true; } export function flushPendingAuthToken(win: BrowserWindow): void { - const token = takePendingAuthToken(); - if (!token) return; - if (win.isDestroyed() || win.webContents.isDestroyed()) { - queueAuthToken(token); - return; - } - getLogger().info('Delivering queued auth token to renderer'); - win.webContents.send('auth:verify-token', token); - win.show(); - win.focus(); + if (!pending) return; + if (win.isDestroyed() || win.webContents.isDestroyed()) return; + getLogger().info({ kind: pending.kind }, 'Delivering queued deep link to renderer'); + const link = pending; + pending = null; + sendDeepLink(win, link); } diff --git a/apps/desktop/src/main/windows/deepLink.ts b/apps/desktop/src/main/windows/deepLink.ts new file mode 100644 index 00000000..05f6666e --- /dev/null +++ b/apps/desktop/src/main/windows/deepLink.ts @@ -0,0 +1,48 @@ +export type DripnexDeepLink = + | { kind: 'auth-verify'; token: string } + | { kind: 'note'; noteId: string; heading?: string } + | { kind: 'notebook'; notebookId: string } + | { kind: 'tag'; tag: string }; + +/** + * Parse `dripnex://note/`, `dripnex://notebook/`, `dripnex://tag/`, + * and `dripnex://auth/verify?token=`. + */ +export function parseDripnexUrl(raw: string): DripnexDeepLink | null { + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return null; + } + if (parsed.protocol !== 'dripnex:') return null; + + const host = parsed.hostname.toLowerCase(); + const path = decodeURIComponent(parsed.pathname.replace(/^\/+/, '')); + const heading = parsed.hash ? decodeURIComponent(parsed.hash.slice(1)) : undefined; + + if (host === 'auth' && (path === 'verify' || parsed.pathname === '/verify')) { + const token = parsed.searchParams.get('token'); + return token ? { kind: 'auth-verify', token } : null; + } + + if (host === 'note') { + const noteId = path || parsed.searchParams.get('id') || ''; + if (!noteId) return null; + return heading ? { kind: 'note', noteId, heading } : { kind: 'note', noteId }; + } + + if (host === 'notebook' || host === 'book') { + const notebookId = path || parsed.searchParams.get('id') || ''; + if (!notebookId) return null; + return { kind: 'notebook', notebookId }; + } + + if (host === 'tag') { + const tag = path || parsed.searchParams.get('name') || ''; + if (!tag) return null; + return { kind: 'tag', tag }; + } + + return null; +} diff --git a/apps/desktop/src/preload/api/data.ts b/apps/desktop/src/preload/api/data.ts index 08a23456..31ee601b 100644 --- a/apps/desktop/src/preload/api/data.ts +++ b/apps/desktop/src/preload/api/data.ts @@ -10,6 +10,12 @@ export interface DataAPI { content: string, suggestedName: string ) => Promise<{ success: boolean; path?: string; error?: string }>; + exportFile: ( + content: string, + suggestedName: string, + kind: 'md' | 'html' | 'pdf' + ) => Promise<{ success: boolean; path?: string; error?: string }>; + printHtml: (html: string) => Promise<{ success: boolean; error?: string }>; import: () => Promise; paths: () => Promise; openFolder: () => Promise<{ success: boolean }>; @@ -23,6 +29,9 @@ export function createDataApi(): DataAPI { export: () => ipcRenderer.invoke('data:export'), exportNote: (content: string, suggestedName: string) => ipcRenderer.invoke('data:exportNote', content, suggestedName), + exportFile: (content, suggestedName, kind) => + ipcRenderer.invoke('data:exportFile', content, suggestedName, kind), + printHtml: html => ipcRenderer.invoke('note:printHtml', html), import: () => ipcRenderer.invoke('data:import'), paths: () => ipcRenderer.invoke('data:paths'), openFolder: () => ipcRenderer.invoke('data:openFolder'), diff --git a/apps/desktop/src/preload/api/notebooks.ts b/apps/desktop/src/preload/api/notebooks.ts index c6bc4fac..ef6c1484 100644 --- a/apps/desktop/src/preload/api/notebooks.ts +++ b/apps/desktop/src/preload/api/notebooks.ts @@ -9,6 +9,7 @@ export interface NotebooksAPI { create: (input: { name: string; parentId?: string }) => Promise; ensureTemplates: () => Promise; rename: (id: string, name: string) => Promise; + setIcon: (id: string, icon: string | null) => Promise; move: (id: string, newParentId: string | null) => Promise; delete: (id: string) => Promise<{ success: boolean }>; reorder: (parentId: string | null, orderedIds: string[]) => Promise<{ success: boolean }>; @@ -46,6 +47,7 @@ export function createNotebooksApi(): NotebooksAPI { create: input => ipcRenderer.invoke('notebooks:create', input), ensureTemplates: () => ipcRenderer.invoke('notebooks:ensureTemplates'), rename: (id, name) => ipcRenderer.invoke('notebooks:rename', id, name), + setIcon: (id, icon) => ipcRenderer.invoke('notebooks:setIcon', id, icon), move: (id, newParentId) => ipcRenderer.invoke('notebooks:move', id, newParentId), delete: id => ipcRenderer.invoke('notebooks:delete', id), reorder: (parentId, orderedIds) => diff --git a/apps/desktop/src/preload/api/types.ts b/apps/desktop/src/preload/api/types.ts index 7371c017..2e346d86 100644 --- a/apps/desktop/src/preload/api/types.ts +++ b/apps/desktop/src/preload/api/types.ts @@ -29,6 +29,7 @@ export interface NotebookSnapshot { order: number; createdAt: string; updatedAt: string; + icon: string | null; } /** Notebook with metadata (note/child counts) */ @@ -275,6 +276,8 @@ export interface ScannedPlugin { configSchema?: Record; code: string; path: string; + keymaps: string[]; + menus: string[]; } /** Plugin registry state row */ diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index a9b833fd..3f75bbfe 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -144,10 +144,28 @@ function NotesApp() { const selectedNoteRef = useRef(null); selectedNoteRef.current = selectedNote; const [noteHistory, setNoteHistory] = useState(emptyNoteHistory); + const noteHistoryRef = useRef(noteHistory); + noteHistoryRef.current = noteHistory; + const historyNavGen = useRef(0); const setSelectedNoteAndVisit = useCallback((note: NoteSnapshot | null) => { setSelectedNote(note); if (note) setNoteHistory(prev => visitNote(prev, note.id)); }, []); + const goNoteHistory = useCallback(async (direction: 'back' | 'forward') => { + const gen = ++historyNavGen.current; + const current = noteHistoryRef.current; + const { state, id } = direction === 'back' ? historyBack(current) : historyForward(current); + if (!id) return; + try { + const result = await window.dripnex.notes.get(id); + if (gen !== historyNavGen.current) return; + if (!result.ok) return; + setNoteHistory(state); + setSelectedNote(result.data); + } catch { + // Keep the cursor if the note cannot be loaded. + } + }, []); const { appAPI, dataAPI, pluginSlot } = usePluginRuntime(selectedNoteRef); const { searchQuery, debouncedSearch, handleSearch, clearSearch } = useDebouncedSearch(300); const [isGraphOpen, setIsGraphOpen] = useState(false); @@ -315,20 +333,10 @@ function NotesApp() { displayedNotes, onSelectNote: handleSelectNote, onNoteBack: () => { - const { state, id } = historyBack(noteHistory); - if (!id) return; - setNoteHistory(state); - void window.dripnex.notes.get(id).then(result => { - if (result.ok) setSelectedNote(result.data); - }); + void goNoteHistory('back'); }, onNoteForward: () => { - const { state, id } = historyForward(noteHistory); - if (!id) return; - setNoteHistory(state); - void window.dripnex.notes.get(id).then(result => { - if (result.ok) setSelectedNote(result.data); - }); + void goNoteHistory('forward'); }, onToggleZen: toggleDistractionFree, onOpenInWindow: () => { @@ -515,20 +523,10 @@ function NotesApp() { canForward={canGoForward(noteHistory)} distractionFree={distractionFree} onBack={() => { - const { state, id } = historyBack(noteHistory); - if (!id) return; - setNoteHistory(state); - void window.dripnex.notes.get(id).then(result => { - if (result.ok) setSelectedNote(result.data); - }); + void goNoteHistory('back'); }} onForward={() => { - const { state, id } = historyForward(noteHistory); - if (!id) return; - setNoteHistory(state); - void window.dripnex.notes.get(id).then(result => { - if (result.ok) setSelectedNote(result.data); - }); + void goNoteHistory('forward'); }} onToggleZen={toggleDistractionFree} onOpenWindow={() => { diff --git a/apps/desktop/src/renderer/components/ColorPicker/ColorPickerModal.module.css b/apps/desktop/src/renderer/components/ColorPicker/ColorPickerModal.module.css index 626fbdfb..a652601e 100644 --- a/apps/desktop/src/renderer/components/ColorPicker/ColorPickerModal.module.css +++ b/apps/desktop/src/renderer/components/ColorPicker/ColorPickerModal.module.css @@ -2,13 +2,13 @@ position: fixed; inset: 0; z-index: 80; - display: flex; - align-items: center; - justify-content: center; - background: color-mix(in srgb, #000 32%, transparent); } .dialog { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); min-width: 220px; padding: 14px; background: var(--bg-elevated); @@ -17,9 +17,19 @@ box-shadow: var(--shadow-xl); } +.popover { + position: fixed; + min-width: 148px; + padding: 10px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); +} + .title { - margin: 0 0 10px; + margin: 0 0 8px; color: var(--text-primary); - font-size: var(--text-sm); + font-size: var(--text-xs); font-weight: 600; } diff --git a/apps/desktop/src/renderer/components/ColorPicker/ColorPickerModal.tsx b/apps/desktop/src/renderer/components/ColorPicker/ColorPickerModal.tsx index 12ebe635..5e9c70b3 100644 --- a/apps/desktop/src/renderer/components/ColorPicker/ColorPickerModal.tsx +++ b/apps/desktop/src/renderer/components/ColorPicker/ColorPickerModal.tsx @@ -1,22 +1,36 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { ColorPicker } from './ColorPicker'; import styles from './ColorPickerModal.module.css'; +export interface ColorPickerAnchor { + top: number; + left: number; + bottom: number; + right: number; +} + interface ColorPickerModalProps { title: string; currentColor: string | null; onSelect: (color: string) => void; onClear: () => void; onClose: () => void; + /** When set, opens as an anchored popover instead of a centered modal. */ + anchor?: ColorPickerAnchor; } +const POPOVER_WIDTH = 168; +const POPOVER_HEIGHT = 148; +const PAD = 8; + export function ColorPickerModal({ title, currentColor, onSelect, onClear, onClose, + anchor, }: ColorPickerModalProps) { useEffect(() => { const onKey = (event: KeyboardEvent) => { @@ -26,13 +40,29 @@ export function ColorPickerModal({ return () => document.removeEventListener('keydown', onKey); }, [onClose]); + const popoverStyle = useMemo(() => { + if (!anchor) return undefined; + let left = anchor.left; + let top = anchor.bottom + 6; + if (left + POPOVER_WIDTH > window.innerWidth - PAD) { + left = window.innerWidth - POPOVER_WIDTH - PAD; + } + if (top + POPOVER_HEIGHT > window.innerHeight - PAD) { + top = anchor.top - POPOVER_HEIGHT - 6; + } + if (left < PAD) left = PAD; + if (top < PAD) top = PAD; + return { left, top }; + }, [anchor]); + return createPortal(
event.stopPropagation()} >

{title}

diff --git a/apps/desktop/src/renderer/components/ColorPicker/index.ts b/apps/desktop/src/renderer/components/ColorPicker/index.ts index b044011f..2dc36bf6 100644 --- a/apps/desktop/src/renderer/components/ColorPicker/index.ts +++ b/apps/desktop/src/renderer/components/ColorPicker/index.ts @@ -1,3 +1,4 @@ export { ColorPicker, TAG_COLORS } from './ColorPicker'; export { ColorPickerModal } from './ColorPickerModal'; export type { ColorPickerProps } from './ColorPicker'; +export type { ColorPickerAnchor } from './ColorPickerModal'; diff --git a/apps/desktop/src/renderer/components/MarkdownEditor.tsx b/apps/desktop/src/renderer/components/MarkdownEditor.tsx index afe155fa..fe263c84 100644 --- a/apps/desktop/src/renderer/components/MarkdownEditor.tsx +++ b/apps/desktop/src/renderer/components/MarkdownEditor.tsx @@ -61,11 +61,12 @@ import { import { embedInlinePreview } from '@dripnex/embeds/codemirror'; import { pluginExtensionCompartment, editorPluginStore } from '@dripnex/plugin-api'; import { htmlToGfmMarkdown } from '../utils/htmlToMarkdown'; +import { scrollBehavior } from '../utils/motion'; import { useEditorBufferStore } from '../stores/editorBufferStore'; import { useSettingsStore, selectEditor } from '../stores/settings'; import { setEditorView } from '../hooks/useCommandRegistry'; -import { createEditorTheme, markdownHighlighting, SCROLL_PAST_END_PADDING } from './editorTheme.js'; import { emojiShortcodeCompletions } from '../plugins/emojiShortcodes'; +import { createEditorTheme, markdownHighlighting, SCROLL_PAST_END_PADDING } from './editorTheme.js'; import { fenceLanguageCompletions, slashCompletions } from './editor/slashCompletions'; import { UrlPastePicker } from './editor/UrlPastePicker'; import styles from './MarkdownEditor.module.css'; @@ -117,6 +118,7 @@ export interface MarkdownEditorHandle { onScroll: (callback: (fraction: number) => void) => () => void; canScroll: () => boolean; jumpToLine: (line: number) => void; + getVisibleLine: () => number; } export const MarkdownEditor = forwardRef( @@ -254,10 +256,22 @@ export const MarkdownEditor = forwardRef { + const view = viewRef.current; + if (!view) return 1; + const block = view.lineBlockAtHeight(view.scrollDOM.scrollTop + 48); + return view.state.doc.lineAt(block.from).number; + }, })); // Keep refs updated diff --git a/apps/desktop/src/renderer/components/NoteEditor.tsx b/apps/desktop/src/renderer/components/NoteEditor.tsx index 61d07a04..36535f58 100644 --- a/apps/desktop/src/renderer/components/NoteEditor.tsx +++ b/apps/desktop/src/renderer/components/NoteEditor.tsx @@ -1,7 +1,8 @@ import { useRef, useCallback, useState, useEffect, useMemo, lazy, Suspense } from 'react'; import { FileText, MoreVertical, Link2, Hash } from 'lucide-react'; import { LayoutZone } from '@dripnex/plugin-api'; -import type { MarkdownHeading } from '@dripnex/markdown'; +import { toggleNthGfmTask, type MarkdownHeading } from '@dripnex/markdown'; +import { getEditorView } from '../hooks/useCommandRegistry'; import type { NoteSnapshot, NoteStatus } from '../../preload/index'; import { useEditorPreferencesStore } from '../stores/editorPreferencesStore'; import { @@ -243,8 +244,13 @@ export function NoteEditor({ const showPreview = viewMode === 'preview' || viewMode === 'split'; const isSplitMode = viewMode === 'split'; + const [outlineActiveLine, setOutlineActiveLine] = useState(null); + const [outlineActiveText, setOutlineActiveText] = useState(null); + const handleOutlineJump = useCallback( (heading: MarkdownHeading) => { + setOutlineActiveLine(heading.line); + setOutlineActiveText(heading.text); if (showEditor) { editorRef.current?.jumpToLine(heading.line); } else { @@ -254,6 +260,51 @@ export function NoteEditor({ [showEditor] ); + useEffect(() => { + if (!outlineOpen || !note) return; + let unsub: (() => void) | undefined; + let interval: number | undefined; + + const attach = (): boolean => { + if (showEditor && editorRef.current) { + const editor = editorRef.current; + const sync = () => { + setOutlineActiveLine(editor.getVisibleLine()); + setOutlineActiveText(null); + }; + unsub = editor.onScroll(sync); + sync(); + return true; + } + if (!showEditor && previewRef.current) { + const preview = previewRef.current; + const sync = () => { + setOutlineActiveLine(null); + setOutlineActiveText(preview.getVisibleHeading()); + }; + unsub = preview.onScroll(sync); + sync(); + return true; + } + return false; + }; + + if (!attach()) { + let tries = 0; + interval = window.setInterval(() => { + tries += 1; + if (attach() || tries > 20) { + if (interval) window.clearInterval(interval); + } + }, 50); + } + + return () => { + if (interval) window.clearInterval(interval); + unsub?.(); + }; + }, [outlineOpen, showEditor, note?.id, viewMode]); + // Scroll sync for split mode (see useScrollSync for architecture docs) const { masterRef, handleEditorReady, handlePreviewReady } = useScrollSync({ isSplitMode, @@ -277,6 +328,28 @@ export function NoteEditor({ [onUpdate] ); + const handlePreviewCheckbox = useCallback( + (index: number) => { + if (!note) return; + const current = + useEditorBufferStore.getState().noteId === note.id + ? useEditorBufferStore.getState().liveContent + : note.content; + const next = toggleNthGfmTask(current, index); + if (next == null || next === current) return; + const view = getEditorView(); + if (view) { + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: next }, + }); + return; + } + useEditorBufferStore.getState().updateBuffer(next); + handleChange(next); + }, + [note, handleChange] + ); + // Cleanup debounce timer on unmount to prevent stale mutations useEffect(() => { return () => { @@ -475,6 +548,7 @@ export function NoteEditor({ masterRef.current = 'preview'; }} > + setLightbox({ src: url, alt: target })} resolvedEmbeds={resolvedEmbeds} + onCheckboxToggle={handlePreviewCheckbox} />
)}
{outlineOpen ? ( - + ) : null}
+ + {/* Plugin Status Bar */} diff --git a/apps/desktop/src/renderer/components/NoteList.module.css b/apps/desktop/src/renderer/components/NoteList.module.css index 8895df04..398b68eb 100644 --- a/apps/desktop/src/renderer/components/NoteList.module.css +++ b/apps/desktop/src/renderer/components/NoteList.module.css @@ -29,6 +29,13 @@ -webkit-app-region: drag; } +.note-list-header :global([data-layout-zone]) { + display: flex; + align-items: center; + gap: 4px; + -webkit-app-region: no-drag; +} + .header-title { flex: 1; min-width: 0; @@ -299,6 +306,49 @@ opacity: 0.8; } +.status-chip { + font-size: 10px; + line-height: 1.3; + padding: 1px 6px; + border-radius: 4px; + border: 1px solid color-mix(in srgb, currentColor 35%, transparent); + background: color-mix(in srgb, currentColor 12%, transparent); +} + +.status-chip--on_hold { + color: var(--status-on-hold); +} + +.status-chip--completed { + color: var(--status-completed); +} + +.status-chip--dropped { + color: var(--status-dropped); +} + +.task-progress { + font-variant-numeric: tabular-nums; + color: var(--text-muted); +} + +.note-list-status { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 12px; + border-top: 1px solid var(--border-subtle); + min-height: 28px; +} + +.note-list-count { + font-size: 11px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + /* Tag Badges (pill style, no #) */ .note-list-item-meta .tags { display: flex; diff --git a/apps/desktop/src/renderer/components/NoteList.tsx b/apps/desktop/src/renderer/components/NoteList.tsx index c92f113f..c9b12e0c 100644 --- a/apps/desktop/src/renderer/components/NoteList.tsx +++ b/apps/desktop/src/renderer/components/NoteList.tsx @@ -32,6 +32,13 @@ import styles from './NoteList.module.css'; const sc = cssm(styles); +const STATUS_LABEL: Record = { + active: 'Active', + on_hold: 'On Hold', + completed: 'Completed', + dropped: 'Dropped', +}; + interface NoteListProps { notes: NoteWithExcerpt[]; selectedId: string | null; @@ -251,6 +258,10 @@ export function NoteList({ return 'All Notes'; }; + const selectedIndex = selectedId ? notes.findIndex(n => n.id === selectedId) : -1; + const listPosition = + selectedIndex >= 0 ? `${selectedIndex + 1} of ${notes.length}` : `${notes.length}`; + return (
{formatRelativeTime(note.updatedAt)} + {note.status !== 'active' ? ( + + {STATUS_LABEL[note.status]} + + ) : null} + {tasks.total > 0 ? ( + + {tasks.completed} of {tasks.total} + + ) : null} {note.tags.length > 0 && ( {note.tags.slice(0, 2).map(tag => { @@ -468,6 +496,10 @@ function NoteListItem({ })} )} +
{note.excerpt &&
{note.excerpt}
} diff --git a/apps/desktop/src/renderer/components/NoteListContextMenu/NoteListContextMenu.tsx b/apps/desktop/src/renderer/components/NoteListContextMenu/NoteListContextMenu.tsx index 883754e2..cb8581f3 100644 --- a/apps/desktop/src/renderer/components/NoteListContextMenu/NoteListContextMenu.tsx +++ b/apps/desktop/src/renderer/components/NoteListContextMenu/NoteListContextMenu.tsx @@ -10,6 +10,9 @@ import { PinOff, FileStack, } from 'lucide-react'; +import { useStore } from 'zustand'; +import { pluginContextMenuStore } from '@dripnex/plugin-api'; +import { dispatchCommand } from '../../hooks/useCommandRegistry'; import styles from './NoteListContextMenu.module.css'; export interface NoteListContextMenuProps { @@ -153,6 +156,9 @@ export function NoteListContextMenu({ }, [noteId, onCreateFromTemplate, onClose]); const isTemplate = currentNotebookId === 'templates'; + const pluginItems = useStore(pluginContextMenuStore, state => state.items).filter( + item => item.target === 'note-list-item' + ); return createPortal(
Move to Trash ⌘⌫ + {pluginItems.length > 0 ? ( + <> +
+ {pluginItems.map(item => ( + + ))} + + ) : null} )}
, diff --git a/apps/desktop/src/renderer/components/editor/ActionsPanel/ActionsPanel.tsx b/apps/desktop/src/renderer/components/editor/ActionsPanel/ActionsPanel.tsx index a3fc869a..7decc1f2 100644 --- a/apps/desktop/src/renderer/components/editor/ActionsPanel/ActionsPanel.tsx +++ b/apps/desktop/src/renderer/components/editor/ActionsPanel/ActionsPanel.tsx @@ -105,7 +105,7 @@ export const ActionsPanel = memo(function ActionsPanel({ // Copy note link to clipboard const handleCopyLink = useCallback(async () => { - const noteLink = `dripnex://note/${noteId}`; + const noteLink = `dripnex://note/${encodeURIComponent(noteId)}`; try { await navigator.clipboard.writeText(noteLink); onClose(); diff --git a/apps/desktop/src/renderer/components/editor/MarkdownPreview.module.css b/apps/desktop/src/renderer/components/editor/MarkdownPreview.module.css index 82b16b4a..121a20c9 100644 --- a/apps/desktop/src/renderer/components/editor/MarkdownPreview.module.css +++ b/apps/desktop/src/renderer/components/editor/MarkdownPreview.module.css @@ -280,7 +280,7 @@ border: 1.5px solid var(--border); border-radius: 3px; background: transparent; - cursor: default; + cursor: pointer; position: relative; vertical-align: middle; } @@ -317,6 +317,52 @@ margin-bottom: 0; } +.markdown-preview blockquote.markdown-alert { + font-style: normal; + border-left-width: 4px; +} + +.markdown-preview .markdown-alert-title { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 0.4em; + font-weight: 650; + font-style: normal; + color: var(--alert-color, var(--text-primary)); + letter-spacing: 0.02em; +} + +.markdown-preview .markdown-alert-note { + --alert-color: #4493f8; + border-left-color: var(--alert-color); + background: color-mix(in srgb, var(--alert-color) 10%, var(--bg-elevated)); +} + +.markdown-preview .markdown-alert-tip { + --alert-color: #3fb950; + border-left-color: var(--alert-color); + background: color-mix(in srgb, var(--alert-color) 10%, var(--bg-elevated)); +} + +.markdown-preview .markdown-alert-important { + --alert-color: #ab7df8; + border-left-color: var(--alert-color); + background: color-mix(in srgb, var(--alert-color) 10%, var(--bg-elevated)); +} + +.markdown-preview .markdown-alert-warning { + --alert-color: #d29922; + border-left-color: var(--alert-color); + background: color-mix(in srgb, var(--alert-color) 10%, var(--bg-elevated)); +} + +.markdown-preview .markdown-alert-caution { + --alert-color: #f85149; + border-left-color: var(--alert-color); + background: color-mix(in srgb, var(--alert-color) 10%, var(--bg-elevated)); +} + /* Code */ .markdown-preview code { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; diff --git a/apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx b/apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx index df66f22a..54da6151 100644 --- a/apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx +++ b/apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx @@ -18,13 +18,16 @@ import { rehypePluginStore, previewComponentStore, codeBlockStore, + emitPreviewEvent, } from '@dripnex/plugin-api'; import { coreRemarkPlugins } from '../../lib/coreRemarkPlugins'; import { formatDateTime } from '../../utils/date'; import { useEditorBufferStore, selectContentForNote } from '../../stores/editorBufferStore'; import { usePreviewFindStore } from '../../stores/previewFindStore'; import { applyPreviewFind, unwrapPreviewFindMarks } from '../../utils/previewFind'; +import { scrollBehavior } from '../../utils/motion'; import { cssm } from '../../lib/cssm'; +import { emitLocalDeepLink } from '../../utils/parseDripnexUrl'; import { PreviewFindBar } from './PreviewFindBar'; import { FenceBlock } from './FenceBlock'; import styles from './MarkdownPreview.module.css'; @@ -46,6 +49,8 @@ interface MarkdownPreviewProps { readonly onEmbedClick?: (target: string, url: string) => void; /** Optional pre-resolved embeds from parent (for sharing with editor) */ readonly resolvedEmbeds?: Record; + /** Toggle the Nth GFM task when a preview checkbox is clicked */ + readonly onCheckboxToggle?: (index: number, checked: boolean) => void; } /** Imperative handle for scroll sync */ @@ -55,6 +60,7 @@ export interface MarkdownPreviewHandle { onScroll: (callback: (fraction: number) => void) => () => void; canScroll: () => boolean; jumpToHeading: (text: string) => void; + getVisibleHeading: () => string | null; } export const MarkdownPreview = forwardRef( @@ -68,6 +74,7 @@ export const MarkdownPreview = forwardRef { - const wikilinkEl = (e.target as HTMLElement).closest('.wikilink'); + const target = e.target as HTMLElement; + + const wikilinkEl = target.closest('.wikilink'); if (wikilinkEl) { const noteTitle = wikilinkEl.getAttribute('data-target'); const anchor = wikilinkEl.getAttribute('data-anchor') ?? undefined; @@ -204,9 +212,22 @@ export const MarkdownPreview = forwardRef) => { + const box = e.target; + const root = containerRef.current; + if (!root) return; + const boxes = root.querySelectorAll('input[type="checkbox"]'); + const index = Array.prototype.indexOf.call(boxes, box); + if (index < 0) return; + const checked = box.checked; + const allowed = emitPreviewEvent('checkbox:change', { index, checked }); + if (!allowed) return; + onCheckboxToggle?.(index, checked); + }; + useEffect(() => { onReady?.(); }, []); @@ -252,7 +286,20 @@ export const MarkdownPreview = forwardRef (h.textContent ?? '').trim() === text); - target?.scrollIntoView({ block: 'start' }); + target?.scrollIntoView({ block: 'start', behavior: scrollBehavior() }); + }, + getVisibleHeading: () => { + const el = containerRef.current; + if (!el) return null; + const headings = Array.from(el.querySelectorAll('h1,h2,h3,h4,h5,h6')); + if (headings.length === 0) return null; + const top = el.getBoundingClientRect().top + 12; + let current = headings[0] ?? null; + for (const heading of headings) { + if (heading.getBoundingClientRect().top <= top + 24) current = heading; + else break; + } + return current?.textContent?.trim() ?? null; }, })); @@ -279,6 +326,7 @@ export const MarkdownPreview = forwardRef
{hasProgress && ( @@ -322,61 +370,75 @@ export const MarkdownPreview = forwardRef - r.plugin), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ] as any[] - } - rehypePlugins={ - [ - rehypeRaw, - [rehypeSanitize, sanitizeSchema], - rehypeHighlight, - ...pluginRehypeRegs.map(r => r.plugin), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ] as any[] - } - components={ - { - pre: ({ children }) => {children}, - input: ({ type, checked, ...props }) => { - if (type === 'checkbox') { - return ; - } - return ; - }, - // Custom embed-image element bypasses rehype URL sanitization - 'embed-image': ({ src, alt }: { src?: string; alt?: string }) => ( - {alt} - ), - // Code block renderer delegation to plugins - code: ({ className, children, ...props }) => { - const match = /language-([\w+#.-]+)/.exec(className || ''); - const lang = match?.[1]; - if (lang) { - const reg = pluginCodeBlockRegs.find(r => r.language === lang); - if (reg) { - const CodeRenderer = reg.component; - const code = String(children).replace(/\n$/, ''); - return ; +
+ r.plugin), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any[] + } + rehypePlugins={ + [ + rehypeRaw, + [rehypeSanitize, sanitizeSchema], + rehypeHighlight, + ...pluginRehypeRegs.map(r => r.plugin), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any[] + } + components={ + { + pre: ({ children }) => {children}, + input: ({ type, checked, ...props }) => { + if (type === 'checkbox') { + return ( + e.stopPropagation()} + /> + ); + } + return ; + }, + // Custom embed-image element bypasses rehype URL sanitization + 'embed-image': ({ src, alt }: { src?: string; alt?: string }) => ( + {alt} + ), + // Code block renderer delegation to plugins + code: ({ className, children, ...props }) => { + const match = /language-([\w+#.-]+)/.exec(className || ''); + const lang = match?.[1]; + if (lang) { + const reg = pluginCodeBlockRegs.find(r => r.language === lang); + if (reg) { + const CodeRenderer = reg.component; + const code = String(children).replace(/\n$/, ''); + return ; + } } - } - return ( - - {children} - - ); - }, - // Plugin-registered preview components - ...Object.fromEntries(pluginComponentRegs.map(r => [r.tagName, r.component])), - } as Record> - } - > - {resolvedContent} - + return ( + + {children} + + ); + }, + // Plugin-registered preview components + ...Object.fromEntries(pluginComponentRegs.map(r => [r.tagName, r.component])), + } as Record> + } + > + {resolvedContent} + +
); diff --git a/apps/desktop/src/renderer/components/editor/OutlinePanel.module.css b/apps/desktop/src/renderer/components/editor/OutlinePanel.module.css index 14c527f4..a9a95ee2 100644 --- a/apps/desktop/src/renderer/components/editor/OutlinePanel.module.css +++ b/apps/desktop/src/renderer/components/editor/OutlinePanel.module.css @@ -28,13 +28,14 @@ width: 100%; text-align: left; border: none; + border-left: 2px solid transparent; background: transparent; color: var(--text-secondary); font: inherit; font-size: 12px; line-height: 1.35; padding: 4px 8px; - border-radius: 5px; + border-radius: 0; cursor: pointer; white-space: nowrap; overflow: hidden; @@ -46,6 +47,12 @@ background: var(--bg-hover); } +.outline-item-active { + color: var(--text-primary); + background: var(--accent-muted); + border-left-color: var(--accent); +} + .outline-item--l2 { padding-left: 16px; } .outline-item--l3 { padding-left: 24px; } .outline-item--l4 { padding-left: 32px; } diff --git a/apps/desktop/src/renderer/components/editor/OutlinePanel.tsx b/apps/desktop/src/renderer/components/editor/OutlinePanel.tsx index ef4f9e14..db6b3338 100644 --- a/apps/desktop/src/renderer/components/editor/OutlinePanel.tsx +++ b/apps/desktop/src/renderer/components/editor/OutlinePanel.tsx @@ -1,6 +1,7 @@ import { memo, useMemo } from 'react'; import { scanMarkdown, type MarkdownHeading } from '@dripnex/markdown'; import { cssm } from '../../lib/cssm'; +import { headingIndexAtOrBefore, headingIndexByText } from '../../utils/outlineActive'; import styles from './OutlinePanel.module.css'; const sc = cssm(styles); @@ -8,10 +9,24 @@ const sc = cssm(styles); interface OutlinePanelProps { readonly content: string; readonly onJump: (heading: MarkdownHeading) => void; + readonly activeLine?: number | null; + readonly activeText?: string | null; } -export const OutlinePanel = memo(function OutlinePanel({ content, onJump }: OutlinePanelProps) { +export const OutlinePanel = memo(function OutlinePanel({ + content, + onJump, + activeLine, + activeText, +}: OutlinePanelProps) { const headings = useMemo(() => scanMarkdown(content).headings, [content]); + const activeIndex = useMemo(() => { + if (activeLine != null) { + const fromLine = headingIndexAtOrBefore(headings, activeLine); + if (fromLine >= 0) return fromLine; + } + return headingIndexByText(headings, activeText ?? null); + }, [headings, activeLine, activeText]); return (
+ {current ? ( + + ) : null} +
+ , + document.body + ); +} diff --git a/apps/desktop/src/renderer/components/sidebar/NotebookItem.tsx b/apps/desktop/src/renderer/components/sidebar/NotebookItem.tsx index 4efb95b8..24e73626 100644 --- a/apps/desktop/src/renderer/components/sidebar/NotebookItem.tsx +++ b/apps/desktop/src/renderer/components/sidebar/NotebookItem.tsx @@ -3,18 +3,22 @@ import { createPortal } from 'react-dom'; import { ChevronDown, ChevronRight, - Inbox, - Folder, Plus, GitBranch, History, GripVertical, Trash2, + Smile, } from 'lucide-react'; +import { useStore } from 'zustand'; +import { pluginContextMenuStore } from '@dripnex/plugin-api'; +import { dispatchCommand } from '../../hooks/useCommandRegistry'; import type { NotebookTreeNode } from '../../../preload/index'; import { useNotebookExpandStore } from '../../stores/notebookExpandStore'; import { useWorkspaceRootId } from '../../hooks/useNavigation'; import { CommitHistory } from '../git/CommitHistory'; +import { NotebookIconPicker, type IconPickerAnchor } from './NotebookIconPicker'; +import { notebookLucideIcon } from './notebookIcons'; import { sc } from './sc'; type DropPosition = 'above' | 'inside' | 'below' | null; @@ -32,6 +36,7 @@ interface NotebookItemProps { readonly onRename: (id: string, name: string) => void; readonly onDelete: (id: string) => void; readonly onCreateChild: (parentId: string) => void; + readonly onSetIcon: (id: string, icon: string | null) => void; readonly onMove?: (id: string, newParentId: string | null) => void; readonly onReorder?: (parentId: string | null, orderedIds: string[]) => void; readonly siblingIds?: string[]; @@ -63,6 +68,7 @@ export const NotebookItem = memo(function NotebookItem({ onRename, onDelete, onCreateChild, + onSetIcon, onMove, onReorder, siblingIds, @@ -77,6 +83,7 @@ export const NotebookItem = memo(function NotebookItem({ const [isGitLoading, setIsGitLoading] = useState(false); const [showCommitHistory, setShowCommitHistory] = useState(false); const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); + const [iconPicker, setIconPicker] = useState(null); const [dropPosition, setDropPosition] = useState(null); const [isDragging, setIsDragging] = useState(false); const [canDrag, setCanDrag] = useState(false); @@ -85,6 +92,13 @@ export const NotebookItem = memo(function NotebookItem({ const hasChildren = node.children.length > 0; const isInbox = node.notebook.id === 'inbox'; const canHaveChildren = depth < 2; // Max 3 levels (0, 1, 2) + const Icon = notebookLucideIcon( + node.notebook.icon, + isInbox ? 'inbox' : node.notebook.id === 'templates' ? 'file-stack' : 'folder' + ); + const pluginMenuItems = useStore(pluginContextMenuStore, state => state.items).filter( + item => item.target === 'notebook-item' + ); // Memoize descendant IDs for circular reference prevention const descendantIds = useMemo(() => collectDescendantIds(node), [node]); @@ -394,7 +408,7 @@ export const NotebookItem = memo(function NotebookItem({ @@ -463,6 +477,7 @@ export const NotebookItem = memo(function NotebookItem({ onRename={onRename} onDelete={onDelete} onCreateChild={onCreateChild} + onSetIcon={onSetIcon} onMove={onMove} onReorder={onReorder} siblingIds={node.children.map(c => c.notebook.id)} @@ -492,6 +507,23 @@ export const NotebookItem = memo(function NotebookItem({ New sub-notebook ) : null} + + {pluginMenuItems.length > 0 + ? pluginMenuItems.map(item => ( + + )) + : null} , document.body ) @@ -536,6 +583,18 @@ export const NotebookItem = memo(function NotebookItem({ onClose={() => setShowCommitHistory(false)} /> )} + + {iconPicker ? ( + { + onSetIcon(node.notebook.id, icon); + setIconPicker(null); + }} + onClose={() => setIconPicker(null)} + /> + ) : null} ); }); diff --git a/apps/desktop/src/renderer/components/sidebar/NotebookList.tsx b/apps/desktop/src/renderer/components/sidebar/NotebookList.tsx index dd5727bf..41d8f7f4 100644 --- a/apps/desktop/src/renderer/components/sidebar/NotebookList.tsx +++ b/apps/desktop/src/renderer/components/sidebar/NotebookList.tsx @@ -61,7 +61,8 @@ export function NotebookList({ nameFilter = '', }: NotebookListProps) { const { data: tree, isLoading, error } = useNotebookTree(); - const { renameNotebook, deleteNotebook, moveNotebook, reorderNotebooks } = useNotebookMutations(); + const { renameNotebook, deleteNotebook, moveNotebook, reorderNotebooks, setNotebookIcon } = + useNotebookMutations(); // Calculate ancestor IDs for breadcrumb-style highlighting const ancestorIds = useMemo( @@ -124,6 +125,13 @@ export function NotebookList({ [renameNotebook] ); + const handleSetIcon = useCallback( + async (id: string, icon: string | null) => { + await setNotebookIcon.mutateAsync({ id, icon }); + }, + [setNotebookIcon] + ); + const handleDelete = useCallback( async (id: string) => { await deleteNotebook.mutateAsync(id); @@ -178,6 +186,7 @@ export function NotebookList({ onRename={handleRename} onDelete={handleDelete} onCreateChild={onRequestCreateChild} + onSetIcon={handleSetIcon} onMove={handleMove} onReorder={handleReorder} siblingIds={rootSiblingIds} diff --git a/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx b/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx index 63c1401b..e2b92607 100644 --- a/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx +++ b/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx @@ -355,6 +355,7 @@ export function Sidebar({ onOpenGraph }: SidebarProps) { )} + setIsSyncModalOpen(true)} /> {isCreateNotebookOpen && ( diff --git a/apps/desktop/src/renderer/components/sidebar/StatusGlyph.tsx b/apps/desktop/src/renderer/components/sidebar/StatusGlyph.tsx index f9e0d843..6313acf6 100644 --- a/apps/desktop/src/renderer/components/sidebar/StatusGlyph.tsx +++ b/apps/desktop/src/renderer/components/sidebar/StatusGlyph.tsx @@ -1,30 +1,15 @@ +import { Check, Pause, Play, X } from 'lucide-react'; import type { NoteStatus } from '../../../preload/index'; -const glyphs: Record = { - active: { - fill: 'var(--status-active)', - path: 'M9.6 7.6v8.8c0 .5.5.8 1 .5l7-4.4a.6.6 0 0 0 0-1l-7-4.4a.6.6 0 0 0-1 .5z', - }, - on_hold: { - fill: 'var(--status-on-hold)', - path: 'M8.6 7.4h2.2v9.2H8.6zm4.6 0h2.2v9.2h-2.2z', - }, - completed: { - fill: 'var(--status-completed)', - path: 'M9.3 15.1 6.6 12.4l1.2-1.2 1.5 1.5 5.1-5.1 1.2 1.2z', - }, - dropped: { - fill: 'var(--status-dropped)', - path: 'M8.1 8.1 9.2 7l2.8 2.8L14.8 7l1.1 1.1-2.8 2.8 2.8 2.8-1.1 1.1-2.8-2.8-2.8 2.8-1.1-1.1 2.8-2.8z', - }, +const ICONS: Record = { + active: Play, + on_hold: Pause, + completed: Check, + dropped: X, }; +/** Stroke icon — color comes from the parent (sidebar / header tokens). */ export function StatusGlyph({ status }: { status: NoteStatus }) { - const glyph = glyphs[status]; - return ( - - ); + const Icon = ICONS[status]; + return