experiments in a post-browser web
10
fork

Configure Feed

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

test(editor): add tests for notes/highlights system changes

Add unit tests for highlight serialization/deserialization with empty text
fields, line-anchored highlights, round-trip validation, and edge cases.

Add Playwright tests for:
- Cmd+Shift+H toggles notes pane
- Cmd+Shift+L with selection creates highlight with empty text
- Cmd+Shift+L without selection anchors to current line
- Notes pane shows 'Line N — highlight' for empty notes
- Notes pane shows 'Line N' with note text for annotated highlights
- Notes pane has Edit button
- Cmd+Shift+L auto-opens notes pane
- Notes pane title says 'Notes'

Update test-layout-page.html with annotation CSS styles and test helpers
for programmatic highlight creation, selection, and inspection.

+2497 -47
+1 -1
backend/electron/main.ts
··· 74 74 75 75 // Built-in extensions that load in consolidated mode (iframes) 76 76 // External extensions (including 'example') load in separate windows 77 - const CONSOLIDATED_EXTENSION_IDS = ['cmd', 'editor', 'groups', 'hud', 'localsearch', 'peeks', 'search', 'slides', 'websearch', 'windows', 'page', 'files', 'pagestream', 'sheets', 'tags', 'feeds', 'helpdocs', 'entities', 'scripts']; 77 + const CONSOLIDATED_EXTENSION_IDS = ['cmd', 'editor', 'groups', 'hud', 'localsearch', 'peeks', 'search', 'slides', 'websearch', 'windows', 'page', 'files', 'pagestream', 'sheets', 'tags', 'feeds', 'helpdocs', 'entities', 'scripts', 'timers']; 78 78 79 79 // Extensions that must load eagerly (not lazy) — needed at startup 80 80 const EAGER_EXTENSION_IDS = new Set(['cmd', 'hud']);
+45 -14
extensions/editor/annotations-pane.js
··· 10 10 this.onAnnotationClick = options.onAnnotationClick; // (highlight) => void 11 11 this.onAnnotationDelete = options.onAnnotationDelete; // (highlightId) => void 12 12 this.onAnnotationEdit = options.onAnnotationEdit; // (highlightId, note) => void 13 + this.onAddNote = options.onAddNote; // () => void — trigger highlight from selection 14 + this.onToggle = options.onToggle; // () => void — called when internal toggle clicked 13 15 this.collapsed = true; // Start collapsed 14 16 this.highlights = []; 15 17 ··· 22 24 23 25 const title = document.createElement('span'); 24 26 title.className = 'sidebar-title'; 25 - title.textContent = 'Annotations'; 27 + title.textContent = 'Notes'; 26 28 this.header.appendChild(title); 27 29 28 30 const countBadge = document.createElement('span'); ··· 31 33 this.countBadge = countBadge; 32 34 this.header.appendChild(countBadge); 33 35 36 + // Add Note button 37 + this.addNoteBtn = document.createElement('button'); 38 + this.addNoteBtn.className = 'annotation-add-btn'; 39 + this.addNoteBtn.textContent = '+'; 40 + this.addNoteBtn.title = 'Add note at selection (Cmd+Shift+L)'; 41 + this.addNoteBtn.tabIndex = -1; 42 + this.addNoteBtn.addEventListener('mousedown', (e) => e.preventDefault()); 43 + this.addNoteBtn.addEventListener('click', () => { 44 + if (this.onAddNote) this.onAddNote(); 45 + }); 46 + this.header.appendChild(this.addNoteBtn); 47 + 34 48 this.toggleBtn = document.createElement('button'); 35 49 this.toggleBtn.className = 'sidebar-toggle'; 36 50 this.toggleBtn.innerHTML = '\u25B6'; // > 37 51 this.toggleBtn.tabIndex = -1; 38 52 this.toggleBtn.addEventListener('mousedown', (e) => e.preventDefault()); 39 - this.toggleBtn.addEventListener('click', () => this.toggle()); 53 + this.toggleBtn.addEventListener('click', () => { 54 + if (this.onToggle) { 55 + this.onToggle(); 56 + } else { 57 + this.toggle(); 58 + } 59 + }); 40 60 this.header.appendChild(this.toggleBtn); 41 61 42 62 this.element.appendChild(this.header); ··· 52 72 /** 53 73 * Update the displayed highlights. 54 74 * @param {Array} highlights - Array of {id, from, to, text, note, color} 75 + * @param {object} [doc] - CodeMirror doc for line number lookup 55 76 */ 56 - update(highlights) { 77 + update(highlights, doc) { 57 78 this.highlights = highlights || []; 79 + this.doc = doc || this.doc; 58 80 this.countBadge.textContent = String(this.highlights.length); 59 81 this.render(); 60 82 } ··· 65 87 if (this.highlights.length === 0) { 66 88 const empty = document.createElement('div'); 67 89 empty.className = 'annotations-empty'; 68 - empty.textContent = 'No highlights yet. Select text and use Cmd+H to highlight.'; 90 + empty.innerHTML = 'No notes yet.<br><br>Select text or place cursor, then click <strong>+</strong> or press <strong>\u2318\u21E7L</strong> to add a note.'; 69 91 this.content.appendChild(empty); 70 92 return; 71 93 } ··· 83 105 colorDot.style.background = hl.color || 'var(--base0A)'; 84 106 item.appendChild(colorDot); 85 107 86 - // Text content 108 + // Line reference + user note 87 109 const textWrap = document.createElement('div'); 88 110 textWrap.className = 'annotation-text-wrap'; 89 111 90 - const text = document.createElement('div'); 91 - text.className = 'annotation-text'; 92 - text.textContent = hl.text.length > 80 ? hl.text.substring(0, 80) + '...' : hl.text; 93 - textWrap.appendChild(text); 112 + // Show line number 113 + const lineRef = document.createElement('div'); 114 + lineRef.className = 'annotation-text'; 115 + let lineNum = '?'; 116 + try { 117 + if (this.doc) lineNum = this.doc.lineAt(hl.from).number; 118 + } catch (e) { /* position may be stale */ } 119 + lineRef.textContent = hl.note ? `Line ${lineNum}` : `Line ${lineNum} — highlight`; 120 + textWrap.appendChild(lineRef); 94 121 95 122 if (hl.note) { 96 123 const note = document.createElement('div'); ··· 107 134 108 135 const editBtn = document.createElement('button'); 109 136 editBtn.className = 'annotation-btn'; 110 - editBtn.textContent = 'Note'; 111 - editBtn.title = 'Edit note'; 137 + editBtn.textContent = 'Edit'; 138 + editBtn.title = 'Edit note text'; 112 139 editBtn.addEventListener('mousedown', (e) => e.preventDefault()); 113 140 editBtn.addEventListener('click', (e) => { 114 141 e.stopPropagation(); ··· 151 178 } 152 179 153 180 toggle() { 154 - this.collapsed = !this.collapsed; 181 + this.setCollapsed(!this.collapsed); 182 + } 183 + 184 + setCollapsed(collapsed) { 185 + this.collapsed = collapsed; 155 186 this.element.classList.toggle('collapsed', this.collapsed); 156 187 157 188 if (this.collapsed) { 158 189 this.toggleBtn.innerHTML = '\u25B6'; 159 - this.toggleBtn.title = 'Show Annotations'; 190 + this.toggleBtn.title = 'Show Notes'; 160 191 } else { 161 192 this.toggleBtn.innerHTML = '\u25C0'; 162 - this.toggleBtn.title = 'Hide Annotations'; 193 + this.toggleBtn.title = 'Hide Notes'; 163 194 } 164 195 } 165 196
+4 -4
extensions/editor/codemirror.js
··· 550 550 }, 551 551 '&.cm-focused': { 552 552 outline: 'none', 553 - borderColor: 'var(--base0D)', 553 + borderColor: 'var(--base02)', 554 554 }, 555 555 '.cm-content': { 556 556 padding: '10px 14px', ··· 560 560 borderLeftColor: 'var(--base05)', 561 561 }, 562 562 '.cm-selectionBackground, ::selection': { 563 - backgroundColor: 'color-mix(in srgb, var(--base0D) 35%, var(--base02))', 563 + backgroundColor: 'color-mix(in srgb, var(--base0D) 45%, var(--base02))', 564 564 }, 565 565 '&.cm-focused .cm-selectionBackground': { 566 - backgroundColor: 'color-mix(in srgb, var(--base0D) 40%, var(--base01))', 566 + backgroundColor: 'color-mix(in srgb, var(--base0D) 55%, var(--base02))', 567 567 }, 568 568 '.cm-activeLine': { 569 - backgroundColor: 'var(--base02)', 569 + backgroundColor: 'color-mix(in srgb, var(--base01) 50%, transparent)', 570 570 }, 571 571 '.cm-activeLineGutter': { 572 572 backgroundColor: 'var(--base02)',
+27 -24
extensions/editor/editor-layout.js
··· 67 67 68 68 // Pane state — outline and preview start collapsed 69 69 this.paneVisibility = { outline: true, editor: true, preview: true, annotations: false }; 70 - this.paneCollapsed = { outline: true, preview: true }; // collapsed = narrow strip, not hidden 70 + this.paneCollapsed = { outline: true, preview: true }; 71 71 this.paneOrder = ['outline', 'editor', 'preview']; // annotations always far right 72 72 this.paneWidths = {}; // user-resized widths, persisted 73 73 this.maximizedPane = null; // null or pane name ··· 120 120 container: document.createElement('div'), // temporary, will reparent 121 121 onHeaderClick: (header) => this.jumpToHeader(header), 122 122 onFoldToggle: (header, shouldFold) => this.handleOutlineFoldToggle(header, shouldFold), 123 + onToggle: () => this.togglePane('outline'), 123 124 }); 124 125 125 126 // Create editor container (center) ··· 153 154 this.previewToggleBtn = this._createToggleBtn('Preview', 'Toggle preview (Cmd+Shift+P)', () => this.togglePane('preview')); 154 155 sidebarToggles.appendChild(this.previewToggleBtn); 155 156 156 - this.annotationsToggleBtn = this._createToggleBtn('Notes', 'Toggle annotations (Cmd+Shift+H)', () => this.togglePane('annotations')); 157 + this.annotationsToggleBtn = this._createToggleBtn('Notes', 'Toggle notes (Cmd+Shift+H)', () => this.togglePane('annotations')); 157 158 sidebarToggles.appendChild(this.annotationsToggleBtn); 158 159 159 160 // Pane order cycle button ··· 188 189 // Create preview sidebar 189 190 this.previewSidebar = new PreviewSidebar({ 190 191 container: document.createElement('div'), // temporary 192 + onToggle: () => this.togglePane('preview'), 191 193 }); 192 194 193 195 // Create annotations pane (always far right) ··· 196 198 onAnnotationClick: (hl) => this._jumpToHighlight(hl), 197 199 onAnnotationDelete: (id) => this._removeHighlight(id), 198 200 onAnnotationEdit: (id, note) => this._updateHighlightNote(id, note), 201 + onAddNote: () => this._addHighlightFromSelection(), 202 + onToggle: () => this.togglePane('annotations'), 199 203 }); 200 204 201 205 // Build pane layout ··· 383 387 const rightEl = this._getPaneElement(rightPaneName); 384 388 if (!leftEl || !rightEl) return; 385 389 386 - // Determine which pane to resize (the fixed-width one, not the flex:1 editor) 387 390 const leftIsEditor = leftPaneName === 'editor'; 388 - const rightIsEditor = rightPaneName === 'editor'; 389 - // If editor is on the left, resize the right pane (invert delta) 390 - // If editor is on the right (or neither is editor), resize the left pane 391 391 const targetEl = leftIsEditor ? rightEl : leftEl; 392 - const invertDelta = leftIsEditor; // dragging right should shrink the right pane 392 + const invertDelta = leftIsEditor; 393 + const targetPaneName = leftIsEditor ? rightPaneName : leftPaneName; 393 394 394 395 resizer.classList.add('dragging'); 395 396 const startX = startEvent.clientX; ··· 404 405 targetEl.style.width = `${newWidth}px`; 405 406 targetEl.style.minWidth = `${newWidth}px`; 406 407 }; 407 - 408 - const targetPaneName = leftIsEditor ? rightPaneName : leftPaneName; 409 408 410 409 const onMouseUp = () => { 411 410 resizer.classList.remove('dragging'); ··· 524 523 525 524 if (name === 'outline') { 526 525 const collapsed = this.paneCollapsed.outline; 527 - el.classList.toggle('collapsed', collapsed); 528 - this.outlineSidebar.collapsed = collapsed; 526 + this.outlineSidebar.setCollapsed(collapsed); 529 527 if (!collapsed) { 530 - // Apply user width or CSS default 531 528 if (this.paneWidths.outline) { 532 529 el.style.width = `${this.paneWidths.outline}px`; 533 530 el.style.minWidth = '80px'; ··· 541 538 } 542 539 } else if (name === 'preview') { 543 540 const collapsed = this.paneCollapsed.preview; 544 - el.classList.toggle('collapsed', collapsed); 545 - this.previewSidebar.collapsed = collapsed; 541 + this.previewSidebar.setCollapsed(collapsed); 546 542 if (!collapsed) { 547 543 if (this.paneWidths.preview) { 548 544 el.style.width = `${this.paneWidths.preview}px`; ··· 684 680 e.preventDefault(); 685 681 this.togglePane('preview'); 686 682 } 687 - // Cmd+Shift+H: Toggle annotations 683 + // Cmd+Shift+H: Toggle notes pane 688 684 if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'h') { 689 685 e.preventDefault(); 690 686 this.togglePane('annotations'); 691 687 } 692 - // Cmd+H: Add highlight on selected text 693 - if ((e.metaKey || e.ctrlKey) && e.key === 'h' && !e.shiftKey) { 688 + // Cmd+Shift+L: Add note at selection/cursor 689 + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'l') { 694 690 e.preventDefault(); 695 691 this._addHighlightFromSelection(); 696 692 } ··· 708 704 709 705 const state = this.cmEditor.state; 710 706 const sel = state.selection.main; 711 - if (sel.from === sel.to) return; // No selection 707 + 708 + // If no selection, anchor to the current line 709 + let from = sel.from; 710 + let to = sel.to; 711 + if (from === to) { 712 + const line = state.doc.lineAt(from); 713 + from = line.from; 714 + to = line.to; 715 + } 712 716 713 - const text = state.doc.sliceString(sel.from, sel.to); 714 717 const highlight = { 715 718 id: generateHighlightId(), 716 - from: sel.from, 717 - to: sel.to, 718 - text, 719 + from, 720 + to, 721 + text: '', // notes don't store selected text 719 722 note: '', 720 723 color: nextHighlightColor(), 721 724 }; ··· 724 727 this._syncAnnotationsPane(); 725 728 this._persistHighlights(); 726 729 727 - // Auto-show annotations pane 730 + // Auto-show notes pane 728 731 if (!this.paneVisibility.annotations) { 729 732 this.togglePane('annotations'); 730 733 } ··· 756 759 _syncAnnotationsPane() { 757 760 if (!this.cmEditor || !this.annotationsPane) return; 758 761 const highlights = getHighlights(this.cmEditor.state); 759 - this.annotationsPane.update(highlights); 762 + this.annotationsPane.update(highlights, this.cmEditor.state.doc); 760 763 } 761 764 762 765 _persistHighlights() {
+24
extensions/editor/home.css
··· 404 404 display: none; 405 405 } 406 406 407 + .annotations-pane.collapsed .annotation-add-btn { 408 + display: none; 409 + } 410 + 407 411 .annotations-pane.collapsed .sidebar-header { 408 412 justify-content: center; 409 413 padding: 8px 4px; ··· 417 421 border-radius: 8px; 418 422 min-width: 18px; 419 423 text-align: center; 424 + } 425 + 426 + .annotation-add-btn { 427 + background: none; 428 + border: 1px solid var(--base02); 429 + color: var(--base04); 430 + cursor: pointer; 431 + padding: 0 6px; 432 + font-size: 14px; 433 + font-weight: 600; 434 + line-height: 1; 435 + border-radius: 3px; 436 + transition: all 0.1s; 437 + flex-shrink: 0; 438 + margin-left: auto; 439 + } 440 + 441 + .annotation-add-btn:hover { 442 + background: var(--base02); 443 + color: var(--base05); 420 444 } 421 445 422 446 .annotations-content {
+13 -2
extensions/editor/outline-sidebar.js
··· 74 74 this.container = options.container; 75 75 this.onHeaderClick = options.onHeaderClick; 76 76 this.onFoldToggle = options.onFoldToggle; // callback: (header, shouldFold) => void 77 + this.onToggle = options.onToggle; // callback: () => void — called when internal toggle clicked 77 78 this.collapsed = false; 78 79 this.headers = []; 79 80 this.parentSet = new Set(); ··· 128 129 this.toggleBtn.innerHTML = '\u25C0'; // left arrow 129 130 this.toggleBtn.tabIndex = -1; 130 131 this.toggleBtn.addEventListener('mousedown', (e) => e.preventDefault()); 131 - this.toggleBtn.addEventListener('click', () => this.toggle()); 132 + this.toggleBtn.addEventListener('click', () => { 133 + if (this.onToggle) { 134 + this.onToggle(); 135 + } else { 136 + this.toggle(); 137 + } 138 + }); 132 139 this.header.appendChild(this.toggleBtn); 133 140 134 141 this.element.appendChild(this.header); ··· 394 401 * Toggle sidebar collapsed state. 395 402 */ 396 403 toggle() { 397 - this.collapsed = !this.collapsed; 404 + this.setCollapsed(!this.collapsed); 405 + } 406 + 407 + setCollapsed(collapsed) { 408 + this.collapsed = collapsed; 398 409 this.element.classList.toggle('collapsed', this.collapsed); 399 410 400 411 if (this.collapsed) {
+13 -2
extensions/editor/preview-sidebar.js
··· 106 106 export class PreviewSidebar { 107 107 constructor(options) { 108 108 this.container = options.container; 109 + this.onToggle = options.onToggle; // callback: () => void — called when internal toggle clicked 109 110 this.collapsed = false; 110 111 111 112 // Create sidebar element ··· 126 127 this.toggleBtn.innerHTML = '\u25B6'; // ▶ 127 128 this.toggleBtn.tabIndex = -1; 128 129 this.toggleBtn.addEventListener('mousedown', (e) => e.preventDefault()); 129 - this.toggleBtn.addEventListener('click', () => this.toggle()); 130 + this.toggleBtn.addEventListener('click', () => { 131 + if (this.onToggle) { 132 + this.onToggle(); 133 + } else { 134 + this.toggle(); 135 + } 136 + }); 130 137 this.header.appendChild(this.toggleBtn); 131 138 132 139 this.element.appendChild(this.header); ··· 151 158 * Toggle sidebar collapsed state. 152 159 */ 153 160 toggle() { 154 - this.collapsed = !this.collapsed; 161 + this.setCollapsed(!this.collapsed); 162 + } 163 + 164 + setCollapsed(collapsed) { 165 + this.collapsed = collapsed; 155 166 this.element.classList.toggle('collapsed', this.collapsed); 156 167 157 168 if (this.collapsed) {
+46
extensions/timers/background.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6 + <title>Timers Extension</title> 7 + </head> 8 + <body> 9 + <script type="module"> 10 + import extension from './background.js'; 11 + 12 + const api = window.app; 13 + const extId = extension.id; 14 + 15 + // Initialize extension BEFORE signaling ready 16 + // (lazy loading waits for ext:ready, so handlers must be registered first) 17 + if (extension.init) { 18 + await extension.init(); 19 + } 20 + 21 + // Signal ready to main process 22 + api.publish('ext:ready', { 23 + id: extId, 24 + manifest: { 25 + id: extension.id, 26 + labels: extension.labels, 27 + version: '1.0.0' 28 + } 29 + }, api.scopes.SYSTEM); 30 + 31 + // Handle shutdown request from main process 32 + api.subscribe('app:shutdown', () => { 33 + if (extension.uninit) { 34 + extension.uninit(); 35 + } 36 + }, api.scopes.SYSTEM); 37 + 38 + // Handle extension-specific shutdown 39 + api.subscribe(`ext:${extId}:shutdown`, () => { 40 + if (extension.uninit) { 41 + extension.uninit(); 42 + } 43 + }, api.scopes.SYSTEM); 44 + </script> 45 + </body> 46 + </html>
+927
extensions/timers/background.js
··· 1 + /** 2 + * Timers Extension Background Script 3 + * 4 + * Timer engine: countdown, alarm, stopwatch, and interval timers. 5 + * Stores timers as items in the datastore with type "timer". 6 + * Runs a 1-second tick loop that updates elapsed time, checks completions, 7 + * fires notifications, and publishes tick events for the UI. 8 + * 9 + * Commands and shortcuts are declared in manifest.json. 10 + * This extension loads lazily on first command invocation. 11 + * 12 + * Runs in isolated extension process (peek://ext/timers/background.html) 13 + */ 14 + 15 + import { id, labels, schemas, storageKeys, defaults } from './config.js'; 16 + 17 + const api = window.app; 18 + const debug = api.debug; 19 + 20 + // Extension content is served from peek://ext/timers/ 21 + const address = 'peek://ext/timers/home.html'; 22 + 23 + // In-memory timer state: id -> { item, tickState } 24 + const activeTimers = new Map(); 25 + 26 + // Single tick interval reference 27 + let tickInterval = null; 28 + 29 + // Audio context for bell sound (created lazily) 30 + let audioCtx = null; 31 + 32 + // ===== Duration Parsing ===== 33 + 34 + /** 35 + * Parse flexible duration strings into milliseconds. 36 + * Supports: "1hr", "1h", "30m", "30min", "5m30s", "90s", "90sec", "1h30m", "60" (seconds) 37 + * @param {string} str - Duration string 38 + * @returns {number|null} Duration in ms, or null if unparseable 39 + */ 40 + const parseDuration = (str) => { 41 + if (!str) return null; 42 + str = str.trim().toLowerCase(); 43 + 44 + // Just a number — treat as seconds 45 + if (/^\d+$/.test(str)) { 46 + return parseInt(str, 10) * 1000; 47 + } 48 + 49 + let totalMs = 0; 50 + let matched = false; 51 + 52 + // Match hours 53 + const hourMatch = str.match(/(\d+)\s*(?:hr|hours?|h)/); 54 + if (hourMatch) { 55 + totalMs += parseInt(hourMatch[1], 10) * 3600000; 56 + matched = true; 57 + } 58 + 59 + // Match minutes 60 + const minMatch = str.match(/(\d+)\s*(?:min|minutes?|m)(?!s)/); 61 + if (minMatch) { 62 + totalMs += parseInt(minMatch[1], 10) * 60000; 63 + matched = true; 64 + } 65 + 66 + // Match seconds 67 + const secMatch = str.match(/(\d+)\s*(?:sec|seconds?|s)/); 68 + if (secMatch) { 69 + totalMs += parseInt(secMatch[1], 10) * 1000; 70 + matched = true; 71 + } 72 + 73 + return matched ? totalMs : null; 74 + }; 75 + 76 + // ===== Time Parsing (for alarm) ===== 77 + 78 + /** 79 + * Parse a time string into a target epoch ms. 80 + * Supports: "6pm", "14:30", "6:30pm", "6:30am" 81 + * If the time has already passed today, sets it for tomorrow. 82 + * @param {string} str - Time string 83 + * @returns {number|null} Target epoch ms, or null if unparseable 84 + */ 85 + const parseTime = (str) => { 86 + if (!str) return null; 87 + str = str.trim().toLowerCase(); 88 + 89 + let hours = null; 90 + let minutes = 0; 91 + 92 + // Match "6pm", "6am", "6:30pm", "6:30am", "14:30" 93 + const match12 = str.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/); 94 + const match24 = str.match(/^(\d{1,2}):(\d{2})$/); 95 + 96 + if (match12) { 97 + hours = parseInt(match12[1], 10); 98 + minutes = match12[2] ? parseInt(match12[2], 10) : 0; 99 + const period = match12[3]; 100 + if (period === 'pm' && hours !== 12) hours += 12; 101 + if (period === 'am' && hours === 12) hours = 0; 102 + } else if (match24) { 103 + hours = parseInt(match24[1], 10); 104 + minutes = parseInt(match24[2], 10); 105 + } 106 + 107 + if (hours === null || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) { 108 + return null; 109 + } 110 + 111 + const now = new Date(); 112 + const target = new Date(now); 113 + target.setHours(hours, minutes, 0, 0); 114 + 115 + // If the time has already passed today, set for tomorrow 116 + if (target.getTime() <= now.getTime()) { 117 + target.setDate(target.getDate() + 1); 118 + } 119 + 120 + return target.getTime(); 121 + }; 122 + 123 + // ===== Format Helpers ===== 124 + 125 + /** 126 + * Format milliseconds as HH:MM:SS or MM:SS or 0:SS 127 + * @param {number} ms - Milliseconds 128 + * @returns {string} Formatted time string 129 + */ 130 + const formatDuration = (ms) => { 131 + if (ms < 0) ms = 0; 132 + const totalSeconds = Math.floor(ms / 1000); 133 + const hours = Math.floor(totalSeconds / 3600); 134 + const minutes = Math.floor((totalSeconds % 3600) / 60); 135 + const seconds = totalSeconds % 60; 136 + 137 + if (hours > 0) { 138 + return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; 139 + } 140 + return `${minutes}:${String(seconds).padStart(2, '0')}`; 141 + }; 142 + 143 + /** 144 + * Format remaining time until a target timestamp 145 + * @param {number} targetMs - Target epoch ms 146 + * @returns {string} Formatted time remaining 147 + */ 148 + const formatTimeRemaining = (targetMs) => { 149 + const remaining = targetMs - Date.now(); 150 + return formatDuration(remaining); 151 + }; 152 + 153 + /** 154 + * Format a human-readable timer status 155 + * @param {object} timer - Timer metadata 156 + * @returns {string} Status string 157 + */ 158 + const formatTimerStatus = (timer) => { 159 + const meta = timer; 160 + switch (meta.status) { 161 + case 'running': { 162 + if (meta.timerType === 'countdown') { 163 + const remaining = meta.duration - (Date.now() - meta.startedAt); 164 + return `Countdown: ${formatDuration(remaining)} remaining`; 165 + } 166 + if (meta.timerType === 'alarm') { 167 + return `Alarm: ${formatTimeRemaining(meta.targetTime)} until alarm`; 168 + } 169 + if (meta.timerType === 'stopwatch') { 170 + const elapsed = meta.elapsed + (Date.now() - meta.startedAt); 171 + return `Stopwatch: ${formatDuration(elapsed)}`; 172 + } 173 + if (meta.timerType === 'interval') { 174 + const elapsed = Date.now() - meta.startedAt; 175 + const inCycle = elapsed % meta.duration; 176 + return `Interval: ${formatDuration(meta.duration - inCycle)} (${meta.intervalCount} cycles)`; 177 + } 178 + return 'Running'; 179 + } 180 + case 'paused': return 'Paused'; 181 + case 'completed': return 'Completed'; 182 + case 'stopped': return 'Stopped'; 183 + default: return meta.status || 'Unknown'; 184 + } 185 + }; 186 + 187 + // ===== Bell Sound ===== 188 + 189 + /** 190 + * Play a bell tone using Web Audio API (no external file needed) 191 + */ 192 + const playBell = () => { 193 + try { 194 + if (!audioCtx) { 195 + audioCtx = new (window.AudioContext || window.webkitAudioContext)(); 196 + } 197 + const ctx = audioCtx; 198 + 199 + // Bell tone: two overlapping sine waves with decay 200 + const now = ctx.currentTime; 201 + const duration = 0.8; 202 + 203 + // Primary tone 204 + const osc1 = ctx.createOscillator(); 205 + const gain1 = ctx.createGain(); 206 + osc1.type = 'sine'; 207 + osc1.frequency.value = 830; // approximately A5-ish bell tone 208 + gain1.gain.setValueAtTime(0.3, now); 209 + gain1.gain.exponentialRampToValueAtTime(0.001, now + duration); 210 + osc1.connect(gain1); 211 + gain1.connect(ctx.destination); 212 + osc1.start(now); 213 + osc1.stop(now + duration); 214 + 215 + // Harmonic overtone 216 + const osc2 = ctx.createOscillator(); 217 + const gain2 = ctx.createGain(); 218 + osc2.type = 'sine'; 219 + osc2.frequency.value = 1660; // octave above 220 + gain2.gain.setValueAtTime(0.15, now); 221 + gain2.gain.exponentialRampToValueAtTime(0.001, now + duration * 0.6); 222 + osc2.connect(gain2); 223 + gain2.connect(ctx.destination); 224 + osc2.start(now); 225 + osc2.stop(now + duration); 226 + 227 + // Third harmonic for richness 228 + const osc3 = ctx.createOscillator(); 229 + const gain3 = ctx.createGain(); 230 + osc3.type = 'sine'; 231 + osc3.frequency.value = 2490; 232 + gain3.gain.setValueAtTime(0.08, now); 233 + gain3.gain.exponentialRampToValueAtTime(0.001, now + duration * 0.4); 234 + osc3.connect(gain3); 235 + gain3.connect(ctx.destination); 236 + osc3.start(now); 237 + osc3.stop(now + duration); 238 + } catch (err) { 239 + debug && console.log('[ext:timers] Bell playback failed:', err); 240 + } 241 + }; 242 + 243 + // ===== Timer Data Operations ===== 244 + 245 + /** 246 + * Create a timer item in the datastore 247 + * @param {object} opts - Timer options 248 + * @returns {object|null} Created item or null 249 + */ 250 + const createTimerItem = async (opts) => { 251 + const metadata = { 252 + timerType: opts.timerType, 253 + duration: opts.duration || 0, 254 + targetTime: opts.targetTime || 0, 255 + startedAt: Date.now(), 256 + stoppedAt: null, 257 + pausedAt: null, 258 + elapsed: 0, 259 + intervalCount: 0, 260 + status: 'running', 261 + notification: { 262 + type: 'system', 263 + sound: null 264 + } 265 + }; 266 + 267 + const result = await api.datastore.addItem('timer', { 268 + content: '', 269 + title: opts.title || `${opts.timerType} timer`, 270 + metadata: JSON.stringify(metadata) 271 + }); 272 + 273 + if (!result.success) { 274 + console.error('[ext:timers] Failed to create timer:', result.error); 275 + return null; 276 + } 277 + 278 + const item = { 279 + id: result.data.id, 280 + title: opts.title || `${opts.timerType} timer`, 281 + metadata 282 + }; 283 + 284 + // Track in active timers 285 + activeTimers.set(item.id, item); 286 + 287 + debug && console.log('[ext:timers] Created timer:', item.id, metadata.timerType); 288 + return item; 289 + }; 290 + 291 + /** 292 + * Update timer metadata in the datastore 293 + * @param {string} timerId - Timer item ID 294 + * @param {object} metadata - Updated metadata 295 + */ 296 + const updateTimerMetadata = async (timerId, metadata) => { 297 + try { 298 + await api.datastore.updateItem(timerId, { 299 + metadata: JSON.stringify(metadata) 300 + }); 301 + } catch (err) { 302 + console.error('[ext:timers] Failed to update timer:', err); 303 + } 304 + }; 305 + 306 + /** 307 + * Complete a timer: update status, fire notification, play bell 308 + * @param {object} timer - Timer object from activeTimers 309 + */ 310 + const completeTimer = async (timer) => { 311 + timer.metadata.status = 'completed'; 312 + timer.metadata.stoppedAt = Date.now(); 313 + await updateTimerMetadata(timer.id, timer.metadata); 314 + 315 + // Remove from active tracking 316 + activeTimers.delete(timer.id); 317 + 318 + // Fire system notification 319 + try { 320 + new Notification(timer.title || 'Timer Complete', { 321 + body: formatTimerStatus(timer.metadata) 322 + }); 323 + } catch (err) { 324 + debug && console.log('[ext:timers] Notification failed:', err); 325 + } 326 + 327 + // Play bell sound 328 + playBell(); 329 + 330 + // Publish completion event 331 + api.publish('timer:completed', { 332 + id: timer.id, 333 + title: timer.title, 334 + timerType: timer.metadata.timerType 335 + }, api.scopes.GLOBAL); 336 + 337 + debug && console.log('[ext:timers] Timer completed:', timer.id); 338 + }; 339 + 340 + /** 341 + * Fire an interval notification (does not complete the timer) 342 + * @param {object} timer - Timer object 343 + */ 344 + const fireIntervalNotification = (timer) => { 345 + timer.metadata.intervalCount++; 346 + 347 + try { 348 + new Notification(timer.title || 'Interval Timer', { 349 + body: `Interval #${timer.metadata.intervalCount} completed` 350 + }); 351 + } catch (err) { 352 + debug && console.log('[ext:timers] Interval notification failed:', err); 353 + } 354 + 355 + playBell(); 356 + 357 + api.publish('timer:interval', { 358 + id: timer.id, 359 + title: timer.title, 360 + count: timer.metadata.intervalCount 361 + }, api.scopes.GLOBAL); 362 + }; 363 + 364 + // ===== Tick Loop ===== 365 + 366 + /** 367 + * Process one tick for all active timers. 368 + * Called every 1 second by the tick interval. 369 + */ 370 + const tick = () => { 371 + const now = Date.now(); 372 + const tickData = []; 373 + 374 + for (const [timerId, timer] of activeTimers) { 375 + const meta = timer.metadata; 376 + if (meta.status !== 'running') continue; 377 + 378 + let display = ''; 379 + let remaining = 0; 380 + 381 + switch (meta.timerType) { 382 + case 'countdown': { 383 + const elapsed = now - meta.startedAt; 384 + remaining = meta.duration - elapsed; 385 + if (remaining <= 0) { 386 + completeTimer(timer); 387 + continue; 388 + } 389 + display = formatDuration(remaining); 390 + break; 391 + } 392 + 393 + case 'alarm': { 394 + remaining = meta.targetTime - now; 395 + if (remaining <= 0) { 396 + completeTimer(timer); 397 + continue; 398 + } 399 + display = formatDuration(remaining); 400 + break; 401 + } 402 + 403 + case 'stopwatch': { 404 + const elapsed = meta.elapsed + (now - meta.startedAt); 405 + display = formatDuration(elapsed); 406 + break; 407 + } 408 + 409 + case 'interval': { 410 + const totalElapsed = now - meta.startedAt; 411 + const expectedIntervals = Math.floor(totalElapsed / meta.duration); 412 + // Fire notification for any new intervals 413 + while (meta.intervalCount < expectedIntervals) { 414 + fireIntervalNotification(timer); 415 + // Update metadata in datastore (fire-and-forget) 416 + updateTimerMetadata(timerId, meta); 417 + } 418 + const inCycle = totalElapsed % meta.duration; 419 + remaining = meta.duration - inCycle; 420 + display = formatDuration(remaining); 421 + break; 422 + } 423 + } 424 + 425 + tickData.push({ 426 + id: timerId, 427 + title: timer.title, 428 + timerType: meta.timerType, 429 + status: meta.status, 430 + display, 431 + remaining, 432 + intervalCount: meta.intervalCount || 0 433 + }); 434 + } 435 + 436 + // Publish tick for UI updates 437 + if (tickData.length > 0) { 438 + api.publish('timer:tick', { timers: tickData }, api.scopes.GLOBAL); 439 + } 440 + 441 + // Update HUD with active timer summary 442 + updateHud(tickData); 443 + }; 444 + 445 + /** 446 + * Start the tick loop if not already running 447 + */ 448 + const startTickLoop = () => { 449 + if (tickInterval) return; 450 + tickInterval = setInterval(tick, 1000); 451 + debug && console.log('[ext:timers] Tick loop started'); 452 + }; 453 + 454 + /** 455 + * Stop the tick loop if no active timers remain 456 + */ 457 + const stopTickLoopIfEmpty = () => { 458 + if (activeTimers.size === 0 && tickInterval) { 459 + clearInterval(tickInterval); 460 + tickInterval = null; 461 + debug && console.log('[ext:timers] Tick loop stopped (no active timers)'); 462 + // Clear HUD 463 + updateHud([]); 464 + } 465 + }; 466 + 467 + // ===== HUD Integration ===== 468 + 469 + /** 470 + * Publish active timer info for HUD display 471 + * @param {Array} tickData - Array of active timer tick info 472 + */ 473 + const updateHud = (tickData) => { 474 + const running = tickData.filter(t => t.status === 'running'); 475 + if (running.length === 0) { 476 + api.publish('timer:hud-update', { text: '' }, api.scopes.GLOBAL); 477 + return; 478 + } 479 + 480 + const displays = running.slice(0, 3).map(t => t.display); 481 + const count = running.length; 482 + const text = count === 1 483 + ? `${running[0].display}` 484 + : `${count} timers: ${displays.join(', ')}`; 485 + api.publish('timer:hud-update', { text, count }, api.scopes.GLOBAL); 486 + }; 487 + 488 + // ===== Restore Running Timers ===== 489 + 490 + /** 491 + * Restore all running timers from the datastore on startup 492 + */ 493 + const restoreTimers = async () => { 494 + try { 495 + const result = await api.datastore.queryItems({ type: 'timer' }); 496 + if (!result.success) return; 497 + 498 + let restoredCount = 0; 499 + for (const item of result.data) { 500 + let meta; 501 + try { 502 + meta = typeof item.metadata === 'string' ? JSON.parse(item.metadata) : item.metadata; 503 + } catch { 504 + continue; 505 + } 506 + 507 + if (!meta || (meta.status !== 'running' && meta.status !== 'paused')) continue; 508 + 509 + // For running countdown/alarm timers, check if they should have completed while offline 510 + if (meta.status === 'running') { 511 + const now = Date.now(); 512 + if (meta.timerType === 'countdown') { 513 + const elapsed = now - meta.startedAt; 514 + if (elapsed >= meta.duration) { 515 + // Completed while extension was unloaded 516 + meta.status = 'completed'; 517 + meta.stoppedAt = meta.startedAt + meta.duration; 518 + await updateTimerMetadata(item.id, meta); 519 + continue; 520 + } 521 + } else if (meta.timerType === 'alarm') { 522 + if (now >= meta.targetTime) { 523 + meta.status = 'completed'; 524 + meta.stoppedAt = meta.targetTime; 525 + await updateTimerMetadata(item.id, meta); 526 + continue; 527 + } 528 + } 529 + } 530 + 531 + activeTimers.set(item.id, { 532 + id: item.id, 533 + title: item.title, 534 + metadata: meta 535 + }); 536 + restoredCount++; 537 + } 538 + 539 + if (restoredCount > 0) { 540 + startTickLoop(); 541 + debug && console.log(`[ext:timers] Restored ${restoredCount} active timer(s)`); 542 + } 543 + } catch (err) { 544 + console.error('[ext:timers] Failed to restore timers:', err); 545 + } 546 + }; 547 + 548 + // ===== Window Management ===== 549 + 550 + let isOpeningTimers = false; 551 + const openTimersWindow = async () => { 552 + if (isOpeningTimers) return; 553 + isOpeningTimers = true; 554 + try { 555 + const params = { 556 + role: 'workspace', 557 + key: address, 558 + height: 600, 559 + width: 700, 560 + trackingSource: 'cmd', 561 + trackingSourceId: 'timers' 562 + }; 563 + 564 + await api.window.open(address, params); 565 + } catch (error) { 566 + console.error('[ext:timers] Failed to open timers window:', error); 567 + } finally { 568 + isOpeningTimers = false; 569 + } 570 + }; 571 + 572 + // ===== Command Handlers ===== 573 + 574 + /** 575 + * Handle the "timers" command — open timers UI 576 + */ 577 + const handleTimers = async () => { 578 + openTimersWindow(); 579 + }; 580 + 581 + /** 582 + * Handle the "timer countdown <duration>" command 583 + */ 584 + const handleCountdown = async (args) => { 585 + const parts = (args || '').trim().split(/\s+/); 586 + // Check for optional name after duration using "as" or final quoted string 587 + let durationStr = ''; 588 + let name = ''; 589 + 590 + // Find where the duration ends and name begins 591 + // Simple heuristic: first part that looks like a duration gets consumed 592 + for (let i = 0; i < parts.length; i++) { 593 + if (!durationStr && parseDuration(parts[i]) !== null) { 594 + durationStr = parts[i]; 595 + } else if (durationStr) { 596 + name = parts.slice(i).join(' '); 597 + break; 598 + } else { 599 + // Could be a duration part, try accumulating 600 + durationStr = parts.slice(0, i + 1).join(''); 601 + if (parseDuration(durationStr) === null) { 602 + // Not a duration, might be name 603 + name = parts.slice(i).join(' '); 604 + durationStr = parts.slice(0, i).join(''); 605 + break; 606 + } 607 + } 608 + } 609 + 610 + if (!durationStr) durationStr = args; 611 + 612 + const duration = parseDuration(durationStr); 613 + if (!duration) { 614 + return { output: `Invalid duration: "${args}". Try: 30m, 1h, 5m30s, 90s`, mimeType: 'text/plain' }; 615 + } 616 + 617 + const title = name || `Countdown ${formatDuration(duration)}`; 618 + const timer = await createTimerItem({ 619 + timerType: 'countdown', 620 + duration, 621 + title 622 + }); 623 + 624 + if (!timer) { 625 + return { output: 'Failed to create timer', mimeType: 'text/plain' }; 626 + } 627 + 628 + startTickLoop(); 629 + return { output: `Started countdown: ${formatDuration(duration)}`, mimeType: 'text/plain' }; 630 + }; 631 + 632 + /** 633 + * Handle the "timer alarm <time>" command 634 + */ 635 + const handleAlarm = async (args) => { 636 + const parts = (args || '').trim().split(/\s+/); 637 + const timeStr = parts[0]; 638 + const name = parts.slice(1).join(' '); 639 + 640 + const targetTime = parseTime(timeStr); 641 + if (!targetTime) { 642 + return { output: `Invalid time: "${timeStr}". Try: 6pm, 14:30, 6:30pm`, mimeType: 'text/plain' }; 643 + } 644 + 645 + const targetDate = new Date(targetTime); 646 + const timeDisplay = targetDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); 647 + const title = name || `Alarm ${timeDisplay}`; 648 + 649 + const timer = await createTimerItem({ 650 + timerType: 'alarm', 651 + targetTime, 652 + title 653 + }); 654 + 655 + if (!timer) { 656 + return { output: 'Failed to create alarm', mimeType: 'text/plain' }; 657 + } 658 + 659 + startTickLoop(); 660 + return { output: `Alarm set for ${timeDisplay}`, mimeType: 'text/plain' }; 661 + }; 662 + 663 + /** 664 + * Handle the "timer stopwatch [name]" command 665 + */ 666 + const handleStopwatch = async (args) => { 667 + const name = (args || '').trim(); 668 + const title = name || 'Stopwatch'; 669 + 670 + const timer = await createTimerItem({ 671 + timerType: 'stopwatch', 672 + title 673 + }); 674 + 675 + if (!timer) { 676 + return { output: 'Failed to create stopwatch', mimeType: 'text/plain' }; 677 + } 678 + 679 + startTickLoop(); 680 + return { output: `Stopwatch started: ${title}`, mimeType: 'text/plain' }; 681 + }; 682 + 683 + /** 684 + * Handle the "timer interval <duration>" command 685 + */ 686 + const handleInterval = async (args) => { 687 + const parts = (args || '').trim().split(/\s+/); 688 + let durationStr = parts[0]; 689 + const name = parts.slice(1).join(' '); 690 + 691 + const duration = parseDuration(durationStr); 692 + if (!duration) { 693 + return { output: `Invalid duration: "${durationStr}". Try: 30m, 1h, 5m30s`, mimeType: 'text/plain' }; 694 + } 695 + 696 + const title = name || `Interval ${formatDuration(duration)}`; 697 + const timer = await createTimerItem({ 698 + timerType: 'interval', 699 + duration, 700 + title 701 + }); 702 + 703 + if (!timer) { 704 + return { output: 'Failed to create interval timer', mimeType: 'text/plain' }; 705 + } 706 + 707 + startTickLoop(); 708 + return { output: `Interval timer started: every ${formatDuration(duration)}`, mimeType: 'text/plain' }; 709 + }; 710 + 711 + // ===== Timer Control (via pubsub for UI) ===== 712 + 713 + const setupPubsubHandlers = () => { 714 + // Pause a timer 715 + api.subscribe('timer:pause', async (msg) => { 716 + const timer = activeTimers.get(msg.id); 717 + if (!timer || timer.metadata.status !== 'running') return; 718 + 719 + const now = Date.now(); 720 + timer.metadata.status = 'paused'; 721 + timer.metadata.pausedAt = now; 722 + 723 + // For stopwatch, accumulate elapsed 724 + if (timer.metadata.timerType === 'stopwatch') { 725 + timer.metadata.elapsed += (now - timer.metadata.startedAt); 726 + } 727 + 728 + await updateTimerMetadata(timer.id, timer.metadata); 729 + api.publish('timer:state-changed', { id: timer.id, status: 'paused' }, api.scopes.GLOBAL); 730 + debug && console.log('[ext:timers] Paused timer:', timer.id); 731 + }, api.scopes.GLOBAL); 732 + 733 + // Resume a timer 734 + api.subscribe('timer:resume', async (msg) => { 735 + const timer = activeTimers.get(msg.id); 736 + if (!timer || timer.metadata.status !== 'paused') return; 737 + 738 + const now = Date.now(); 739 + timer.metadata.status = 'running'; 740 + 741 + if (timer.metadata.timerType === 'stopwatch') { 742 + // Reset startedAt for new running period 743 + timer.metadata.startedAt = now; 744 + } else if (timer.metadata.timerType === 'countdown') { 745 + // Adjust startedAt to account for paused time 746 + const pausedDuration = now - timer.metadata.pausedAt; 747 + timer.metadata.startedAt += pausedDuration; 748 + } else if (timer.metadata.timerType === 'alarm') { 749 + // Adjust targetTime by paused duration 750 + const pausedDuration = now - timer.metadata.pausedAt; 751 + timer.metadata.targetTime += pausedDuration; 752 + } else if (timer.metadata.timerType === 'interval') { 753 + const pausedDuration = now - timer.metadata.pausedAt; 754 + timer.metadata.startedAt += pausedDuration; 755 + } 756 + 757 + timer.metadata.pausedAt = null; 758 + await updateTimerMetadata(timer.id, timer.metadata); 759 + startTickLoop(); 760 + api.publish('timer:state-changed', { id: timer.id, status: 'running' }, api.scopes.GLOBAL); 761 + debug && console.log('[ext:timers] Resumed timer:', timer.id); 762 + }, api.scopes.GLOBAL); 763 + 764 + // Stop a timer 765 + api.subscribe('timer:stop', async (msg) => { 766 + const timer = activeTimers.get(msg.id); 767 + if (!timer) return; 768 + 769 + timer.metadata.status = 'stopped'; 770 + timer.metadata.stoppedAt = Date.now(); 771 + 772 + if (timer.metadata.timerType === 'stopwatch' && timer.metadata.status !== 'paused') { 773 + timer.metadata.elapsed += (Date.now() - timer.metadata.startedAt); 774 + } 775 + 776 + await updateTimerMetadata(timer.id, timer.metadata); 777 + activeTimers.delete(timer.id); 778 + stopTickLoopIfEmpty(); 779 + api.publish('timer:state-changed', { id: timer.id, status: 'stopped' }, api.scopes.GLOBAL); 780 + debug && console.log('[ext:timers] Stopped timer:', timer.id); 781 + }, api.scopes.GLOBAL); 782 + 783 + // Delete a timer 784 + api.subscribe('timer:delete', async (msg) => { 785 + activeTimers.delete(msg.id); 786 + stopTickLoopIfEmpty(); 787 + try { 788 + await api.datastore.deleteItem(msg.id); 789 + } catch (err) { 790 + console.error('[ext:timers] Failed to delete timer:', err); 791 + } 792 + api.publish('timer:state-changed', { id: msg.id, status: 'deleted' }, api.scopes.GLOBAL); 793 + debug && console.log('[ext:timers] Deleted timer:', msg.id); 794 + }, api.scopes.GLOBAL); 795 + 796 + // Request current timer state (UI requests on load) 797 + api.subscribe('timer:request-state', async () => { 798 + const timers = []; 799 + try { 800 + const result = await api.datastore.queryItems({ type: 'timer' }); 801 + if (result.success) { 802 + for (const item of result.data) { 803 + let meta; 804 + try { 805 + meta = typeof item.metadata === 'string' ? JSON.parse(item.metadata) : item.metadata; 806 + } catch { 807 + continue; 808 + } 809 + timers.push({ 810 + id: item.id, 811 + title: item.title, 812 + metadata: meta 813 + }); 814 + } 815 + } 816 + } catch (err) { 817 + console.error('[ext:timers] Failed to query timers:', err); 818 + } 819 + api.publish('timer:state-response', { timers }, api.scopes.GLOBAL); 820 + }, api.scopes.GLOBAL); 821 + }; 822 + 823 + // ===== Registration ===== 824 + 825 + const init = async () => { 826 + debug && console.log('[ext:timers] init'); 827 + 828 + // Register command handlers 829 + api.commands.register({ 830 + name: 'timers', 831 + description: 'View all timers', 832 + execute: async () => { handleTimers(); } 833 + }); 834 + 835 + // Register subcommands via pubsub (for cmd panel with params) 836 + api.subscribe('cmd:execute:timer countdown', async (msg) => { 837 + const result = await handleCountdown(msg.search?.trim()); 838 + if (msg.expectResult && msg.resultTopic) { 839 + api.publish(msg.resultTopic, result || { success: true }, api.scopes.GLOBAL); 840 + } 841 + }, api.scopes.GLOBAL); 842 + api.publish('cmd:register', { 843 + name: 'timer countdown', 844 + description: 'Start a countdown timer (e.g., 30m, 1h, 5m30s)', 845 + source: 'timers', 846 + scope: 'global', 847 + accepts: [], 848 + produces: [], 849 + params: [{ name: 'duration', type: 'string', required: true, description: 'Duration (e.g., 30m, 1h, 5m30s, 90s)' }] 850 + }, api.scopes.GLOBAL); 851 + 852 + api.subscribe('cmd:execute:timer alarm', async (msg) => { 853 + const result = await handleAlarm(msg.search?.trim()); 854 + if (msg.expectResult && msg.resultTopic) { 855 + api.publish(msg.resultTopic, result || { success: true }, api.scopes.GLOBAL); 856 + } 857 + }, api.scopes.GLOBAL); 858 + api.publish('cmd:register', { 859 + name: 'timer alarm', 860 + description: 'Set an alarm for a specific time (e.g., 6pm, 14:30)', 861 + source: 'timers', 862 + scope: 'global', 863 + accepts: [], 864 + produces: [], 865 + params: [{ name: 'time', type: 'string', required: true, description: 'Time (e.g., 6pm, 14:30, 6:30pm)' }] 866 + }, api.scopes.GLOBAL); 867 + 868 + api.subscribe('cmd:execute:timer stopwatch', async (msg) => { 869 + const result = await handleStopwatch(msg.search?.trim()); 870 + if (msg.expectResult && msg.resultTopic) { 871 + api.publish(msg.resultTopic, result || { success: true }, api.scopes.GLOBAL); 872 + } 873 + }, api.scopes.GLOBAL); 874 + api.publish('cmd:register', { 875 + name: 'timer stopwatch', 876 + description: 'Start a stopwatch that counts up', 877 + source: 'timers', 878 + scope: 'global', 879 + accepts: [], 880 + produces: [], 881 + params: [{ name: 'name', type: 'string', required: false, description: 'Optional name for the stopwatch' }] 882 + }, api.scopes.GLOBAL); 883 + 884 + api.subscribe('cmd:execute:timer interval', async (msg) => { 885 + const result = await handleInterval(msg.search?.trim()); 886 + if (msg.expectResult && msg.resultTopic) { 887 + api.publish(msg.resultTopic, result || { success: true }, api.scopes.GLOBAL); 888 + } 889 + }, api.scopes.GLOBAL); 890 + api.publish('cmd:register', { 891 + name: 'timer interval', 892 + description: 'Start a repeating interval timer', 893 + source: 'timers', 894 + scope: 'global', 895 + accepts: [], 896 + produces: [], 897 + params: [{ name: 'duration', type: 'string', required: true, description: 'Interval duration (e.g., 25m, 1h)' }] 898 + }, api.scopes.GLOBAL); 899 + 900 + // Set up pubsub handlers for timer control 901 + setupPubsubHandlers(); 902 + 903 + // Restore running timers from datastore 904 + await restoreTimers(); 905 + }; 906 + 907 + const uninit = () => { 908 + debug && console.log('[ext:timers] uninit'); 909 + if (tickInterval) { 910 + clearInterval(tickInterval); 911 + tickInterval = null; 912 + } 913 + // Unregister standalone commands 914 + for (const name of ['timer countdown', 'timer alarm', 'timer stopwatch', 'timer interval']) { 915 + api.publish('cmd:unregister', { name }, api.scopes.GLOBAL); 916 + } 917 + }; 918 + 919 + export default { 920 + defaults, 921 + id, 922 + init, 923 + uninit, 924 + labels, 925 + schemas, 926 + storageKeys 927 + };
+21
extensions/timers/config.js
··· 1 + const id = 'timers'; 2 + 3 + const labels = { 4 + name: 'Timers', 5 + }; 6 + 7 + const schemas = {}; 8 + 9 + const storageKeys = {}; 10 + 11 + const defaults = { 12 + prefs: {} 13 + }; 14 + 15 + export { 16 + id, 17 + labels, 18 + schemas, 19 + storageKeys, 20 + defaults 21 + };
+217
extensions/timers/home.css
··· 1 + /* Import theme variables */ 2 + @import url('peek://theme/variables.css'); 3 + 4 + * { 5 + box-sizing: border-box; 6 + margin: 0; 7 + padding: 0; 8 + } 9 + 10 + html { 11 + font-family: var(--theme-font-sans); 12 + -webkit-font-smoothing: antialiased; 13 + font-size: 14px; 14 + line-height: 1.5; 15 + } 16 + 17 + body { 18 + background: var(--base00); 19 + color: var(--base05); 20 + min-height: 100vh; 21 + } 22 + 23 + /* Header */ 24 + .header-container { 25 + padding: 12px 16px 0 16px; 26 + } 27 + 28 + .page-title { 29 + font-size: 16px; 30 + font-weight: 600; 31 + color: var(--base05); 32 + margin: 0; 33 + } 34 + 35 + /* Sections */ 36 + .section { 37 + padding: 0 16px; 38 + margin-top: 16px; 39 + } 40 + 41 + .section-label { 42 + font-size: 11px; 43 + font-weight: 600; 44 + text-transform: uppercase; 45 + letter-spacing: 0.05em; 46 + color: var(--base04); 47 + margin-bottom: 8px; 48 + } 49 + 50 + .active-section .section-label { 51 + color: var(--base0A); 52 + } 53 + 54 + /* Cards container */ 55 + .cards { 56 + /* peek-grid handles layout */ 57 + } 58 + 59 + /* Timer card base */ 60 + peek-card.timer-card { 61 + --peek-card-bg: var(--base01); 62 + --peek-card-hover-bg: var(--base02); 63 + --peek-card-border: transparent; 64 + --peek-card-radius: 6px; 65 + --peek-card-padding: 10px; 66 + --peek-card-gap: 6px; 67 + max-width: 340px; 68 + } 69 + 70 + /* Active timer cards */ 71 + peek-card.active-timer { 72 + --peek-card-bg: var(--base01); 73 + --peek-card-border: var(--base0A); 74 + } 75 + 76 + peek-card.paused-timer { 77 + --peek-card-border: var(--base03); 78 + opacity: 0.85; 79 + } 80 + 81 + /* Timer badge */ 82 + .timer-badge { 83 + display: inline-flex; 84 + align-items: center; 85 + justify-content: center; 86 + width: 24px; 87 + height: 24px; 88 + border-radius: 4px; 89 + font-size: 9px; 90 + font-weight: 700; 91 + letter-spacing: 0.02em; 92 + flex-shrink: 0; 93 + color: var(--base00); 94 + } 95 + 96 + .timer-badge-countdown { 97 + background: var(--base0D); 98 + } 99 + 100 + .timer-badge-alarm { 101 + background: var(--base08); 102 + } 103 + 104 + .timer-badge-stopwatch { 105 + background: var(--base0B); 106 + } 107 + 108 + .timer-badge-interval { 109 + background: var(--base0E); 110 + } 111 + 112 + /* Status badge */ 113 + .status-badge { 114 + font-size: 10px; 115 + padding: 2px 6px; 116 + border-radius: 8px; 117 + flex-shrink: 0; 118 + } 119 + 120 + .status-badge.paused { 121 + background: rgba(255, 180, 60, 0.2); 122 + color: var(--base0A); 123 + } 124 + 125 + /* Card title */ 126 + .card-title { 127 + font-size: 13px; 128 + font-weight: 600; 129 + color: var(--base05); 130 + white-space: nowrap; 131 + overflow: hidden; 132 + text-overflow: ellipsis; 133 + } 134 + 135 + /* Timer display (large time readout) */ 136 + .timer-display { 137 + font-size: 28px; 138 + font-weight: 300; 139 + font-variant-numeric: tabular-nums; 140 + color: var(--base05); 141 + letter-spacing: 0.02em; 142 + padding: 4px 0; 143 + } 144 + 145 + .active-timer .timer-display { 146 + color: var(--base0A); 147 + } 148 + 149 + .paused-timer .timer-display { 150 + color: var(--base04); 151 + } 152 + 153 + /* Interval count */ 154 + .interval-count { 155 + font-size: 11px; 156 + color: var(--base04); 157 + } 158 + 159 + /* Timer footer */ 160 + .timer-footer { 161 + display: flex; 162 + justify-content: space-between; 163 + align-items: center; 164 + gap: 8px; 165 + } 166 + 167 + .timer-actions { 168 + display: flex; 169 + gap: 4px; 170 + } 171 + 172 + /* Card meta (footer text) */ 173 + .card-meta { 174 + font-size: 11px; 175 + color: var(--base03); 176 + } 177 + 178 + /* Completed/stopped cards — muted */ 179 + peek-card.timer-card:not(.active-timer) { 180 + opacity: 0.65; 181 + } 182 + 183 + peek-card.timer-card:not(.active-timer):hover { 184 + opacity: 0.9; 185 + } 186 + 187 + peek-card.timer-card:not(.active-timer) .timer-display { 188 + font-size: 20px; 189 + color: var(--base04); 190 + } 191 + 192 + /* Empty state */ 193 + .empty-state { 194 + grid-column: 1 / -1; 195 + text-align: center; 196 + padding: 32px 16px; 197 + color: var(--base03); 198 + font-size: 13px; 199 + line-height: 1.8; 200 + } 201 + 202 + .empty-state code { 203 + display: inline-block; 204 + margin-top: 4px; 205 + padding: 2px 8px; 206 + background: var(--base01); 207 + border: 1px solid var(--base02); 208 + border-radius: 4px; 209 + font-family: var(--theme-font-mono, monospace); 210 + font-size: 12px; 211 + color: var(--base0D); 212 + } 213 + 214 + /* List view mode */ 215 + peek-grid.cards[view-mode="list"] peek-card { 216 + max-width: none; 217 + }
+51
extensions/timers/home.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6 + <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1"> 7 + <title>Timers</title> 8 + <link rel="stylesheet" type="text/css" href="home.css"> 9 + 10 + <!-- Import map for resolving bare module specifiers --> 11 + <script type="importmap"> 12 + { 13 + "imports": { 14 + "lit": "peek://node_modules/lit/index.js", 15 + "lit/": "peek://node_modules/lit/", 16 + "lit-html": "peek://node_modules/lit-html/lit-html.js", 17 + "lit-html/": "peek://node_modules/lit-html/", 18 + "lit-element": "peek://node_modules/lit-element/lit-element.js", 19 + "lit-element/": "peek://node_modules/lit-element/", 20 + "@lit/reactive-element": "peek://node_modules/@lit/reactive-element/reactive-element.js", 21 + "@lit/reactive-element/": "peek://node_modules/@lit/reactive-element/" 22 + } 23 + } 24 + </script> 25 + 26 + <!-- Import peek-components --> 27 + <script type="module"> 28 + import 'peek://app/components/peek-card.js'; 29 + import 'peek://app/components/peek-grid.js'; 30 + import 'peek://app/components/peek-input.js'; 31 + import 'peek://app/components/peek-button.js'; 32 + </script> 33 + </head> 34 + <body> 35 + <div class="header-container"> 36 + <h1 class="page-title">Timers</h1> 37 + </div> 38 + 39 + <div class="section active-section" id="active-section"> 40 + <div class="section-label">Active</div> 41 + <peek-grid class="cards active-cards" min-item-width="280" gap="8"></peek-grid> 42 + </div> 43 + 44 + <div class="section" id="completed-section"> 45 + <div class="section-label">History</div> 46 + <peek-grid class="cards completed-cards" min-item-width="280" gap="8"></peek-grid> 47 + </div> 48 + 49 + <script type="module" src="home.js"></script> 50 + </body> 51 + </html>
+347
extensions/timers/home.js
··· 1 + /** 2 + * Timers Home UI 3 + * 4 + * Displays all timers in a card grid with live-updating displays. 5 + * Active timers update every second via timer:tick pubsub events. 6 + * Completed/stopped timers shown in a muted history section below. 7 + */ 8 + 9 + const api = window.app; 10 + const debug = api.debug; 11 + 12 + // Timer type icons (text-based, no emoji) 13 + const TIMER_ICONS = { 14 + countdown: 'CD', 15 + alarm: 'AL', 16 + stopwatch: 'SW', 17 + interval: 'IV' 18 + }; 19 + 20 + // Timer type labels 21 + const TIMER_LABELS = { 22 + countdown: 'Countdown', 23 + alarm: 'Alarm', 24 + stopwatch: 'Stopwatch', 25 + interval: 'Interval' 26 + }; 27 + 28 + // All timers loaded from datastore 29 + let allTimers = []; 30 + 31 + // Map of timer ID -> card element for fast tick updates 32 + const cardMap = new Map(); 33 + 34 + // ===== Format Helpers ===== 35 + 36 + const formatDuration = (ms) => { 37 + if (ms < 0) ms = 0; 38 + const totalSeconds = Math.floor(ms / 1000); 39 + const hours = Math.floor(totalSeconds / 3600); 40 + const minutes = Math.floor((totalSeconds % 3600) / 60); 41 + const seconds = totalSeconds % 60; 42 + 43 + if (hours > 0) { 44 + return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; 45 + } 46 + return `${minutes}:${String(seconds).padStart(2, '0')}`; 47 + }; 48 + 49 + const getTimerDisplay = (timer) => { 50 + const meta = timer.metadata; 51 + const now = Date.now(); 52 + 53 + if (meta.status === 'paused') { 54 + if (meta.timerType === 'stopwatch') { 55 + return formatDuration(meta.elapsed); 56 + } 57 + if (meta.timerType === 'countdown') { 58 + const elapsed = meta.pausedAt - meta.startedAt; 59 + return formatDuration(meta.duration - elapsed); 60 + } 61 + if (meta.timerType === 'alarm') { 62 + return formatDuration(meta.targetTime - meta.pausedAt); 63 + } 64 + if (meta.timerType === 'interval') { 65 + const elapsed = meta.pausedAt - meta.startedAt; 66 + const inCycle = elapsed % meta.duration; 67 + return formatDuration(meta.duration - inCycle); 68 + } 69 + } 70 + 71 + if (meta.status === 'running') { 72 + if (meta.timerType === 'countdown') { 73 + return formatDuration(meta.duration - (now - meta.startedAt)); 74 + } 75 + if (meta.timerType === 'alarm') { 76 + return formatDuration(meta.targetTime - now); 77 + } 78 + if (meta.timerType === 'stopwatch') { 79 + return formatDuration(meta.elapsed + (now - meta.startedAt)); 80 + } 81 + if (meta.timerType === 'interval') { 82 + const elapsed = now - meta.startedAt; 83 + const inCycle = elapsed % meta.duration; 84 + return formatDuration(meta.duration - inCycle); 85 + } 86 + } 87 + 88 + if (meta.status === 'completed' || meta.status === 'stopped') { 89 + if (meta.timerType === 'stopwatch') { 90 + return formatDuration(meta.elapsed); 91 + } 92 + if (meta.timerType === 'countdown') { 93 + return formatDuration(meta.duration); 94 + } 95 + return 'Done'; 96 + } 97 + 98 + return '0:00'; 99 + }; 100 + 101 + const formatCompletedTime = (timer) => { 102 + const meta = timer.metadata; 103 + if (!meta.stoppedAt && !meta.startedAt) return ''; 104 + const ts = meta.stoppedAt || meta.startedAt; 105 + const d = new Date(ts); 106 + return d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); 107 + }; 108 + 109 + // ===== Card Creation ===== 110 + 111 + const createTimerCard = (timer) => { 112 + const meta = timer.metadata; 113 + const isActive = meta.status === 'running' || meta.status === 'paused'; 114 + 115 + const card = document.createElement('peek-card'); 116 + card.className = 'timer-card'; 117 + if (isActive) card.classList.add('active-timer'); 118 + if (meta.status === 'paused') card.classList.add('paused-timer'); 119 + card.elevated = true; 120 + card.dataset.timerId = timer.id; 121 + 122 + // Header: type badge + title 123 + const header = document.createElement('div'); 124 + header.slot = 'header'; 125 + header.style.display = 'flex'; 126 + header.style.alignItems = 'center'; 127 + header.style.gap = '8px'; 128 + 129 + const badge = document.createElement('span'); 130 + badge.className = `timer-badge timer-badge-${meta.timerType}`; 131 + badge.textContent = TIMER_ICONS[meta.timerType] || '??'; 132 + 133 + const title = document.createElement('h2'); 134 + title.className = 'card-title'; 135 + title.textContent = timer.title || TIMER_LABELS[meta.timerType] || 'Timer'; 136 + title.style.margin = '0'; 137 + title.style.flex = '1'; 138 + title.style.minWidth = '0'; 139 + 140 + if (meta.status === 'paused') { 141 + const pauseBadge = document.createElement('span'); 142 + pauseBadge.className = 'status-badge paused'; 143 + pauseBadge.textContent = 'paused'; 144 + header.appendChild(badge); 145 + header.appendChild(title); 146 + header.appendChild(pauseBadge); 147 + } else { 148 + header.appendChild(badge); 149 + header.appendChild(title); 150 + } 151 + 152 + card.appendChild(header); 153 + 154 + // Body: large time display 155 + const body = document.createElement('div'); 156 + body.className = 'timer-display'; 157 + body.textContent = getTimerDisplay(timer); 158 + card.appendChild(body); 159 + 160 + // Interval count for interval timers 161 + if (meta.timerType === 'interval' && meta.intervalCount > 0) { 162 + const countEl = document.createElement('div'); 163 + countEl.className = 'interval-count'; 164 + countEl.textContent = `${meta.intervalCount} interval${meta.intervalCount !== 1 ? 's' : ''} completed`; 165 + card.appendChild(countEl); 166 + } 167 + 168 + // Footer: action buttons for active timers, timestamp for inactive 169 + const footer = document.createElement('div'); 170 + footer.slot = 'footer'; 171 + footer.className = 'card-meta timer-footer'; 172 + 173 + if (isActive) { 174 + const btnContainer = document.createElement('div'); 175 + btnContainer.className = 'timer-actions'; 176 + 177 + // Pause/Resume button 178 + if (meta.timerType !== 'alarm') { 179 + const pauseBtn = document.createElement('peek-button'); 180 + pauseBtn.variant = 'ghost'; 181 + pauseBtn.size = 'sm'; 182 + pauseBtn.textContent = meta.status === 'paused' ? 'Resume' : 'Pause'; 183 + pauseBtn.addEventListener('click', (e) => { 184 + e.stopPropagation(); 185 + if (meta.status === 'paused') { 186 + api.publish('timer:resume', { id: timer.id }, api.scopes.GLOBAL); 187 + } else { 188 + api.publish('timer:pause', { id: timer.id }, api.scopes.GLOBAL); 189 + } 190 + }); 191 + btnContainer.appendChild(pauseBtn); 192 + } 193 + 194 + // Stop button 195 + const stopBtn = document.createElement('peek-button'); 196 + stopBtn.variant = 'ghost'; 197 + stopBtn.size = 'sm'; 198 + stopBtn.textContent = 'Stop'; 199 + stopBtn.addEventListener('click', (e) => { 200 + e.stopPropagation(); 201 + api.publish('timer:stop', { id: timer.id }, api.scopes.GLOBAL); 202 + }); 203 + btnContainer.appendChild(stopBtn); 204 + 205 + footer.appendChild(btnContainer); 206 + } else { 207 + // Completed/stopped: show timestamp and delete button 208 + const timeSpan = document.createElement('span'); 209 + timeSpan.textContent = formatCompletedTime(timer); 210 + footer.appendChild(timeSpan); 211 + 212 + const deleteBtn = document.createElement('peek-button'); 213 + deleteBtn.variant = 'ghost'; 214 + deleteBtn.size = 'sm'; 215 + deleteBtn.textContent = 'Delete'; 216 + deleteBtn.addEventListener('click', (e) => { 217 + e.stopPropagation(); 218 + api.publish('timer:delete', { id: timer.id }, api.scopes.GLOBAL); 219 + }); 220 + footer.appendChild(deleteBtn); 221 + } 222 + 223 + card.appendChild(footer); 224 + 225 + return card; 226 + }; 227 + 228 + // ===== Rendering ===== 229 + 230 + const render = () => { 231 + cardMap.clear(); 232 + 233 + const activeContainer = document.querySelector('.active-cards'); 234 + const completedContainer = document.querySelector('.completed-cards'); 235 + const activeSection = document.getElementById('active-section'); 236 + const completedSection = document.getElementById('completed-section'); 237 + 238 + activeContainer.innerHTML = ''; 239 + completedContainer.innerHTML = ''; 240 + 241 + // Separate active from inactive 242 + const active = allTimers.filter(t => t.metadata.status === 'running' || t.metadata.status === 'paused'); 243 + const inactive = allTimers.filter(t => t.metadata.status === 'completed' || t.metadata.status === 'stopped'); 244 + 245 + // Sort: running before paused, most recent first 246 + active.sort((a, b) => { 247 + if (a.metadata.status !== b.metadata.status) { 248 + return a.metadata.status === 'running' ? -1 : 1; 249 + } 250 + return (b.metadata.startedAt || 0) - (a.metadata.startedAt || 0); 251 + }); 252 + 253 + // Sort inactive by most recently completed 254 + inactive.sort((a, b) => (b.metadata.stoppedAt || b.metadata.startedAt || 0) - (a.metadata.stoppedAt || a.metadata.startedAt || 0)); 255 + 256 + // Show/hide sections 257 + activeSection.style.display = active.length > 0 ? '' : 'none'; 258 + completedSection.style.display = inactive.length > 0 ? '' : 'none'; 259 + 260 + if (active.length === 0 && inactive.length === 0) { 261 + activeSection.style.display = ''; 262 + activeContainer.innerHTML = '<div class="empty-state">No timers yet. Use the command palette to start one:<br><code>timer countdown 5m</code></div>'; 263 + } 264 + 265 + for (const timer of active) { 266 + const card = createTimerCard(timer); 267 + activeContainer.appendChild(card); 268 + cardMap.set(timer.id, card); 269 + } 270 + 271 + for (const timer of inactive) { 272 + const card = createTimerCard(timer); 273 + completedContainer.appendChild(card); 274 + cardMap.set(timer.id, card); 275 + } 276 + }; 277 + 278 + // ===== Live Tick Updates ===== 279 + 280 + const handleTick = (msg) => { 281 + if (!msg.timers) return; 282 + 283 + for (const tickInfo of msg.timers) { 284 + const card = cardMap.get(tickInfo.id); 285 + if (!card) continue; 286 + 287 + // Update the time display 288 + const display = card.querySelector('.timer-display'); 289 + if (display) { 290 + display.textContent = tickInfo.display; 291 + } 292 + 293 + // Update interval count 294 + if (tickInfo.timerType === 'interval' && tickInfo.intervalCount > 0) { 295 + let countEl = card.querySelector('.interval-count'); 296 + if (!countEl) { 297 + countEl = document.createElement('div'); 298 + countEl.className = 'interval-count'; 299 + // Insert before footer 300 + const footer = card.querySelector('[slot="footer"]'); 301 + if (footer) { 302 + card.insertBefore(countEl, footer); 303 + } else { 304 + card.appendChild(countEl); 305 + } 306 + } 307 + countEl.textContent = `${tickInfo.intervalCount} interval${tickInfo.intervalCount !== 1 ? 's' : ''} completed`; 308 + } 309 + } 310 + }; 311 + 312 + // ===== Initialization ===== 313 + 314 + const init = async () => { 315 + debug && console.log('[timers:home] init'); 316 + 317 + // Register escape handler 318 + api.escape.onEscape(() => { 319 + return { handled: false }; // Let window close 320 + }); 321 + 322 + // Request current timer state from background 323 + api.subscribe('timer:state-response', (msg) => { 324 + if (msg.timers) { 325 + allTimers = msg.timers; 326 + render(); 327 + } 328 + }, api.scopes.GLOBAL); 329 + 330 + // Subscribe to tick events for live display updates 331 + api.subscribe('timer:tick', handleTick, api.scopes.GLOBAL); 332 + 333 + // Subscribe to state changes (pause, resume, stop, delete, complete) 334 + api.subscribe('timer:state-changed', () => { 335 + // Re-request full state to re-render 336 + api.publish('timer:request-state', {}, api.scopes.GLOBAL); 337 + }, api.scopes.GLOBAL); 338 + 339 + api.subscribe('timer:completed', () => { 340 + api.publish('timer:request-state', {}, api.scopes.GLOBAL); 341 + }, api.scopes.GLOBAL); 342 + 343 + // Request initial state 344 + api.publish('timer:request-state', {}, api.scopes.GLOBAL); 345 + }; 346 + 347 + document.addEventListener('DOMContentLoaded', init);
+47
extensions/timers/manifest.json
··· 1 + { 2 + "id": "timers", 3 + "shortname": "timers", 4 + "name": "Timers", 5 + "description": "Countdown timers, alarms, stopwatches, and interval timers", 6 + "version": "1.0.0", 7 + "background": "background.html", 8 + "builtin": true, 9 + "commands": [ 10 + { 11 + "name": "timers", 12 + "description": "View all timers", 13 + "action": { "type": "execute" } 14 + }, 15 + { 16 + "name": "timer countdown", 17 + "description": "Start a countdown timer (e.g., 30m, 1h, 5m30s)", 18 + "action": { "type": "execute" } 19 + }, 20 + { 21 + "name": "timer alarm", 22 + "description": "Set an alarm for a specific time (e.g., 6pm, 14:30)", 23 + "action": { "type": "execute" } 24 + }, 25 + { 26 + "name": "timer stopwatch", 27 + "description": "Start a stopwatch that counts up", 28 + "action": { "type": "execute" } 29 + }, 30 + { 31 + "name": "timer interval", 32 + "description": "Start a repeating interval timer", 33 + "action": { "type": "execute" } 34 + } 35 + ], 36 + "shortcuts": [ 37 + { 38 + "keys": "Option+t", 39 + "command": "timers", 40 + "global": true 41 + }, 42 + { 43 + "keys": "CommandOrControl+T", 44 + "command": "timers" 45 + } 46 + ] 47 + }
+473
tests/editor/editor-layout.spec.ts
··· 1037 1037 expect(hasFoldGutter).toBe(true); 1038 1038 }); 1039 1039 }); 1040 + 1041 + // ========================================================================== 1042 + // Notes and Highlights 1043 + // ========================================================================== 1044 + 1045 + test.describe('Notes and Highlights', () => { 1046 + test('Cmd+Shift+H toggles notes pane', async () => { 1047 + const wasBefore = await page.evaluate(() => window.getAnnotationsState().visible); 1048 + 1049 + await page.keyboard.press('Meta+Shift+h'); 1050 + 1051 + const isAfter = await page.evaluate(() => window.getAnnotationsState().visible); 1052 + expect(isAfter).toBe(!wasBefore); 1053 + 1054 + // Toggle back to original state 1055 + await page.keyboard.press('Meta+Shift+h'); 1056 + const restored = await page.evaluate(() => window.getAnnotationsState().visible); 1057 + expect(restored).toBe(wasBefore); 1058 + }); 1059 + 1060 + test('notes pane title says Notes', async () => { 1061 + // Open notes pane 1062 + await page.evaluate(() => { 1063 + if (!window.getAnnotationsState().visible) window.toggleAnnotations(); 1064 + }); 1065 + 1066 + const title = await page.evaluate(() => { 1067 + const el = document.querySelector('.annotations-pane .sidebar-title'); 1068 + return el?.textContent; 1069 + }); 1070 + expect(title).toBe('Notes'); 1071 + 1072 + // Close pane 1073 + await page.evaluate(() => { 1074 + if (window.getAnnotationsState().visible) window.toggleAnnotations(); 1075 + }); 1076 + }); 1077 + 1078 + test('Cmd+Shift+L with selection creates highlight with empty text', async () => { 1079 + // Set known content and select some text 1080 + await page.evaluate(() => { 1081 + window.setEditorContent('Hello World\nSecond line\nThird line'); 1082 + }); 1083 + 1084 + // Wait for content to settle 1085 + await page.waitForFunction(() => window.getEditorContent() === 'Hello World\nSecond line\nThird line'); 1086 + 1087 + // Select "World" (chars 6-11) 1088 + await page.evaluate(() => window.selectRange(6, 11)); 1089 + 1090 + // Press Cmd+Shift+L to add highlight 1091 + await page.keyboard.press('Meta+Shift+l'); 1092 + 1093 + // Wait for highlight to appear 1094 + await page.waitForFunction(() => window.getEditorHighlights().length > 0); 1095 + 1096 + const highlights = await page.evaluate(() => window.getEditorHighlights()); 1097 + expect(highlights.length).toBeGreaterThanOrEqual(1); 1098 + 1099 + const hl = highlights[highlights.length - 1]; 1100 + expect(hl.text).toBe(''); 1101 + expect(hl.from).toBe(6); 1102 + expect(hl.to).toBe(11); 1103 + 1104 + // Restore content 1105 + await page.evaluate(() => { 1106 + window.setEditorContent(`# Introduction 1107 + 1108 + Welcome to the document. 1109 + 1110 + ## Section One 1111 + 1112 + Content in section one. 1113 + 1114 + ### Subsection 1.1 1115 + 1116 + Details about subsection 1.1. 1117 + 1118 + ### Subsection 1.2 1119 + 1120 + Details about subsection 1.2. 1121 + 1122 + ## Section Two 1123 + 1124 + Content in section two. 1125 + 1126 + ### Subsection 2.1 1127 + 1128 + More details here. 1129 + 1130 + ## Section Three 1131 + 1132 + Final section content. 1133 + 1134 + ### Subsection 3.1 1135 + 1136 + Some code: 1137 + 1138 + \`\`\`javascript 1139 + function hello() { 1140 + console.log('world'); 1141 + } 1142 + \`\`\` 1143 + 1144 + ### Subsection 3.2 1145 + 1146 + Conclusion text. 1147 + `); 1148 + if (window.getAnnotationsState().visible) window.toggleAnnotations(); 1149 + }); 1150 + }); 1151 + 1152 + test('Cmd+Shift+L without selection anchors to current line', async () => { 1153 + // Set known content 1154 + await page.evaluate(() => { 1155 + window.setEditorContent('First line\nSecond line\nThird line'); 1156 + }); 1157 + await page.waitForFunction(() => window.getEditorContent() === 'First line\nSecond line\nThird line'); 1158 + 1159 + // Place cursor in "Second line" (position 15, no selection) 1160 + await page.evaluate(() => window.setCursor(15)); 1161 + 1162 + // Press Cmd+Shift+L with no selection 1163 + await page.keyboard.press('Meta+Shift+l'); 1164 + 1165 + // Wait for highlight 1166 + await page.waitForFunction(() => window.getEditorHighlights().length > 0); 1167 + 1168 + const highlights = await page.evaluate(() => window.getEditorHighlights()); 1169 + const hl = highlights[highlights.length - 1]; 1170 + expect(hl.text).toBe(''); 1171 + // Should cover the full "Second line" -- from=11, to=22 1172 + expect(hl.from).toBe(11); 1173 + expect(hl.to).toBe(22); 1174 + 1175 + // Restore content 1176 + await page.evaluate(() => { 1177 + window.setEditorContent(`# Introduction 1178 + 1179 + Welcome to the document. 1180 + 1181 + ## Section One 1182 + 1183 + Content in section one. 1184 + 1185 + ### Subsection 1.1 1186 + 1187 + Details about subsection 1.1. 1188 + 1189 + ### Subsection 1.2 1190 + 1191 + Details about subsection 1.2. 1192 + 1193 + ## Section Two 1194 + 1195 + Content in section two. 1196 + 1197 + ### Subsection 2.1 1198 + 1199 + More details here. 1200 + 1201 + ## Section Three 1202 + 1203 + Final section content. 1204 + 1205 + ### Subsection 3.1 1206 + 1207 + Some code: 1208 + 1209 + \`\`\`javascript 1210 + function hello() { 1211 + console.log('world'); 1212 + } 1213 + \`\`\` 1214 + 1215 + ### Subsection 3.2 1216 + 1217 + Conclusion text. 1218 + `); 1219 + if (window.getAnnotationsState().visible) window.toggleAnnotations(); 1220 + }); 1221 + }); 1222 + 1223 + test('notes pane shows "Line N -- highlight" for empty notes', async () => { 1224 + // Set known content 1225 + await page.evaluate(() => { 1226 + window.setEditorContent('Alpha\nBravo\nCharlie'); 1227 + }); 1228 + await page.waitForFunction(() => window.getEditorContent() === 'Alpha\nBravo\nCharlie'); 1229 + 1230 + // Select "Bravo" (from=6, to=11) 1231 + await page.evaluate(() => window.selectRange(6, 11)); 1232 + 1233 + await page.keyboard.press('Meta+Shift+l'); 1234 + 1235 + // Notes pane should auto-open; wait for annotation items 1236 + await page.waitForFunction(() => { 1237 + return document.querySelectorAll('.annotation-item').length > 0; 1238 + }); 1239 + 1240 + const items = await page.evaluate(() => window.getAnnotationItems()); 1241 + const lastItem = items[items.length - 1]; 1242 + // Should show "Line 2 -- highlight" (Bravo is on line 2) 1243 + expect(lastItem.text).toContain('Line 2'); 1244 + expect(lastItem.text).toContain('highlight'); 1245 + 1246 + // Restore content and close pane 1247 + await page.evaluate(() => { 1248 + window.setEditorContent(`# Introduction 1249 + 1250 + Welcome to the document. 1251 + 1252 + ## Section One 1253 + 1254 + Content in section one. 1255 + 1256 + ### Subsection 1.1 1257 + 1258 + Details about subsection 1.1. 1259 + 1260 + ### Subsection 1.2 1261 + 1262 + Details about subsection 1.2. 1263 + 1264 + ## Section Two 1265 + 1266 + Content in section two. 1267 + 1268 + ### Subsection 2.1 1269 + 1270 + More details here. 1271 + 1272 + ## Section Three 1273 + 1274 + Final section content. 1275 + 1276 + ### Subsection 3.1 1277 + 1278 + Some code: 1279 + 1280 + \`\`\`javascript 1281 + function hello() { 1282 + console.log('world'); 1283 + } 1284 + \`\`\` 1285 + 1286 + ### Subsection 3.2 1287 + 1288 + Conclusion text. 1289 + `); 1290 + if (window.getAnnotationsState().visible) window.toggleAnnotations(); 1291 + }); 1292 + }); 1293 + 1294 + test('notes pane shows "Line N" with note text for annotated highlights', async () => { 1295 + // Set known content 1296 + await page.evaluate(() => { 1297 + window.setEditorContent('Alpha\nBravo\nCharlie'); 1298 + }); 1299 + await page.waitForFunction(() => window.getEditorContent() === 'Alpha\nBravo\nCharlie'); 1300 + 1301 + // Select "Alpha" (from=0, to=5) and add highlight 1302 + await page.evaluate(() => window.selectRange(0, 5)); 1303 + await page.keyboard.press('Meta+Shift+l'); 1304 + 1305 + await page.waitForFunction(() => window.getEditorHighlights().length > 0); 1306 + 1307 + // Add a note to the highlight 1308 + const hlId = await page.evaluate(() => { 1309 + const hls = window.getEditorHighlights(); 1310 + return hls[hls.length - 1].id; 1311 + }); 1312 + await page.evaluate((id) => window.updateHighlightNote(id, 'My note text'), hlId); 1313 + 1314 + // Wait for annotation items to update 1315 + await page.waitForFunction(() => { 1316 + const items = document.querySelectorAll('.annotation-item'); 1317 + return Array.from(items).some(item => 1318 + item.querySelector('.annotation-note')?.textContent === 'My note text' 1319 + ); 1320 + }); 1321 + 1322 + const items = await page.evaluate(() => window.getAnnotationItems()); 1323 + const annotatedItem = items.find(i => i.note === 'My note text'); 1324 + expect(annotatedItem).toBeTruthy(); 1325 + // Should show "Line 1" (not "Line 1 -- highlight") 1326 + expect(annotatedItem!.text).toBe('Line 1'); 1327 + expect(annotatedItem!.text).not.toContain('highlight'); 1328 + 1329 + // Restore content and close pane 1330 + await page.evaluate(() => { 1331 + window.setEditorContent(`# Introduction 1332 + 1333 + Welcome to the document. 1334 + 1335 + ## Section One 1336 + 1337 + Content in section one. 1338 + 1339 + ### Subsection 1.1 1340 + 1341 + Details about subsection 1.1. 1342 + 1343 + ### Subsection 1.2 1344 + 1345 + Details about subsection 1.2. 1346 + 1347 + ## Section Two 1348 + 1349 + Content in section two. 1350 + 1351 + ### Subsection 2.1 1352 + 1353 + More details here. 1354 + 1355 + ## Section Three 1356 + 1357 + Final section content. 1358 + 1359 + ### Subsection 3.1 1360 + 1361 + Some code: 1362 + 1363 + \`\`\`javascript 1364 + function hello() { 1365 + console.log('world'); 1366 + } 1367 + \`\`\` 1368 + 1369 + ### Subsection 3.2 1370 + 1371 + Conclusion text. 1372 + `); 1373 + if (window.getAnnotationsState().visible) window.toggleAnnotations(); 1374 + }); 1375 + }); 1376 + 1377 + test('notes pane has Edit button on each item', async () => { 1378 + // Set known content and create a highlight 1379 + await page.evaluate(() => { 1380 + window.setEditorContent('Test content line'); 1381 + }); 1382 + await page.waitForFunction(() => window.getEditorContent() === 'Test content line'); 1383 + 1384 + await page.evaluate(() => window.selectRange(0, 4)); 1385 + await page.keyboard.press('Meta+Shift+l'); 1386 + 1387 + await page.waitForFunction(() => { 1388 + return document.querySelectorAll('.annotation-item').length > 0; 1389 + }); 1390 + 1391 + const editBtnText = await page.evaluate(() => { 1392 + const btn = document.querySelector('.annotation-item .annotation-btn'); 1393 + return btn?.textContent; 1394 + }); 1395 + expect(editBtnText).toBe('Edit'); 1396 + 1397 + // Restore content and close pane 1398 + await page.evaluate(() => { 1399 + window.setEditorContent(`# Introduction 1400 + 1401 + Welcome to the document. 1402 + 1403 + ## Section One 1404 + 1405 + Content in section one. 1406 + 1407 + ### Subsection 1.1 1408 + 1409 + Details about subsection 1.1. 1410 + 1411 + ### Subsection 1.2 1412 + 1413 + Details about subsection 1.2. 1414 + 1415 + ## Section Two 1416 + 1417 + Content in section two. 1418 + 1419 + ### Subsection 2.1 1420 + 1421 + More details here. 1422 + 1423 + ## Section Three 1424 + 1425 + Final section content. 1426 + 1427 + ### Subsection 3.1 1428 + 1429 + Some code: 1430 + 1431 + \`\`\`javascript 1432 + function hello() { 1433 + console.log('world'); 1434 + } 1435 + \`\`\` 1436 + 1437 + ### Subsection 3.2 1438 + 1439 + Conclusion text. 1440 + `); 1441 + if (window.getAnnotationsState().visible) window.toggleAnnotations(); 1442 + }); 1443 + }); 1444 + 1445 + test('Cmd+Shift+L auto-opens notes pane', async () => { 1446 + // Ensure notes pane is closed 1447 + await page.evaluate(() => { 1448 + if (window.getAnnotationsState().visible) window.toggleAnnotations(); 1449 + }); 1450 + 1451 + await page.evaluate(() => { 1452 + window.setEditorContent('Some text to highlight'); 1453 + }); 1454 + await page.waitForFunction(() => window.getEditorContent() === 'Some text to highlight'); 1455 + 1456 + await page.evaluate(() => window.selectRange(0, 4)); 1457 + await page.keyboard.press('Meta+Shift+l'); 1458 + 1459 + // Notes pane should be auto-opened 1460 + await page.waitForFunction(() => window.getAnnotationsState().visible === true); 1461 + 1462 + const visible = await page.evaluate(() => window.getAnnotationsState().visible); 1463 + expect(visible).toBe(true); 1464 + 1465 + // Restore 1466 + await page.evaluate(() => { 1467 + window.setEditorContent(`# Introduction 1468 + 1469 + Welcome to the document. 1470 + 1471 + ## Section One 1472 + 1473 + Content in section one. 1474 + 1475 + ### Subsection 1.1 1476 + 1477 + Details about subsection 1.1. 1478 + 1479 + ### Subsection 1.2 1480 + 1481 + Details about subsection 1.2. 1482 + 1483 + ## Section Two 1484 + 1485 + Content in section two. 1486 + 1487 + ### Subsection 2.1 1488 + 1489 + More details here. 1490 + 1491 + ## Section Three 1492 + 1493 + Final section content. 1494 + 1495 + ### Subsection 3.1 1496 + 1497 + Some code: 1498 + 1499 + \`\`\`javascript 1500 + function hello() { 1501 + console.log('world'); 1502 + } 1503 + \`\`\` 1504 + 1505 + ### Subsection 3.2 1506 + 1507 + Conclusion text. 1508 + `); 1509 + if (window.getAnnotationsState().visible) window.toggleAnnotations(); 1510 + }); 1511 + }); 1512 + }); 1040 1513 });
+91
tests/editor/test-layout-page.html
··· 161 161 border-top: 1px solid var(--base02); min-height: 22px; 162 162 font-family: var(--theme-font-mono); font-size: 12px; 163 163 } 164 + /* Annotations / Notes pane styles */ 165 + .annotations-pane { 166 + display: flex; flex-direction: column; width: 260px; min-width: 260px; 167 + background: var(--base00); border-left: 1px solid var(--base02); 168 + font-family: var(--theme-font-mono); font-size: 12px; 169 + } 170 + .annotations-pane.collapsed { width: 32px; min-width: 32px; } 171 + .annotations-pane.collapsed .annotations-content { display: none; } 172 + .annotations-pane.collapsed .sidebar-title { display: none; } 173 + .annotations-content { flex: 1; overflow-y: auto; padding: 8px 0; } 174 + .annotations-empty { padding: 12px; color: var(--base03); font-style: italic; } 175 + .annotations-count { 176 + font-size: 10px; background: var(--base02); color: var(--base04); 177 + padding: 1px 6px; border-radius: 8px; margin-left: 6px; 178 + } 179 + .annotation-item { 180 + display: flex; align-items: flex-start; gap: 8px; 181 + padding: 8px 12px; cursor: pointer; border-bottom: 1px solid var(--base01); 182 + } 183 + .annotation-item:hover { background: var(--base01); } 184 + .annotation-color { 185 + display: inline-block; width: 8px; height: 8px; border-radius: 50%; 186 + margin-top: 4px; flex-shrink: 0; 187 + } 188 + .annotation-text-wrap { flex: 1; min-width: 0; } 189 + .annotation-text { 190 + color: var(--base05); font-size: 12px; 191 + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 192 + } 193 + .annotation-note { color: var(--base03); font-size: 11px; margin-top: 2px; } 194 + .annotation-actions { display: flex; gap: 4px; flex-shrink: 0; } 195 + .annotation-btn { 196 + background: none; border: 1px solid var(--base02); color: var(--base04); 197 + font-size: 10px; padding: 2px 6px; border-radius: 3px; cursor: pointer; 198 + } 199 + .annotation-btn-delete { color: var(--base08); } 200 + .annotation-add-btn { 201 + background: none; border: 1px solid var(--base02); color: var(--base04); 202 + font-size: 14px; padding: 0 6px; border-radius: 3px; cursor: pointer; 203 + margin-left: auto; 204 + } 205 + .cm-highlight-mark { border-radius: 2px; } 206 + .pane-container { display: flex; flex: 1; overflow: hidden; } 164 207 </style> 165 208 </head> 166 209 <body> ··· 272 315 273 316 // Helper: save status visibility 274 317 window.setSaveStatusVisible = (v) => layout.setSaveStatusVisible(v); 318 + 319 + // Helper: get annotations pane state 320 + window.getAnnotationsState = () => ({ 321 + visible: layout.paneVisibility.annotations, 322 + highlightCount: layout.annotationsPane.highlights.length, 323 + element: layout.annotationsPane.getElement(), 324 + }); 325 + 326 + // Helper: toggle annotations pane 327 + window.toggleAnnotations = () => layout.togglePane('annotations'); 328 + 329 + // Helper: add highlight from selection (programmatic) 330 + window.addHighlightFromSelection = () => layout._addHighlightFromSelection(); 331 + 332 + // Helper: get highlights from state (via annotations pane which tracks them) 333 + window.getEditorHighlights = () => { 334 + if (!layout.cmEditor) return []; 335 + return layout.annotationsPane.highlights; 336 + }; 337 + 338 + // Helper: select text range in editor 339 + window.selectRange = (from, to) => { 340 + if (!layout.cmEditor) return; 341 + layout.cmEditor.dispatch({ 342 + selection: { anchor: from, head: to }, 343 + }); 344 + }; 345 + 346 + // Helper: set cursor position 347 + window.setCursor = (pos) => { 348 + if (!layout.cmEditor) return; 349 + layout.cmEditor.dispatch({ 350 + selection: { anchor: pos }, 351 + }); 352 + }; 353 + 354 + // Helper: get annotation items from DOM 355 + window.getAnnotationItems = () => { 356 + const items = document.querySelectorAll('.annotation-item'); 357 + return Array.from(items).map(item => ({ 358 + text: item.querySelector('.annotation-text')?.textContent || '', 359 + note: item.querySelector('.annotation-note')?.textContent || '', 360 + hasEditBtn: !!item.querySelector('.annotation-btn'), 361 + })); 362 + }; 363 + 364 + // Helper: update highlight note 365 + window.updateHighlightNote = (id, note) => layout._updateHighlightNote(id, note); 275 366 276 367 // Signal ready 277 368 document.body.dataset.ready = 'true';
+150
tests/unit/editor-outline-preview.test.js
··· 388 388 assert.ok(html.includes('plain code')); 389 389 }); 390 390 }); 391 + 392 + // =========================================================================== 393 + // Highlight serialization/deserialization tests 394 + // =========================================================================== 395 + 396 + // Inline the pure functions from highlights.js (they depend on CodeMirror 397 + // StateField which can't run in Node, but serialize/deserialize are pure) 398 + 399 + function generateHighlightId() { 400 + return 'hl-' + Date.now().toString(36) + '-' + Math.random().toString(36).substring(2, 8); 401 + } 402 + 403 + function serializeHighlights(highlights) { 404 + return highlights.map(hl => ({ 405 + id: hl.id, 406 + from: hl.from, 407 + to: hl.to, 408 + text: hl.text, 409 + note: hl.note || '', 410 + color: hl.color, 411 + })); 412 + } 413 + 414 + function deserializeHighlights(data, docLength) { 415 + if (!Array.isArray(data)) return []; 416 + return data.filter(hl => 417 + hl && typeof hl.from === 'number' && typeof hl.to === 'number' && 418 + hl.from >= 0 && hl.to <= docLength && hl.from < hl.to 419 + ).map(hl => ({ 420 + id: hl.id || generateHighlightId(), 421 + from: hl.from, 422 + to: hl.to, 423 + text: hl.text || '', 424 + note: hl.note || '', 425 + color: hl.color || 'var(--base0A)', 426 + })); 427 + } 428 + 429 + describe('serializeHighlights', () => { 430 + it('serializes highlights with empty text field', () => { 431 + const highlights = [ 432 + { id: 'hl-1', from: 0, to: 10, text: '', note: '', color: 'var(--base0A)' }, 433 + ]; 434 + const result = serializeHighlights(highlights); 435 + assert.equal(result.length, 1); 436 + assert.equal(result[0].text, ''); 437 + assert.equal(result[0].from, 0); 438 + assert.equal(result[0].to, 10); 439 + }); 440 + 441 + it('serializes highlights with note but empty text', () => { 442 + const highlights = [ 443 + { id: 'hl-2', from: 5, to: 15, text: '', note: 'My note', color: 'var(--base0B)' }, 444 + ]; 445 + const result = serializeHighlights(highlights); 446 + assert.equal(result[0].text, ''); 447 + assert.equal(result[0].note, 'My note'); 448 + }); 449 + 450 + it('preserves all fields during serialization', () => { 451 + const hl = { id: 'hl-3', from: 10, to: 20, text: '', note: 'test', color: 'var(--base0C)' }; 452 + const result = serializeHighlights([hl]); 453 + assert.deepEqual(result[0], hl); 454 + }); 455 + }); 456 + 457 + describe('deserializeHighlights', () => { 458 + it('deserializes highlights with empty text', () => { 459 + const data = [ 460 + { id: 'hl-1', from: 0, to: 10, text: '', note: '', color: 'var(--base0A)' }, 461 + ]; 462 + const result = deserializeHighlights(data, 100); 463 + assert.equal(result.length, 1); 464 + assert.equal(result[0].text, ''); 465 + }); 466 + 467 + it('defaults missing text to empty string', () => { 468 + const data = [ 469 + { id: 'hl-1', from: 0, to: 10, note: '', color: 'var(--base0A)' }, 470 + ]; 471 + const result = deserializeHighlights(data, 100); 472 + assert.equal(result[0].text, ''); 473 + }); 474 + 475 + it('filters out highlights beyond doc length', () => { 476 + const data = [ 477 + { id: 'hl-1', from: 0, to: 50, text: '', note: '', color: 'var(--base0A)' }, 478 + ]; 479 + const result = deserializeHighlights(data, 20); 480 + assert.equal(result.length, 0); 481 + }); 482 + 483 + it('filters out invalid highlights (from >= to)', () => { 484 + const data = [ 485 + { id: 'hl-1', from: 10, to: 10, text: '', note: '', color: 'var(--base0A)' }, 486 + { id: 'hl-2', from: 15, to: 5, text: '', note: '', color: 'var(--base0A)' }, 487 + ]; 488 + const result = deserializeHighlights(data, 100); 489 + assert.equal(result.length, 0); 490 + }); 491 + 492 + it('generates ID if missing', () => { 493 + const data = [ 494 + { from: 0, to: 10, text: '', note: '', color: 'var(--base0A)' }, 495 + ]; 496 + const result = deserializeHighlights(data, 100); 497 + assert.equal(result.length, 1); 498 + assert.ok(result[0].id.startsWith('hl-')); 499 + }); 500 + 501 + it('defaults color if missing', () => { 502 + const data = [ 503 + { id: 'hl-1', from: 0, to: 10, text: '' }, 504 + ]; 505 + const result = deserializeHighlights(data, 100); 506 + assert.equal(result[0].color, 'var(--base0A)'); 507 + }); 508 + 509 + it('round-trips through serialize and deserialize with empty text', () => { 510 + const original = [ 511 + { id: 'hl-1', from: 0, to: 10, text: '', note: 'A note', color: 'var(--base0B)' }, 512 + { id: 'hl-2', from: 15, to: 25, text: '', note: '', color: 'var(--base0C)' }, 513 + ]; 514 + const serialized = serializeHighlights(original); 515 + const deserialized = deserializeHighlights(serialized, 100); 516 + assert.equal(deserialized.length, 2); 517 + assert.equal(deserialized[0].text, ''); 518 + assert.equal(deserialized[0].note, 'A note'); 519 + assert.equal(deserialized[1].text, ''); 520 + assert.equal(deserialized[1].note, ''); 521 + }); 522 + 523 + it('returns empty array for non-array input', () => { 524 + assert.deepEqual(deserializeHighlights(null, 100), []); 525 + assert.deepEqual(deserializeHighlights(undefined, 100), []); 526 + assert.deepEqual(deserializeHighlights('invalid', 100), []); 527 + }); 528 + 529 + it('handles line-anchored highlights (full line range)', () => { 530 + // Simulating a line-anchored highlight: from=start of line, to=end of line 531 + const data = [ 532 + { id: 'hl-line', from: 11, to: 22, text: '', note: '', color: 'var(--base0D)' }, 533 + ]; 534 + const result = deserializeHighlights(data, 50); 535 + assert.equal(result.length, 1); 536 + assert.equal(result[0].from, 11); 537 + assert.equal(result[0].to, 22); 538 + assert.equal(result[0].text, ''); 539 + }); 540 + });