From 51124ab6421413b0c25d39e6b1418c939fa18dbd Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Fri, 24 Apr 2026 00:11:04 -0300 Subject: [PATCH 1/4] feat: local HTTP API, quick capture, mermaid, math, vim mode + fix ASAR crash New features: - Local HTTP API server (port 29168) with bearer token auth GET/POST/PUT /api/notes, /api/notes/search, /api/notes/quick, /api/status - Global Quick Capture (Cmd+Shift+N): frameless floating window for fast notes - Mermaid diagrams: code block renderer with "Open in Mermaid Live" button - Math/LaTeX: code block renderer with styled display - Vim mode: plugin stub with toggle command (ready for @codemirror/vim) Bug fix: - Fix ERR_PACKAGE_PATH_NOT_EXPORTED crash in packaged app electron-vite was externalizing @readied/* workspace packages, but ASAR doesn't have compiled JS for them. Now workspace packages are bundled into the main/preload output instead of externalized. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.local.json | 6 +- apps/desktop/electron-vite.config.ts | 19 +- .../src/main/handlers/localServerHandlers.ts | 184 ++++++++++++ apps/desktop/src/main/index.ts | 105 ++++++- apps/desktop/src/main/services/localServer.ts | 283 ++++++++++++++++++ apps/desktop/src/preload/api/app.ts | 4 + apps/desktop/src/preload/api/index.ts | 3 + apps/desktop/src/preload/api/localServer.ts | 23 ++ apps/desktop/src/preload/index.ts | 4 + .../components/QuickCapture.module.css | 181 +++++++++++ .../src/renderer/components/QuickCapture.tsx | 144 +++++++++ apps/desktop/src/renderer/main.tsx | 6 +- apps/desktop/src/renderer/plugins/index.ts | 9 + apps/desktop/src/renderer/plugins/math.tsx | 159 ++++++++++ apps/desktop/src/renderer/plugins/mermaid.tsx | 155 ++++++++++ apps/desktop/src/renderer/plugins/vimMode.tsx | 98 ++++++ 16 files changed, 1378 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/main/handlers/localServerHandlers.ts create mode 100644 apps/desktop/src/main/services/localServer.ts create mode 100644 apps/desktop/src/preload/api/localServer.ts create mode 100644 apps/desktop/src/renderer/components/QuickCapture.module.css create mode 100644 apps/desktop/src/renderer/components/QuickCapture.tsx create mode 100644 apps/desktop/src/renderer/plugins/math.tsx create mode 100644 apps/desktop/src/renderer/plugins/mermaid.tsx create mode 100644 apps/desktop/src/renderer/plugins/vimMode.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7e56c05a..d5d98fda 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -81,7 +81,11 @@ "Bash(ls /Users/tomasmaritano/Documents/Github/readied/readide/apps/desktop/src/renderer/components/NoteListFilterBar*)", "Bash(ls /Users/tomasmaritano/Documents/Github/readied/readide/apps/desktop/src/renderer/components/*.module.css)", "Bash(npm view:*)", - "Bash(git log:*)" + "Bash(git log:*)", + "Bash(git branch:*)", + "Bash(npx vercel:*)", + "Bash(gh workflow:*)", + "Bash(ls:*)" ] } } diff --git a/apps/desktop/electron-vite.config.ts b/apps/desktop/electron-vite.config.ts index 94b5467c..ac047be4 100644 --- a/apps/desktop/electron-vite.config.ts +++ b/apps/desktop/electron-vite.config.ts @@ -4,7 +4,18 @@ import react from '@vitejs/plugin-react'; export default defineConfig({ main: { - plugins: [externalizeDepsPlugin()], + plugins: [ + externalizeDepsPlugin({ + exclude: [ + '@readied/core', + '@readied/storage-core', + '@readied/storage-sqlite', + '@readied/sync-core', + '@readied/licensing', + '@readied/ai-core', + ], + }), + ], build: { outDir: 'out/main', rollupOptions: { @@ -18,7 +29,11 @@ export default defineConfig({ }, }, preload: { - plugins: [externalizeDepsPlugin()], + plugins: [ + externalizeDepsPlugin({ + exclude: ['@readied/core', '@readied/storage-core', '@readied/licensing'], + }), + ], build: { outDir: 'out/preload', rollupOptions: { diff --git a/apps/desktop/src/main/handlers/localServerHandlers.ts b/apps/desktop/src/main/handlers/localServerHandlers.ts new file mode 100644 index 00000000..2595a533 --- /dev/null +++ b/apps/desktop/src/main/handlers/localServerHandlers.ts @@ -0,0 +1,184 @@ +/** + * Local HTTP API Server IPC Handlers + * + * Manages the local API server lifecycle and exposes status/token info + * to the renderer (settings UI). + */ + +import { ipcMain, app } from 'electron'; +import { createNoteId, createNoteOperation, updateNoteOperation } from '@readied/core'; +import { + LocalServer, + getOrCreateApiToken, + type LocalServerHandlers, +} from '../services/localServer.js'; +import type { SQLiteNoteRepository, DataPaths } from './types.js'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface LocalServerHandlerDeps { + noteRepository: SQLiteNoteRepository; + dataPaths: DataPaths; + noteToSnapshot: (note: { + id: string; + notebookId: string; + content: string; + title: string; + isPinned: boolean; + isDeleted: boolean; + status: import('@readied/core').NoteStatus; + metadata: { + createdAt: string; + updatedAt: string; + tags: readonly string[]; + wordCount: number; + archivedAt: string | null; + }; + }) => { + id: string; + notebookId: string; + content: string; + title: string; + createdAt: string; + updatedAt: string; + tags: string[]; + wordCount: number; + archivedAt: string | null; + isArchived: boolean; + isPinned: boolean; + isDeleted: boolean; + status: import('@readied/core').NoteStatus; + }; +} + +// ============================================================================ +// Module State +// ============================================================================ + +const server = new LocalServer(); +let apiToken: string | null = null; + +// ============================================================================ +// Registration +// ============================================================================ + +export function registerLocalServerHandlers(deps: LocalServerHandlerDeps): void { + const { noteRepository: repo, dataPaths, noteToSnapshot } = deps; + + // Build handler callbacks that bridge HTTP requests to the note repository + const handlers: LocalServerHandlers = { + async listNotes() { + const notes = await repo.list(); + return notes + .filter(n => !n.isDeleted) + .map(n => ({ + id: n.id, + title: n.title, + excerpt: n.content.slice(0, 200).replace(/\n/g, ' '), + updatedAt: n.metadata.updatedAt, + })); + }, + + async getNote(id) { + const note = await repo.get(createNoteId(id)); + if (!note) return null; + const snap = noteToSnapshot(note); + return { + id: snap.id, + title: snap.title, + content: snap.content, + notebookId: snap.notebookId, + createdAt: snap.createdAt, + updatedAt: snap.updatedAt, + tags: snap.tags, + wordCount: snap.wordCount, + isPinned: snap.isPinned, + }; + }, + + async createNote(input) { + const result = await createNoteOperation(input, repo); + if (result.ok) { + return { ok: true, data: { id: result.data.id } }; + } + return { ok: false, error: result.error }; + }, + + async updateNote(id, content) { + const noteId = createNoteId(id); + const result = await updateNoteOperation({ id: noteId, content }, repo); + return { ok: result.ok, error: result.ok ? undefined : result.error }; + }, + + async searchNotes(query) { + const notes = await repo.search(query, 50); + return notes.map(n => ({ + id: n.id, + title: n.title, + excerpt: n.content.slice(0, 200).replace(/\n/g, ' '), + updatedAt: n.metadata.updatedAt, + })); + }, + + async getNoteCount() { + const notes = await repo.list(); + return notes.filter(n => !n.isDeleted).length; + }, + + getAppVersion() { + return app.getVersion(); + }, + }; + + // IPC: Start the local server + ipcMain.handle('localServer:start', async (_event, port?: number) => { + try { + if (server.isRunning()) return { ok: true, port: server.getPort() }; + apiToken = await getOrCreateApiToken(dataPaths.root); + await server.start(port, apiToken, handlers); + return { ok: true, port: server.getPort() }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + }); + + // IPC: Stop the local server + ipcMain.handle('localServer:stop', async () => { + await server.stop(); + return { ok: true }; + }); + + // IPC: Get server status + ipcMain.handle('localServer:status', () => { + return { + running: server.isRunning(), + port: server.getPort(), + }; + }); + + // IPC: Get the bearer token (for displaying in settings) + ipcMain.handle('localServer:getToken', async () => { + if (!apiToken) { + apiToken = await getOrCreateApiToken(dataPaths.root); + } + return apiToken; + }); +} + +/** + * Auto-start the server if desired (called from main index). + * Returns the server instance for lifecycle management. + */ +export async function autoStartLocalServer(deps: LocalServerHandlerDeps): Promise { + const { dataPaths } = deps; + apiToken = await getOrCreateApiToken(dataPaths.root); + // The actual start is controlled by settings — this just prepares the token. + // The renderer will call localServer:start if the setting is enabled. +} + +/** Stop the server on app quit */ +export async function stopLocalServer(): Promise { + await server.stop(); +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 6ad48324..e1e802dd 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -13,7 +13,16 @@ initSentry(); import { join, normalize } from 'path'; import { readFile, writeFile, unlink } from 'fs/promises'; import { readFileSync, writeFileSync, existsSync } from 'fs'; -import { app, BrowserWindow, ipcMain, protocol, net, nativeTheme } from 'electron'; +import { + app, + BrowserWindow, + ipcMain, + protocol, + net, + nativeTheme, + globalShortcut, + screen, +} from 'electron'; import { runMigrations, createDataPaths, type DataPaths } from '@readied/storage-core'; import { createDatabase, @@ -52,6 +61,7 @@ import { startPluginWatcher, stopPluginWatcher } from './pluginWatcher.js'; import { createAIService, getToolRegistry } from './ai/setup.js'; import { registerBuiltInTools } from './ai/built-in-tools.js'; import { registerAIHandlers as registerAIHandlersNew } from './ai/ipc-ai.js'; +import { registerLocalServerHandlers, stopLocalServer } from './handlers/localServerHandlers.js'; // ============================================================================ // Global State @@ -399,6 +409,72 @@ function createNoteWindow(noteId: string, noteTitle: string): void { } } +// ============================================================================ +// Quick Capture Window +// ============================================================================ + +/** Quick capture window singleton */ +let quickCaptureWindow: BrowserWindow | null = null; + +/** Create or focus the quick capture floating window */ +function createQuickCaptureWindow(): void { + // If window exists, focus it + if (quickCaptureWindow && !quickCaptureWindow.isDestroyed()) { + quickCaptureWindow.focus(); + return; + } + + // Center on the current cursor screen + const cursorPoint = screen.getCursorScreenPoint(); + const display = screen.getDisplayNearestPoint(cursorPoint); + const { x, y, width, height } = display.workArea; + const winWidth = 480; + const winHeight = 340; + + quickCaptureWindow = new BrowserWindow({ + x: Math.round(x + (width - winWidth) / 2), + y: Math.round(y + (height - winHeight) / 2), + width: winWidth, + height: winHeight, + resizable: false, + frame: false, + alwaysOnTop: true, + skipTaskbar: true, + show: false, + backgroundColor: '#0a0b0d', + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + nodeIntegration: false, + contextIsolation: true, + sandbox: false, + }, + }); + + quickCaptureWindow.on('ready-to-show', () => { + quickCaptureWindow?.show(); + }); + + // Close on blur (optional UX: dismiss when clicking away) + quickCaptureWindow.on('blur', () => { + if (quickCaptureWindow && !quickCaptureWindow.isDestroyed()) { + quickCaptureWindow.close(); + } + }); + + quickCaptureWindow.on('closed', () => { + quickCaptureWindow = null; + }); + + // Load quick capture view via query param + if (process.env.NODE_ENV === 'development' && process.env.ELECTRON_RENDERER_URL) { + void quickCaptureWindow.loadURL(`${process.env.ELECTRON_RENDERER_URL}?view=quick-capture`); + } else { + void quickCaptureWindow.loadFile(join(__dirname, '../renderer/index.html'), { + query: { view: 'quick-capture' }, + }); + } +} + /** Settings window singleton */ let settingsWindow: BrowserWindow | null = null; @@ -583,6 +659,11 @@ app db: db!, }); registerWindowHandlers(); + registerLocalServerHandlers({ + noteRepository: noteRepository!, + dataPaths, + noteToSnapshot, + }); registerAIHandlersNew(createAIService(), getToolRegistry()); // Register built-in AI tools with database access @@ -796,6 +877,26 @@ app }); } + // Register global quick capture shortcut + globalShortcut.register('CommandOrControl+Shift+N', () => { + createQuickCaptureWindow(); + }); + + // IPC: open quick capture from renderer + ipcMain.handle('window:openQuickCapture', async () => { + createQuickCaptureWindow(); + return { ok: true }; + }); + + // IPC: close the calling window (used by quick capture to close itself) + ipcMain.handle('window:closeSelf', async event => { + const win = BrowserWindow.fromWebContents(event.sender); + if (win && !win.isDestroyed()) { + win.close(); + } + return { ok: true }; + }); + // Create window and start auto-updater createWindow(); initAutoUpdater({ broadcastToWindows }); @@ -818,7 +919,9 @@ app.on('window-all-closed', () => { }); app.on('before-quit', () => { + globalShortcut.unregisterAll(); stopPluginWatcher(); + void stopLocalServer(); if (db) { db.close(); getLogger().info('Database closed'); diff --git a/apps/desktop/src/main/services/localServer.ts b/apps/desktop/src/main/services/localServer.ts new file mode 100644 index 00000000..bb7cd6bd --- /dev/null +++ b/apps/desktop/src/main/services/localServer.ts @@ -0,0 +1,283 @@ +/** + * Local HTTP API Server + * + * Runs inside the Electron main process, allowing external tools + * (Alfred, Raycast, Shortcuts, curl) to interact with notes via HTTP. + * + * Uses Node.js built-in `http` module — no additional dependencies. + */ + +import { createServer, IncomingMessage, ServerResponse } from 'http'; +import { randomBytes } from 'crypto'; +import { promises as fs } from 'fs'; +import { join } from 'path'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface LocalServerHandlers { + listNotes: () => Promise< + Array<{ id: string; title: string; excerpt: string; updatedAt: string }> + >; + getNote: (id: string) => Promise<{ + id: string; + title: string; + content: string; + notebookId: string; + createdAt: string; + updatedAt: string; + tags: string[]; + wordCount: number; + isPinned: boolean; + } | null>; + createNote: (input: { + content: string; + notebookId?: string; + }) => Promise<{ ok: boolean; data?: { id: string }; error?: unknown }>; + updateNote: (id: string, content: string) => Promise<{ ok: boolean; error?: unknown }>; + searchNotes: ( + query: string + ) => Promise>; + getNoteCount: () => Promise; + getAppVersion: () => string; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const DEFAULT_PORT = 29168; // "readied" in phone keypad +const TOKEN_FILE = 'api-token.txt'; + +// ============================================================================ +// Token Management +// ============================================================================ + +/** + * Get or create an API bearer token. Stored as plaintext in the app's + * data directory (not user-facing secrets — local-only convenience token). + */ +export async function getOrCreateApiToken(dataDir: string): Promise { + const tokenPath = join(dataDir, TOKEN_FILE); + try { + const existing = await fs.readFile(tokenPath, 'utf-8'); + const trimmed = existing.trim(); + if (trimmed.length >= 32) return trimmed; + } catch { + // File doesn't exist — generate a new token + } + const token = randomBytes(32).toString('hex'); + await fs.writeFile(tokenPath, token, 'utf-8'); + return token; +} + +// ============================================================================ +// LocalServer Class +// ============================================================================ + +export class LocalServer { + private server: ReturnType | null = null; + private port = DEFAULT_PORT; + private token = ''; + + /** + * Start the HTTP API server. + */ + async start( + port: number = DEFAULT_PORT, + token: string, + handlers: LocalServerHandlers + ): Promise { + if (this.server) return; // Already running + + this.port = port; + this.token = token; + + this.server = createServer((req, res) => { + void this.handleRequest(req, res, handlers); + }); + + return new Promise((resolve, reject) => { + this.server!.on('error', reject); + this.server!.listen(port, '127.0.0.1', () => { + resolve(); + }); + }); + } + + /** + * Stop the HTTP API server. + */ + stop(): Promise { + return new Promise(resolve => { + if (!this.server) { + resolve(); + return; + } + this.server.close(() => { + this.server = null; + resolve(); + }); + }); + } + + isRunning(): boolean { + return this.server !== null; + } + + getPort(): number { + return this.port; + } + + // -------------------------------------------------------------------------- + // Request Handling + // -------------------------------------------------------------------------- + + private async handleRequest( + req: IncomingMessage, + res: ServerResponse, + handlers: LocalServerHandlers + ): Promise { + // Auth check + const authHeader = req.headers.authorization; + if (!authHeader || authHeader !== `Bearer ${this.token}`) { + this.sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + + const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`); + const method = req.method?.toUpperCase() || 'GET'; + const pathname = url.pathname; + + try { + // GET /api/status + if (method === 'GET' && pathname === '/api/status') { + const noteCount = await handlers.getNoteCount(); + this.sendJson(res, 200, { + status: 'ok', + version: handlers.getAppVersion(), + noteCount, + }); + return; + } + + // GET /api/notes/search?q=query + if (method === 'GET' && pathname === '/api/notes/search') { + const query = url.searchParams.get('q') || ''; + if (!query) { + this.sendJson(res, 400, { error: 'Missing ?q= parameter' }); + return; + } + const results = await handlers.searchNotes(query); + this.sendJson(res, 200, results); + return; + } + + // POST /api/notes/quick + if (method === 'POST' && pathname === '/api/notes/quick') { + const body = await this.readBody(req); + const { content } = body as { content?: string }; + if (!content) { + this.sendJson(res, 400, { error: 'Missing content' }); + return; + } + const result = await handlers.createNote({ content, notebookId: 'inbox' }); + if (result.ok && result.data) { + this.sendJson(res, 201, { id: result.data.id }); + } else { + this.sendJson(res, 500, { error: 'Failed to create note' }); + } + return; + } + + // GET /api/notes/:id + if (method === 'GET' && pathname.match(/^\/api\/notes\/[^/]+$/)) { + const noteId = pathname.split('/').pop()!; + const note = await handlers.getNote(noteId); + if (!note) { + this.sendJson(res, 404, { error: 'Note not found' }); + return; + } + this.sendJson(res, 200, note); + return; + } + + // PUT /api/notes/:id + if (method === 'PUT' && pathname.match(/^\/api\/notes\/[^/]+$/)) { + const noteId = pathname.split('/').pop()!; + const body = await this.readBody(req); + const { content } = body as { content?: string }; + if (!content) { + this.sendJson(res, 400, { error: 'Missing content' }); + return; + } + const result = await handlers.updateNote(noteId, content); + if (result.ok) { + this.sendJson(res, 200, { ok: true }); + } else { + this.sendJson(res, 500, { error: 'Failed to update note' }); + } + return; + } + + // POST /api/notes + if (method === 'POST' && pathname === '/api/notes') { + const body = await this.readBody(req); + const { content, notebookId } = body as { + content?: string; + notebookId?: string; + }; + if (!content) { + this.sendJson(res, 400, { error: 'Missing content' }); + return; + } + const result = await handlers.createNote({ content, notebookId }); + if (result.ok && result.data) { + this.sendJson(res, 201, { id: result.data.id }); + } else { + this.sendJson(res, 500, { error: 'Failed to create note' }); + } + return; + } + + // GET /api/notes + if (method === 'GET' && pathname === '/api/notes') { + const notes = await handlers.listNotes(); + this.sendJson(res, 200, notes); + return; + } + + // 404 for everything else + this.sendJson(res, 404, { error: 'Not found' }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Internal server error'; + this.sendJson(res, 500, { error: message }); + } + } + + // -------------------------------------------------------------------------- + // Helpers + // -------------------------------------------------------------------------- + + private sendJson(res: ServerResponse, status: number, data: unknown): void { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(data)); + } + + private readBody(req: IncomingMessage): Promise> { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + try { + const raw = Buffer.concat(chunks).toString('utf-8'); + resolve(raw ? (JSON.parse(raw) as Record) : {}); + } catch { + reject(new Error('Invalid JSON body')); + } + }); + req.on('error', reject); + }); + } +} diff --git a/apps/desktop/src/preload/api/app.ts b/apps/desktop/src/preload/api/app.ts index 670e70fd..cb0ff18a 100644 --- a/apps/desktop/src/preload/api/app.ts +++ b/apps/desktop/src/preload/api/app.ts @@ -38,6 +38,8 @@ export interface EmbedsAPI { export interface WindowsAPI { openNote: (noteId: string, noteTitle: string) => Promise<{ ok: boolean }>; openSettings: () => Promise<{ ok: boolean }>; + openQuickCapture: () => Promise<{ ok: boolean }>; + closeSelf: () => Promise<{ ok: boolean }>; } export interface ShareAPI { @@ -103,6 +105,8 @@ export function createWindowsApi(): WindowsAPI { return { openNote: (noteId, noteTitle) => ipcRenderer.invoke('window:openNote', noteId, noteTitle), openSettings: () => ipcRenderer.invoke('window:openSettings'), + openQuickCapture: () => ipcRenderer.invoke('window:openQuickCapture'), + closeSelf: () => ipcRenderer.invoke('window:closeSelf'), }; } diff --git a/apps/desktop/src/preload/api/index.ts b/apps/desktop/src/preload/api/index.ts index f0b3e8ea..0eb4c630 100644 --- a/apps/desktop/src/preload/api/index.ts +++ b/apps/desktop/src/preload/api/index.ts @@ -56,3 +56,6 @@ export type { ShareAPI, EditorAPI, } from './app'; + +export { createLocalServerApi } from './localServer'; +export type { LocalServerAPI } from './localServer'; diff --git a/apps/desktop/src/preload/api/localServer.ts b/apps/desktop/src/preload/api/localServer.ts new file mode 100644 index 00000000..a072faaa --- /dev/null +++ b/apps/desktop/src/preload/api/localServer.ts @@ -0,0 +1,23 @@ +/** + * Local Server Preload API + * + * Exposes local HTTP API server controls to the renderer. + */ + +import { ipcRenderer } from 'electron'; + +export interface LocalServerAPI { + start: (port?: number) => Promise<{ ok: boolean; port?: number; error?: string }>; + stop: () => Promise<{ ok: boolean }>; + status: () => Promise<{ running: boolean; port: number }>; + getToken: () => Promise; +} + +export function createLocalServerApi(): LocalServerAPI { + return { + start: (port?: number) => ipcRenderer.invoke('localServer:start', port), + stop: () => ipcRenderer.invoke('localServer:stop'), + status: () => ipcRenderer.invoke('localServer:status'), + getToken: () => ipcRenderer.invoke('localServer:getToken'), + }; +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 3bb32b38..05d9d6bf 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -32,6 +32,7 @@ import { createWindowsApi, createShareApi, createEditorApi, + createLocalServerApi, } from './api'; import type { @@ -59,6 +60,7 @@ import type { WindowsAPI, ShareAPI, EditorAPI, + LocalServerAPI, } from './api'; // Re-export all types so the renderer can still import from '../preload/index' @@ -125,6 +127,7 @@ export interface ReadiedAPI { theme: ThemeAPI; plugins: PluginsAPI; editor: EditorAPI; + localServer: LocalServerAPI; } // Compose and expose the API @@ -153,6 +156,7 @@ const api: ReadiedAPI = { theme: createThemeApi(), plugins: createPluginsApi(), editor: createEditorApi(), + localServer: createLocalServerApi(), }; contextBridge.exposeInMainWorld('readied', api); diff --git a/apps/desktop/src/renderer/components/QuickCapture.module.css b/apps/desktop/src/renderer/components/QuickCapture.module.css new file mode 100644 index 00000000..857ea1eb --- /dev/null +++ b/apps/desktop/src/renderer/components/QuickCapture.module.css @@ -0,0 +1,181 @@ +/* Quick Capture — compact floating window */ + +.container { + display: flex; + flex-direction: column; + height: 100vh; + background: var(--bg-base, #0a0b0d); + color: var(--text-primary, #e4e4e7); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + padding: 0; + overflow: hidden; + border-radius: 12px; + -webkit-app-region: drag; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px 0; + -webkit-app-region: drag; +} + +.title { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary, #a1a1aa); + letter-spacing: 0.02em; +} + +.closeButton { + -webkit-app-region: no-drag; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: none; + color: var(--text-tertiary, #71717a); + cursor: pointer; + border-radius: 4px; + font-size: 16px; + line-height: 1; + transition: color 0.15s, background 0.15s; +} + +.closeButton:hover { + color: var(--text-primary, #e4e4e7); + background: var(--bg-hover, #27272a); +} + +.body { + flex: 1; + display: flex; + flex-direction: column; + padding: 12px 16px; + gap: 10px; + overflow: hidden; + -webkit-app-region: no-drag; +} + +.titleInput { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--border-subtle, #27272a); + border-radius: 8px; + background: var(--bg-tertiary, #18181b); + color: var(--text-primary, #e4e4e7); + font-size: 14px; + font-weight: 500; + outline: none; + box-sizing: border-box; + transition: border-color 0.15s; +} + +.titleInput::placeholder { + color: var(--text-tertiary, #71717a); +} + +.titleInput:focus { + border-color: var(--accent-primary, #5eead4); +} + +.contentArea { + flex: 1; + width: 100%; + padding: 8px 10px; + border: 1px solid var(--border-subtle, #27272a); + border-radius: 8px; + background: var(--bg-tertiary, #18181b); + color: var(--text-primary, #e4e4e7); + font-size: 13px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + line-height: 1.6; + outline: none; + resize: none; + box-sizing: border-box; + transition: border-color 0.15s; +} + +.contentArea::placeholder { + color: var(--text-tertiary, #71717a); +} + +.contentArea:focus { + border-color: var(--accent-primary, #5eead4); +} + +.footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px 12px; + -webkit-app-region: no-drag; +} + +.notebookSelect { + padding: 6px 8px; + border: 1px solid var(--border-subtle, #27272a); + border-radius: 6px; + background: var(--bg-tertiary, #18181b); + color: var(--text-secondary, #a1a1aa); + font-size: 12px; + outline: none; + cursor: pointer; + max-width: 160px; +} + +.notebookSelect:focus { + border-color: var(--accent-primary, #5eead4); +} + +.actions { + display: flex; + gap: 8px; +} + +.cancelButton { + padding: 6px 14px; + border: 1px solid var(--border-subtle, #27272a); + border-radius: 6px; + background: transparent; + color: var(--text-secondary, #a1a1aa); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.cancelButton:hover { + background: var(--bg-hover, #27272a); + color: var(--text-primary, #e4e4e7); +} + +.saveButton { + padding: 6px 16px; + border: none; + border-radius: 6px; + background: var(--accent-primary, #5eead4); + color: var(--bg-base, #0a0b0d); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} + +.saveButton:hover:not(:disabled) { + background: var(--accent-hover, #99f6e4); +} + +.saveButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.shortcutHint { + font-size: 10px; + color: var(--text-tertiary, #71717a); + margin-left: 4px; +} diff --git a/apps/desktop/src/renderer/components/QuickCapture.tsx b/apps/desktop/src/renderer/components/QuickCapture.tsx new file mode 100644 index 00000000..c2a74634 --- /dev/null +++ b/apps/desktop/src/renderer/components/QuickCapture.tsx @@ -0,0 +1,144 @@ +/** + * Quick Capture Component + * + * Rendered in a small floating window for rapid note creation. + * Opened via Cmd+Shift+N global shortcut or from the app. + */ + +import { useState, useRef, useEffect, useCallback } from 'react'; +import styles from './QuickCapture.module.css'; + +export function QuickCapture() { + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + const [notebookId, setNotebookId] = useState('inbox'); + const [notebooks, setNotebooks] = useState>([]); + const [saving, setSaving] = useState(false); + const titleRef = useRef(null); + + // Load notebooks on mount + useEffect(() => { + void window.readied.notebooks.list().then(nbs => { + setNotebooks(nbs.map(nb => ({ id: nb.id, name: nb.name }))); + }); + }, []); + + // Auto-focus the title input + useEffect(() => { + titleRef.current?.focus(); + }, []); + + const handleClose = useCallback(() => { + void window.readied.windows.closeSelf(); + }, []); + + const handleSave = useCallback(async () => { + const trimmedContent = content.trim(); + if (!trimmedContent && !title.trim()) return; + + setSaving(true); + try { + // Build markdown content with title as H1 if provided + const markdown = title.trim() ? `# ${title.trim()}\n\n${trimmedContent}` : trimmedContent; + + await window.readied.notes.create({ + content: markdown, + notebookId: notebookId || undefined, + }); + + handleClose(); + } catch { + // If save fails, keep the window open so user doesn't lose text + setSaving(false); + } + }, [content, title, notebookId, handleClose]); + + // Keyboard shortcuts + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + // Escape: close + if (e.key === 'Escape') { + e.preventDefault(); + handleClose(); + return; + } + // Cmd+Enter or Ctrl+Enter: save + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + void handleSave(); + return; + } + }; + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [handleClose, handleSave]); + + return ( +
+
+ Quick Capture + +
+ +
+ setTitle(e.target.value)} + /> +