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/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/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 67c09907..3f75bbfe 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,29 @@ function NotesApp() { const [selectedNote, setSelectedNote] = useState(null); 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); @@ -251,7 +287,7 @@ function NotesApp() { handleStatusChange, } = useNoteActions({ selectedNote, - setSelectedNote, + setSelectedNote: setSelectedNoteAndVisit, selectedNotebookId, clearSearch, appAPI, @@ -293,9 +329,21 @@ function NotesApp() { setIsGraphOpen, searchQuery, clearSearch, - setSelectedNote, + setSelectedNote: setSelectedNoteAndVisit, displayedNotes, onSelectNote: handleSelectNote, + onNoteBack: () => { + void goNoteHistory('back'); + }, + onNoteForward: () => { + void goNoteHistory('forward'); + }, + 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 +420,14 @@ function NotesApp() {
- {!sidebarCollapsed ? ( + {!hideSidebar ? (
) : null} -
+
-
+ {!hideNoteList ? ( +
+ ) : null}
{isGraphOpen ? ( @@ -463,6 +519,23 @@ function NotesApp() { onWikilinkClick={handleWikilinkClick} onNavigateToNote={handleSelectNote} onNoteUpdate={setSelectedNote} + canBack={canGoBack(noteHistory)} + canForward={canGoForward(noteHistory)} + distractionFree={distractionFree} + onBack={() => { + void goNoteHistory('back'); + }} + onForward={() => { + void goNoteHistory('forward'); + }} + onToggleZen={toggleDistractionFree} + onOpenWindow={() => { + if (!selectedNote) return; + void window.dripnex.windows.openNote( + selectedNote.id, + selectedNote.title || 'Note' + ); + }} /> )}
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/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/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.module.css b/apps/desktop/src/renderer/components/NoteEditor.module.css index a1723ea4..0f73f2fd 100644 --- a/apps/desktop/src/renderer/components/NoteEditor.module.css +++ b/apps/desktop/src/renderer/components/NoteEditor.module.css @@ -63,12 +63,33 @@ 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; + min-width: 0; + -webkit-app-region: no-drag; +} + +.note-editor-chrome-drag { + display: none; +} + +.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..36535f58 100644 --- a/apps/desktop/src/renderer/components/NoteEditor.tsx +++ b/apps/desktop/src/renderer/components/NoteEditor.tsx @@ -1,6 +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 { toggleNthGfmTask, type MarkdownHeading } from '@dripnex/markdown'; +import { getEditorView } from '../hooks/useCommandRegistry'; import type { NoteSnapshot, NoteStatus } from '../../preload/index'; import { useEditorPreferencesStore } from '../stores/editorPreferencesStore'; import { @@ -14,23 +16,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 +64,14 @@ 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; + chromeVariant?: 'main' | 'window'; } export function NoteEditor({ @@ -80,6 +89,14 @@ export function NoteEditor({ onWikilinkClick, onNavigateToNote, onNoteUpdate, + canBack = false, + canForward = false, + distractionFree = false, + onBack, + onForward, + onToggleZen, + onOpenWindow, + chromeVariant = 'main', }: NoteEditorProps) { const { showToast } = useToast(); const debounceRef = useRef(null); @@ -227,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 { @@ -238,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, @@ -261,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 () => { @@ -342,19 +431,18 @@ 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 && ( @@ -447,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 64b28ac9..398b68eb 100644 --- a/apps/desktop/src/renderer/components/NoteList.module.css +++ b/apps/desktop/src/renderer/components/NoteList.module.css @@ -24,10 +24,18 @@ align-items: center; gap: 6px; padding: 8px 10px; + min-height: 44px; border-bottom: 1px solid var(--border-subtle); -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; @@ -166,14 +174,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 ============================================================================ */ @@ -306,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 ddd9282a..c9b12e0c 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, @@ -31,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; @@ -250,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 (
); 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 de6215f0..24e73626 100644 --- a/apps/desktop/src/renderer/components/sidebar/NotebookItem.tsx +++ b/apps/desktop/src/renderer/components/sidebar/NotebookItem.tsx @@ -1,18 +1,24 @@ import { useState, useCallback, useRef, memo, useEffect, useMemo } from 'react'; +import { createPortal } from 'react-dom'; import { ChevronDown, ChevronRight, - Inbox, - Folder, Plus, - X, 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; @@ -26,9 +32,11 @@ 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; + 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[]; @@ -56,13 +64,16 @@ export const NotebookItem = memo(function NotebookItem({ ancestorIds, selectedNotebookId, onSelect, + onEnterWorkspace, onRename, onDelete, onCreateChild, + onSetIcon, onMove, 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); @@ -71,6 +82,8 @@ export const NotebookItem = memo(function NotebookItem({ const [isGitEnabled, setIsGitEnabled] = useState(false); 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); @@ -79,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]); @@ -106,6 +126,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(); @@ -147,51 +175,54 @@ export const NotebookItem = memo(function NotebookItem({ [node.notebook.name] ); - const handleAddChild = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - onCreateChild(node.notebook.id); - }, - [node.notebook.id, onCreateChild] - ); - - const handleDelete = useCallback( + const handleContextMenu = useCallback( (e: React.MouseEvent) => { + if (isInbox) return; + e.preventDefault(); e.stopPropagation(); - if (confirm(`Delete "${node.notebook.name}"? Notes will move to Inbox.`)) { - onDelete(node.notebook.id); - } + setMenu({ x: e.clientX, y: e.clientY }); }, - [node.notebook.id, node.notebook.name, onDelete] + [isInbox] ); - const handleToggleGit = useCallback( - async (e: React.MouseEvent) => { - e.stopPropagation(); - setIsGitLoading(true); - try { - if (isGitEnabled) { - await window.dripnex.notebooks.disableGit(node.notebook.id); - setIsGitEnabled(false); - } else { - const result = await window.dripnex.git.init(node.notebook.id); - if (result.success) { - await window.dripnex.notebooks.enableGit(node.notebook.id); - setIsGitEnabled(true); - } + useEffect(() => { + if (!menu) return; + const close = () => setMenu(null); + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') close(); + }; + window.addEventListener('mousedown', close); + window.addEventListener('keydown', onKey); + return () => { + window.removeEventListener('mousedown', close); + window.removeEventListener('keydown', onKey); + }; + }, [menu]); + + const handleToggleGit = useCallback(async () => { + setMenu(null); + setIsGitLoading(true); + try { + if (isGitEnabled) { + await window.dripnex.notebooks.disableGit(node.notebook.id); + setIsGitEnabled(false); + } else { + const result = await window.dripnex.git.init(node.notebook.id); + if (result.success) { + await window.dripnex.notebooks.enableGit(node.notebook.id); + setIsGitEnabled(true); } - } catch (error) { - console.error('Failed to toggle git:', error); - alert(`Failed to ${isGitEnabled ? 'disable' : 'enable'} git: ${error}`); - } finally { - setIsGitLoading(false); } - }, - [node.notebook.id, isGitEnabled] - ); + } catch (error) { + console.error('Failed to toggle git:', error); + alert(`Failed to ${isGitEnabled ? 'disable' : 'enable'} git: ${error}`); + } finally { + setIsGitLoading(false); + } + }, [node.notebook.id, isGitEnabled]); - const handleShowHistory = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); + const handleShowHistory = useCallback(() => { + setMenu(null); setShowCommitHistory(true); }, []); @@ -334,9 +365,10 @@ export const NotebookItem = memo(function NotebookItem({ dropPosition && `drop-${dropPosition}`, isDragging && 'dragging' )} - style={{ paddingLeft: `${depth * 16 + 8}px` }} + style={depth > 0 ? { paddingLeft: `${8 + depth * 16}px` } : undefined} onClick={handleClick} onDoubleClick={handleDoubleClick} + onContextMenu={handleContextMenu} role="button" tabIndex={0} aria-selected={isSelected} @@ -347,37 +379,37 @@ export const NotebookItem = memo(function NotebookItem({ onDragLeave={handleDragLeave} onDrop={handleDrop} > - {/* Drag handle — only visible on hover, enables dragging */} - {!isInbox && !isEditing && ( -