experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(editor): implement note editing flow with autosave

Add edit command to open notes in the editor with autosave support:

- New `edit` command in cmd extension that searches notes and opens
them in the editor. Shows dropdown for multiple matches, opens
directly for single exact match.

- Editor now supports loading items from datastore via itemId URL param.
When editing a datastore item, content is autosaved after 1.5s of
inactivity (debounced).

- Save status indicator in editor toolbar shows "Saved", "Saving...",
or "Unsaved" state. Only visible when editing datastore items.

- Context mode set to 'editor' when editing items, reset to 'default'
on close.

- Publishes editor:changed event on save for other extensions to react.

Flow: cmd:edit {search} -> dropdown -> select -> editor opens with
note content -> autosave on changes -> close returns to default context.

+296 -2
+117
extensions/cmd/commands/edit.js
··· 1 + /** 2 + * Edit command - opens notes in the editor 3 + * 4 + * Usage: 5 + * cmd:edit {search} - search for notes and open in editor 6 + * - Shows matching notes in dropdown 7 + * - On selection: opens editor with note content 8 + */ 9 + import api from 'peek://app/api.js'; 10 + 11 + /** 12 + * Search notes by content 13 + * @param {string} query - Search query 14 + * @param {number} limit - Max results 15 + * @returns {Promise<Array>} Matching notes 16 + */ 17 + const searchNotes = async (query, limit = 20) => { 18 + // Query all text items 19 + const result = await api.datastore.queryItems({ type: 'text' }); 20 + 21 + if (!result.success) { 22 + console.error('[edit] Failed to query items:', result.error); 23 + return []; 24 + } 25 + 26 + const items = result.data || []; 27 + 28 + // If no query, return most recent items 29 + if (!query || !query.trim()) { 30 + return items.slice(0, limit); 31 + } 32 + 33 + // Filter by content match (case-insensitive) 34 + const queryLower = query.toLowerCase(); 35 + const matches = items.filter(item => { 36 + const content = (item.content || '').toLowerCase(); 37 + return content.includes(queryLower); 38 + }); 39 + 40 + return matches.slice(0, limit); 41 + }; 42 + 43 + /** 44 + * Format note for dropdown display 45 + * @param {Object} note - Note item 46 + * @returns {Object} Formatted for dropdown 47 + */ 48 + const formatNoteForDropdown = (note) => { 49 + const content = note.content || ''; 50 + // First line as title, rest as preview 51 + const lines = content.split('\n'); 52 + const title = lines[0].replace(/^#+ /, '').trim() || 'Untitled'; 53 + const preview = lines.slice(1).join(' ').trim().substring(0, 100); 54 + 55 + return { 56 + id: note.id, 57 + title: title.substring(0, 50) + (title.length > 50 ? '...' : ''), 58 + description: preview + (preview.length === 100 ? '...' : ''), 59 + content: content 60 + }; 61 + }; 62 + 63 + // Commands 64 + const commands = [ 65 + { 66 + name: 'edit', 67 + description: 'Edit a note', 68 + // Accept search text, produce selection 69 + accepts: ['text'], 70 + produces: ['item'], 71 + 72 + async execute(ctx) { 73 + const query = ctx.search || ''; 74 + 75 + // Search for matching notes 76 + const notes = await searchNotes(query, 20); 77 + 78 + if (notes.length === 0) { 79 + return { 80 + success: false, 81 + message: query ? `No notes found matching "${query}"` : 'No notes found' 82 + }; 83 + } 84 + 85 + // If exactly one match and query is specific, open directly 86 + if (notes.length === 1 && query.trim()) { 87 + const note = notes[0]; 88 + api.publish('editor:open', { itemId: note.id }, api.scopes.GLOBAL); 89 + return { 90 + success: true, 91 + message: 'Opening note', 92 + data: note 93 + }; 94 + } 95 + 96 + // Return dropdown items for selection 97 + const dropdownItems = notes.map(formatNoteForDropdown); 98 + 99 + return { 100 + success: true, 101 + dropdown: { 102 + items: dropdownItems, 103 + placeholder: 'Select a note to edit', 104 + onSelect: (selected) => { 105 + // This is called when user selects from dropdown 106 + api.publish('editor:open', { itemId: selected.id }, api.scopes.GLOBAL); 107 + return { success: true, message: 'Opening note' }; 108 + } 109 + } 110 + }; 111 + } 112 + } 113 + ]; 114 + 115 + export default { 116 + commands 117 + };
+2
extensions/cmd/commands/index.js
··· 7 7 */ 8 8 import debugCommand from './debug.js'; 9 9 import noteModule from './note.js'; 10 + import editModule from './edit.js'; 10 11 import urlModule from './url.js'; 11 12 import historyModule from './history.js'; 12 13 ··· 34 35 const activeCommands = [ 35 36 debugCommand, 36 37 ...noteModule.commands, 38 + ...editModule.commands, 37 39 ...urlModule.commands, 38 40 ...historyModule.commands, 39 41
+4 -1
extensions/editor/background.js
··· 72 72 // Register global shortcut Option+e 73 73 api.shortcuts.register('Option+e', () => openEditor()); 74 74 75 - // Subscribe to editor:open — open editor with optional content 75 + // Subscribe to editor:open — open editor with optional content or itemId 76 76 api.subscribe('editor:open', (msg) => { 77 77 const params = {}; 78 78 if (msg?.content) { ··· 80 80 } 81 81 if (msg?.file) { 82 82 params.file = msg.file; 83 + } 84 + if (msg?.itemId) { 85 + params.itemId = msg.itemId; 83 86 } 84 87 openEditor(Object.keys(params).length > 0 ? params : undefined); 85 88 }, api.scopes.GLOBAL);
+19
extensions/editor/editor-layout.js
··· 89 89 sidebarToggles.appendChild(this.focusBtn); 90 90 91 91 this.toolbar.appendChild(sidebarToggles); 92 + 93 + // Save status indicator (shown when editing datastore items) 94 + this.saveStatus = document.createElement('span'); 95 + this.saveStatus.id = 'save-status'; 96 + this.saveStatus.className = 'save-status save-status-saved'; 97 + this.saveStatus.textContent = 'Saved'; 98 + this.saveStatus.style.display = 'none'; // Hidden until editing an item 99 + this.toolbar.appendChild(this.saveStatus); 100 + 92 101 this.editorContainer.appendChild(this.toolbar); 93 102 94 103 this.wrapper.appendChild(this.editorContainer); ··· 464 473 focus() { 465 474 if (this.cmEditor) { 466 475 CodeMirror.focus(this.cmEditor); 476 + } 477 + } 478 + 479 + /** 480 + * Show/hide save status indicator. 481 + * @param {boolean} visible - Whether to show the indicator 482 + */ 483 + setSaveStatusVisible(visible) { 484 + if (this.saveStatus) { 485 + this.saveStatus.style.display = visible ? 'inline-block' : 'none'; 467 486 } 468 487 } 469 488
+28
extensions/editor/home.css
··· 405 405 background: var(--base0D); 406 406 color: var(--base00); 407 407 } 408 + 409 + /* ═══════════════════════════════════════════════════════════════════ 410 + Save Status Indicator 411 + ═══════════════════════════════════════════════════════════════════ */ 412 + 413 + .save-status { 414 + font-size: 11px; 415 + font-weight: 500; 416 + padding: 2px 8px; 417 + border-radius: 3px; 418 + user-select: none; 419 + transition: color 0.2s, background 0.2s; 420 + } 421 + 422 + .save-status-saved { 423 + color: var(--base0B); 424 + background: transparent; 425 + } 426 + 427 + .save-status-saving { 428 + color: var(--base0A); 429 + background: transparent; 430 + } 431 + 432 + .save-status-unsaved { 433 + color: var(--base08); 434 + background: var(--base01); 435 + }
+126 -1
extensions/editor/home.js
··· 7 7 * - Preview sidebar (live markdown rendering) 8 8 * - Resizable panels 9 9 * - Focus mode 10 + * - Autosave for datastore items 10 11 */ 11 12 12 13 import { EditorLayout } from './editor-layout.js'; ··· 19 20 20 21 // Settings key for vim mode preference 21 22 const SETTINGS_KEY = 'editor.vimMode'; 23 + 24 + // Autosave state 25 + let currentItemId = null; 26 + let saveStatus = 'saved'; // 'saved' | 'saving' | 'unsaved' 27 + let saveTimer = null; 28 + const AUTOSAVE_DELAY_MS = 1500; // 1.5 seconds after typing stops 22 29 23 30 /** 24 31 * Sample markdown content for testing folding features. ··· 148 155 debug && console.log('[editor] Failed to load settings:', err); 149 156 } 150 157 151 - // Check URL params for content or file path 158 + // Check URL params for content, file path, or itemId 152 159 const params = new URLSearchParams(window.location.search); 153 160 const contentParam = params.get('content'); 154 161 const fileParam = params.get('file'); 162 + const itemIdParam = params.get('itemId'); 155 163 156 164 let initialContent = SAMPLE_CONTENT; 157 165 ··· 172 180 } 173 181 } 174 182 183 + // If itemId provided, load from datastore 184 + if (itemIdParam && api?.datastore?.getItem) { 185 + try { 186 + const result = await api.datastore.getItem(itemIdParam); 187 + if (result.success && result.data) { 188 + initialContent = result.data.content || ''; 189 + currentItemId = itemIdParam; 190 + debug && console.log('[editor] Loaded item from datastore:', itemIdParam); 191 + 192 + // Set context to editor mode 193 + if (api?.context?.setMode) { 194 + api.context.setMode('editor', { metadata: { itemId: itemIdParam } }); 195 + } 196 + } else { 197 + console.error('[editor] Failed to load item:', result.error); 198 + } 199 + } catch (err) { 200 + console.error('[editor] Failed to load item:', err); 201 + } 202 + } 203 + 175 204 // Create editor layout 176 205 editorLayout = new EditorLayout({ 177 206 container: rootEl, ··· 180 209 onContentChange: handleContentChange, 181 210 onVimModeChange: handleVimModeChange, 182 211 }); 212 + 213 + // Show save status indicator if editing a datastore item 214 + if (currentItemId) { 215 + editorLayout.setSaveStatusVisible(true); 216 + } 183 217 184 218 // Set up escape handler 185 219 if (api?.escape) { ··· 196 230 }; 197 231 198 232 /** 233 + * Update save status indicator in the UI 234 + */ 235 + const updateSaveStatusUI = () => { 236 + const statusEl = document.getElementById('save-status'); 237 + if (!statusEl) return; 238 + 239 + statusEl.className = `save-status save-status-${saveStatus}`; 240 + switch (saveStatus) { 241 + case 'saved': 242 + statusEl.textContent = 'Saved'; 243 + break; 244 + case 'saving': 245 + statusEl.textContent = 'Saving...'; 246 + break; 247 + case 'unsaved': 248 + statusEl.textContent = 'Unsaved'; 249 + break; 250 + } 251 + }; 252 + 253 + /** 254 + * Perform autosave to datastore 255 + */ 256 + const performAutosave = async (content) => { 257 + if (!currentItemId || !api?.datastore?.updateItem) { 258 + return; 259 + } 260 + 261 + saveStatus = 'saving'; 262 + updateSaveStatusUI(); 263 + 264 + try { 265 + const result = await api.datastore.updateItem(currentItemId, { content }); 266 + if (result.success) { 267 + saveStatus = 'saved'; 268 + debug && console.log('[editor] Autosaved item:', currentItemId); 269 + 270 + // Publish change event 271 + if (api?.publish) { 272 + api.publish('editor:changed', { action: 'update', itemId: currentItemId }, api.scopes.GLOBAL); 273 + } 274 + } else { 275 + console.error('[editor] Autosave failed:', result.error); 276 + saveStatus = 'unsaved'; 277 + } 278 + } catch (err) { 279 + console.error('[editor] Autosave error:', err); 280 + saveStatus = 'unsaved'; 281 + } 282 + 283 + updateSaveStatusUI(); 284 + }; 285 + 286 + /** 287 + * Schedule autosave with debouncing 288 + */ 289 + const scheduleAutosave = (content) => { 290 + if (!currentItemId) return; 291 + 292 + // Mark as unsaved 293 + saveStatus = 'unsaved'; 294 + updateSaveStatusUI(); 295 + 296 + // Clear existing timer 297 + if (saveTimer) { 298 + clearTimeout(saveTimer); 299 + } 300 + 301 + // Schedule save after delay 302 + saveTimer = setTimeout(() => { 303 + performAutosave(content); 304 + }, AUTOSAVE_DELAY_MS); 305 + }; 306 + 307 + /** 199 308 * Handle content changes 200 309 */ 201 310 const handleContentChange = (content) => { ··· 203 312 if (api?.publish) { 204 313 api.publish('editor:contentChanged', { content }, api.scopes.GLOBAL); 205 314 } 315 + 316 + // Schedule autosave if editing a datastore item 317 + if (currentItemId) { 318 + scheduleAutosave(content); 319 + } 206 320 }; 207 321 208 322 /** ··· 221 335 * Clean up on unload 222 336 */ 223 337 const cleanup = () => { 338 + // Clear any pending autosave timer 339 + if (saveTimer) { 340 + clearTimeout(saveTimer); 341 + saveTimer = null; 342 + } 343 + 344 + // Reset context when leaving editor 345 + if (currentItemId && api?.context?.setMode) { 346 + api.context.setMode('default'); 347 + } 348 + 224 349 if (editorLayout) { 225 350 editorLayout.destroy(); 226 351 editorLayout = null;