Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

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

Merge pull request 'feat: shared save-status-ui across all editors (v0.55.0)' (#402) from feat/v0.55.0-shared-ux into main

scott e7f908d2 d4546a68

+213 -71
+1
CHANGELOG.md
··· 14 14 15 15 ### Changed 16 16 - Wired the shared export-feedback helper into silent-failing export paths across every editor: diagrams (SVG, PNG, ASCII with correct `.shapes.size` Map accessor), forms (CSV/XLSX response exports), sheets (CSV, TSV, XLSX, PDF), docs (HTML, Markdown, TXT, PDF, DOCX). Every export now either confirms success with a row/slide/document count or surfaces the real error via toast instead of failing silently. (#686) 17 + - Promoted save-indicator + save-status-ui from sheets-only modules (`src/sheets/save-*.ts`) to shared library modules (`src/lib/save-*.ts`). Docs, slides, forms, diagrams, and calendar now share a single `wireSaveStatus({ provider, ydoc })` helper with consistent "Saved / Saving… / Saved locally / Unsaved changes" feedback — previously only sheets and docs had live autosave state, and docs carried a 50-line inline copy. Slides, forms, diagrams, and calendar HTML upgraded from an empty `<span class="save-status">` to the full indicator DOM (dot + text). Docs main.ts replaces its inline implementation with a one-line call. 8 new jsdom tests pin the wiring contract (saving→saved transitions, dot class flips, offline "Saved locally" fallback, ydoc-origin echo filtering). (#689) 17 18 18 19 ## [0.52.0] — 2026-04-16 19 20
+4 -1
src/calendar/index.html
··· 38 38 <a class="app-logo" href="/">Tools</a> 39 39 <input class="doc-title-input" id="calendar-title" type="text" value="Untitled Calendar" spellcheck="false"> 40 40 <span class="topbar-spacer"></span> 41 - <span class="save-status" id="save-status"></span> 41 + <div class="save-indicator saved" id="save-indicator"> 42 + <span class="save-dot save-dot--saved"></span> 43 + <span id="save-text">Saved</span> 44 + </div> 42 45 <button class="btn-icon" id="btn-cal-settings" title="Calendar settings"> 43 46 <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M2.9 2.9l1.4 1.4M11.7 11.7l1.4 1.4M2.9 13.1l1.4-1.4M11.7 4.3l1.4-1.4"/></svg> 44 47 </button>
+2
src/calendar/main.ts
··· 12 12 import { EncryptedProvider } from '../lib/provider.js'; 13 13 import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; 14 14 import { wireKeyWarningForSession } from '../lib/key-warning.js'; 15 + import { wireSaveStatus } from '../lib/save-status-ui.js'; 15 16 import { setupTooltips } from '../lib/tooltips.js'; 16 17 import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 17 18 import { createCommandPalette } from '../command-palette.js'; ··· 2339 2340 const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 2340 2341 installDocGoneHandler(provider); 2341 2342 wireKeyWarningForSession(docId, document.querySelector<HTMLElement>('.app-topbar')); 2343 + wireSaveStatus({ provider, ydoc }); 2342 2344 2343 2345 provider.on('sync', () => { 2344 2346 loadEventsFromYjs();
+4 -1
src/diagrams/index.html
··· 48 48 <button class="btn-icon" id="btn-ai-chat" title="AI Chat (Cmd+Shift+L)"> 49 49 <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5l-3 3V4a1 1 0 0 1 1-1z"/><circle cx="5.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="8" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="10.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/></svg> 50 50 </button> 51 - <span class="save-status" id="save-status"></span> 51 + <div class="save-indicator saved" id="save-indicator"> 52 + <span class="save-dot save-dot--saved"></span> 53 + <span id="save-text">Saved</span> 54 + </div> 52 55 </div> 53 56 54 57 <main class="diagrams-main" id="main-content">
+2
src/diagrams/main.ts
··· 9 9 import { EncryptedProvider } from '../lib/provider.js'; 10 10 import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; 11 11 import { wireKeyWarningForSession } from '../lib/key-warning.js'; 12 + import { wireSaveStatus } from '../lib/save-status-ui.js'; 12 13 import { setupTooltips } from '../lib/tooltips.js'; 13 14 import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 14 15 import { ··· 383 384 const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 384 385 installDocGoneHandler(provider); 385 386 wireKeyWarningForSession(docId, document.querySelector<HTMLElement>('.app-topbar')); 387 + wireSaveStatus({ provider, ydoc }); 386 388 provider.on('sync', () => { 387 389 loadFromYjs(); 388 390 pushHistory();
+2 -54
src/docs/main.ts
··· 37 37 import { EncryptedProvider } from '../lib/provider.js'; 38 38 import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; 39 39 import { wireKeyWarningForSession } from '../lib/key-warning.js'; 40 + import { wireSaveStatus } from '../lib/save-status-ui.js'; 40 41 import { FontSize } from './extensions/font-size.js'; 41 42 import { Indent } from './extensions/indent.js'; 42 43 import { Comment } from './extensions/comment.js'; ··· 412 413 }); 413 414 414 415 // --- Autosave indicator (#17) --- 415 - const saveIndicator = $('save-indicator'); 416 - const saveText = $('save-text'); 417 - let lastSaveTime = Date.now(); 418 - let saveState = 'saved'; 419 - 420 - function setSaveState(state: string, time?: number): void { 421 - saveState = state; 422 - saveIndicator.classList.remove('saved', 'saving', 'unsaved'); 423 - saveIndicator.classList.add(state); 424 - if (state === 'saved') { 425 - lastSaveTime = time || Date.now(); 426 - updateSaveTimestamp(); 427 - } else if (state === 'saving') { 428 - saveText.textContent = 'Saving\u2026'; 429 - } else { 430 - saveText.textContent = 'Unsaved changes'; 431 - } 432 - } 433 - 434 - function updateSaveTimestamp() { 435 - if (saveState !== 'saved') return; 436 - const prefix = !provider.connected ? 'Saved locally' : 'Saved'; 437 - const seconds = Math.floor((Date.now() - lastSaveTime) / 1000); 438 - if (seconds < 5) saveText.textContent = prefix; 439 - else if (seconds < 60) saveText.textContent = `${prefix} ${seconds}s ago`; 440 - else saveText.textContent = `${prefix} ${Math.floor(seconds / 60)} min ago`; 441 - } 442 - 443 - setInterval(updateSaveTimestamp, 30_000); 444 - 445 - provider.on('save-status', (payload) => { 446 - if (payload.status === 'saving') setSaveState('saving'); 447 - else if (payload.status === 'saved') { 448 - setSaveState('saved', Date.now()); 449 - if (!provider.connected && saveText) { 450 - saveText.textContent = 'Saved locally'; 451 - } 452 - } 453 - else if (payload.status === 'error') setSaveState('unsaved'); 454 - }); 455 - 456 - const saveDot = document.querySelector('.save-dot') as HTMLElement | null; 457 - if (saveDot) { 458 - provider.on('save-status', (payload) => { 459 - saveDot.classList.remove('save-dot--saved', 'save-dot--saving', 'save-dot--error'); 460 - if (payload.status === 'saving') saveDot.classList.add('save-dot--saving'); 461 - else if (payload.status === 'saved') saveDot.classList.add('save-dot--saved'); 462 - else if (payload.status === 'error') saveDot.classList.add('save-dot--error'); 463 - }); 464 - } 465 - 466 - editor.on('update', () => { 467 - if (saveState === 'saved') setSaveState('unsaved'); 468 - }); 416 + wireSaveStatus({ provider, ydoc }); 469 417 470 418 // --- Word and character count (#16) --- 471 419 const wordCountEl = $('word-count');
+4 -1
src/forms/index.html
··· 38 38 <a class="app-logo" href="/">Tools</a> 39 39 <input class="doc-title-input" id="form-title" type="text" value="Untitled Form" spellcheck="false"> 40 40 <span class="topbar-spacer"></span> 41 - <span class="save-status" id="save-status"></span> 41 + <div class="save-indicator saved" id="save-indicator"> 42 + <span class="save-dot save-dot--saved"></span> 43 + <span id="save-text">Saved</span> 44 + </div> 42 45 <button class="btn-icon" id="btn-ai-chat" title="AI Chat (Cmd+Shift+L)"> 43 46 <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5l-3 3V4a1 1 0 0 1 1-1z"/><circle cx="5.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="8" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="10.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/></svg> 44 47 </button>
+2
src/forms/main.ts
··· 10 10 import { EncryptedProvider } from '../lib/provider.js'; 11 11 import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; 12 12 import { wireKeyWarningForSession } from '../lib/key-warning.js'; 13 + import { wireSaveStatus } from '../lib/save-status-ui.js'; 13 14 import { setupTooltips } from '../lib/tooltips.js'; 14 15 import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 15 16 import { createForm, setTargetSheet, type FormSchema } from './form-builder.js'; ··· 221 222 const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 222 223 installDocGoneHandler(provider); 223 224 wireKeyWarningForSession(docId, document.querySelector<HTMLElement>('.app-topbar')); 225 + wireSaveStatus({ provider, ydoc }); 224 226 225 227 provider.on('sync', () => { 226 228 loadFormFromYjs();
+1 -1
src/sheets/main.ts
··· 25 25 import { wireKeyboardHandler as _wireKeyboardHandler } from './keyboard-handler.js'; 26 26 import { moveSelection as _moveSelection, extendSelection as _extendSelection, moveSelectionTo as _moveSelectionTo, getDataExtent as _getDataExtent, scrollCellIntoView as _scrollCellIntoView, updateSelectionVisuals as _updateSelectionVisuals, clearPrevSelection as _clearPrevSelection, getCellEl as _getCellEl } from './selection-navigation.js'; 27 27 import { deleteSelectedCells as _deleteSelectedCells, copySelection as _copySelection, pasteRowsAtSelection as _pasteRowsAtSelection, pasteAtSelection as _pasteAtSelection, showPasteSpecialDialog as _showPasteSpecialDialogCO, wirePasteListener as _wirePasteListener } from './clipboard-operations.js'; 28 - import { wireSaveStatus } from './save-status-ui.js'; 28 + import { wireSaveStatus } from '../lib/save-status-ui.js'; 29 29 import { renderGrid as _renderGridGR, scheduleRenderGrid as _scheduleRenderGrid, renderSparklines as _renderSparklines } from './grid-rendering.js'; 30 30 import { onGridTouchStart as _onGridTouchStart, wireTouchDoubleTap as _wireTouchDoubleTap } from './touch-events.js'; 31 31 import { startEditing as _startEditingCE, commitEdit as _commitEditCE, updateFormulaBar as _updateFormulaBarCE, attachCellEditorFormulaUX as _attachCellEditorFormulaUXCE } from './cell-editing.js';
src/sheets/save-indicator.ts src/lib/save-indicator.ts
+25 -10
src/sheets/save-status-ui.ts src/lib/save-status-ui.ts
··· 1 1 /** 2 2 * Save Status UI — autosave indicator, save dot, and status text. 3 3 * 4 - * Extracted from main.ts for decomposition. 4 + * Shared across all editors (docs, sheets, slides, forms, diagrams, calendar). 5 + * Expects DOM structure: 6 + * <div class="save-indicator saved" id="save-indicator"> 7 + * <span class="save-dot save-dot--saved"></span> 8 + * <span id="save-text">Saved</span> 9 + * </div> 5 10 */ 6 11 7 12 import { formatSaveTimestamp, getSaveDisplayText } from './save-indicator.js'; 13 + import type { SaveState } from './save-indicator.js'; 8 14 9 15 // ── Types ─────────────────────────────────────────────────── 10 16 11 17 export interface SaveStatusDeps { 12 - provider: { connected: boolean; on: (event: string, handler: (payload: any) => void) => void }; 13 - ydoc: { on: (event: string, handler: (...args: any[]) => void) => void }; 18 + /** An encrypted-provider-like object with `.connected` and `.on('save-status', fn)`. */ 19 + provider: { connected: boolean; on: (event: any, handler: any) => void }; 20 + /** A Yjs doc or equivalent with `.on('update', fn)` for dirty-state detection. */ 21 + ydoc: { on: (event: any, handler: any) => void }; 14 22 } 15 23 16 24 // ── State ─────────────────────────────────────────────────── 17 25 18 26 let lastSaveTime = Date.now(); 19 - let saveState = 'saved'; 27 + let saveState: SaveState = 'saved'; 20 28 21 29 // ── DOM refs ──────────────────────────────────────────────── 22 30 23 31 let _saveIndicator: HTMLElement | null = null; 24 32 let _saveTextEl: HTMLElement | null = null; 33 + let _providerRef: SaveStatusDeps['provider'] | null = null; 25 34 26 35 // ── Internal ──────────────────────────────────────────────── 27 36 28 - function setSaveState(state: string, time?: number): void { 37 + function setSaveState(state: SaveState, time?: number): void { 29 38 saveState = state; 30 39 _saveIndicator?.classList.remove('saved', 'saving', 'unsaved'); 31 40 _saveIndicator?.classList.add(state); 32 41 if (state === 'saved') { lastSaveTime = time || Date.now(); updateSaveTimestamp(); } 33 42 else if (_saveTextEl) { _saveTextEl.textContent = getSaveDisplayText(state) || ''; } 34 43 } 35 - 36 - let _providerRef: SaveStatusDeps['provider'] | null = null; 37 44 38 45 function updateSaveTimestamp(): void { 39 46 if (saveState !== 'saved') return; ··· 49 56 _saveTextEl = document.getElementById('save-text'); 50 57 _providerRef = deps.provider; 51 58 59 + // Reset module state so a fresh init starts clean (important for tests and 60 + // for editors that re-wire after a provider reconnect). 61 + saveState = 'saved'; 62 + lastSaveTime = Date.now(); 63 + 64 + // No indicator on the page — editor hasn't opted in yet. No-op gracefully. 65 + if (!_saveIndicator && !_saveTextEl) return; 66 + 52 67 setInterval(updateSaveTimestamp, 30_000); 53 68 54 - deps.provider.on('save-status', (payload) => { 69 + deps.provider.on('save-status', (payload: { status: string }) => { 55 70 if (payload.status === 'saving') setSaveState('saving'); 56 71 else if (payload.status === 'saved') { 57 72 setSaveState('saved', Date.now()); ··· 64 79 65 80 const saveDotEl = document.querySelector('.save-dot'); 66 81 if (saveDotEl) { 67 - deps.provider.on('save-status', (payload) => { 82 + deps.provider.on('save-status', (payload: { status: string }) => { 68 83 saveDotEl.classList.remove('save-dot--saved', 'save-dot--saving', 'save-dot--error'); 69 84 if (payload.status === 'saving') saveDotEl.classList.add('save-dot--saving'); 70 85 else if (payload.status === 'saved') saveDotEl.classList.add('save-dot--saved'); ··· 72 87 }); 73 88 } 74 89 75 - deps.ydoc.on('update', (update: any, origin: any) => { 90 + deps.ydoc.on('update', (_update: unknown, origin: unknown) => { 76 91 if (origin !== deps.provider && saveState === 'saved') setSaveState('unsaved'); 77 92 }); 78 93 }
+4 -1
src/slides/index.html
··· 39 39 <button class="btn-icon slides-panel-toggle" id="btn-toggle-slide-panel" title="Slide thumbnails" aria-label="Toggle slide thumbnails" aria-expanded="false">&#9776;</button> 40 40 <input class="doc-title-input" id="deck-title" type="text" value="Untitled Presentation" spellcheck="false"> 41 41 <span class="topbar-spacer"></span> 42 - <span class="save-status" id="save-status"></span> 42 + <div class="save-indicator saved" id="save-indicator"> 43 + <span class="save-dot save-dot--saved"></span> 44 + <span id="save-text">Saved</span> 45 + </div> 43 46 <button class="btn-secondary" id="btn-present" title="Present (F5)">&#9655; Present</button> 44 47 <button class="btn-secondary" id="btn-export" title="Export">Export</button> 45 48 <button class="btn-icon" id="btn-ai-chat" title="AI Chat (Cmd+Shift+L)">
+2
src/slides/main.ts
··· 11 11 import { EncryptedProvider } from '../lib/provider.js'; 12 12 import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; 13 13 import { wireKeyWarningForSession } from '../lib/key-warning.js'; 14 + import { wireSaveStatus } from '../lib/save-status-ui.js'; 14 15 import { setupTooltips } from '../lib/tooltips.js'; 15 16 import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 16 17 import { createDeck, slideCount } from './canvas-engine.js'; ··· 176 177 const provider = new EncryptedProvider(ydoc, state.docId, state.cryptoKey); 177 178 installDocGoneHandler(provider); 178 179 wireKeyWarningForSession(state.docId, document.querySelector<HTMLElement>('.app-topbar')); 180 + wireSaveStatus({ provider, ydoc }); 179 181 provider.on('sync', () => { 180 182 loadDeckFromYjs(); 181 183 actions.render();
+1 -1
tests/save-indicator.test.ts
··· 1 1 import { describe, it, expect } from 'vitest'; 2 - import { formatSaveTimestamp, getSaveDisplayText, type SaveState } from '../src/sheets/save-indicator.js'; 2 + import { formatSaveTimestamp, getSaveDisplayText, type SaveState } from '../src/lib/save-indicator.js'; 3 3 4 4 describe('formatSaveTimestamp', () => { 5 5 it('shows "Saved" for less than 5 seconds', () => {
+158
tests/save-status-ui.test.ts
··· 1 + // @vitest-environment jsdom 2 + import { describe, it, expect, beforeEach, vi } from 'vitest'; 3 + import { wireSaveStatus } from '../src/lib/save-status-ui.js'; 4 + 5 + type Handler = (...args: any[]) => void; 6 + 7 + function makeFakeProvider() { 8 + const handlers = new Map<string, Handler[]>(); 9 + const provider = { 10 + connected: true, 11 + on(event: string, fn: Handler) { 12 + const list = handlers.get(event) || []; 13 + list.push(fn); 14 + handlers.set(event, list); 15 + }, 16 + _fire(event: string, payload: unknown) { 17 + (handlers.get(event) || []).forEach(fn => fn(payload)); 18 + }, 19 + }; 20 + return provider; 21 + } 22 + 23 + function makeFakeYdoc() { 24 + const handlers = new Map<string, Handler[]>(); 25 + const ydoc = { 26 + on(event: string, fn: Handler) { 27 + const list = handlers.get(event) || []; 28 + list.push(fn); 29 + handlers.set(event, list); 30 + }, 31 + _fire(event: string, ...args: any[]) { 32 + (handlers.get(event) || []).forEach(fn => fn(...args)); 33 + }, 34 + }; 35 + return ydoc; 36 + } 37 + 38 + function mountDOM() { 39 + document.body.innerHTML = ` 40 + <div class="save-indicator saved" id="save-indicator"> 41 + <span class="save-dot save-dot--saved"></span> 42 + <span id="save-text">Saved</span> 43 + </div> 44 + `; 45 + } 46 + 47 + describe('wireSaveStatus', () => { 48 + beforeEach(() => { 49 + document.body.innerHTML = ''; 50 + vi.useRealTimers(); 51 + }); 52 + 53 + it('switches indicator to saving on save-status=saving', () => { 54 + mountDOM(); 55 + const provider = makeFakeProvider(); 56 + const ydoc = makeFakeYdoc(); 57 + wireSaveStatus({ provider, ydoc }); 58 + 59 + provider._fire('save-status', { status: 'saving' }); 60 + 61 + const indicator = document.getElementById('save-indicator')!; 62 + expect(indicator.classList.contains('saving')).toBe(true); 63 + expect(indicator.classList.contains('saved')).toBe(false); 64 + expect(document.getElementById('save-text')!.textContent).toBe('Saving\u2026'); 65 + }); 66 + 67 + it('restores saved state on save-status=saved', () => { 68 + mountDOM(); 69 + const provider = makeFakeProvider(); 70 + const ydoc = makeFakeYdoc(); 71 + wireSaveStatus({ provider, ydoc }); 72 + 73 + provider._fire('save-status', { status: 'saving' }); 74 + provider._fire('save-status', { status: 'saved' }); 75 + 76 + const indicator = document.getElementById('save-indicator')!; 77 + expect(indicator.classList.contains('saved')).toBe(true); 78 + expect(indicator.classList.contains('saving')).toBe(false); 79 + expect(document.getElementById('save-text')!.textContent).toBe('Saved'); 80 + }); 81 + 82 + it('flips save-dot classes on state transitions', () => { 83 + mountDOM(); 84 + const provider = makeFakeProvider(); 85 + const ydoc = makeFakeYdoc(); 86 + wireSaveStatus({ provider, ydoc }); 87 + 88 + const dot = document.querySelector('.save-dot')!; 89 + 90 + provider._fire('save-status', { status: 'saving' }); 91 + expect(dot.classList.contains('save-dot--saving')).toBe(true); 92 + 93 + provider._fire('save-status', { status: 'error' }); 94 + expect(dot.classList.contains('save-dot--error')).toBe(true); 95 + expect(dot.classList.contains('save-dot--saving')).toBe(false); 96 + 97 + provider._fire('save-status', { status: 'saved' }); 98 + expect(dot.classList.contains('save-dot--saved')).toBe(true); 99 + expect(dot.classList.contains('save-dot--error')).toBe(false); 100 + }); 101 + 102 + it('shows "Saved locally" when provider is disconnected', () => { 103 + mountDOM(); 104 + const provider = makeFakeProvider(); 105 + provider.connected = false; 106 + const ydoc = makeFakeYdoc(); 107 + wireSaveStatus({ provider, ydoc }); 108 + 109 + provider._fire('save-status', { status: 'saved' }); 110 + 111 + expect(document.getElementById('save-text')!.textContent).toBe('Saved locally'); 112 + }); 113 + 114 + it('marks indicator unsaved on save-status=error', () => { 115 + mountDOM(); 116 + const provider = makeFakeProvider(); 117 + const ydoc = makeFakeYdoc(); 118 + wireSaveStatus({ provider, ydoc }); 119 + 120 + provider._fire('save-status', { status: 'error' }); 121 + 122 + const indicator = document.getElementById('save-indicator')!; 123 + expect(indicator.classList.contains('unsaved')).toBe(true); 124 + expect(document.getElementById('save-text')!.textContent).toBe('Unsaved changes'); 125 + }); 126 + 127 + it('marks indicator unsaved on ydoc update from non-provider origin', () => { 128 + mountDOM(); 129 + const provider = makeFakeProvider(); 130 + const ydoc = makeFakeYdoc(); 131 + wireSaveStatus({ provider, ydoc }); 132 + 133 + ydoc._fire('update', new Uint8Array(), 'local-user'); 134 + 135 + const indicator = document.getElementById('save-indicator')!; 136 + expect(indicator.classList.contains('unsaved')).toBe(true); 137 + }); 138 + 139 + it('ignores ydoc updates whose origin is the provider (avoids echo flap)', () => { 140 + mountDOM(); 141 + const provider = makeFakeProvider(); 142 + const ydoc = makeFakeYdoc(); 143 + wireSaveStatus({ provider, ydoc }); 144 + 145 + ydoc._fire('update', new Uint8Array(), provider); 146 + 147 + const indicator = document.getElementById('save-indicator')!; 148 + expect(indicator.classList.contains('unsaved')).toBe(false); 149 + expect(indicator.classList.contains('saved')).toBe(true); 150 + }); 151 + 152 + it('gracefully no-ops when DOM is absent (no crash)', () => { 153 + const provider = makeFakeProvider(); 154 + const ydoc = makeFakeYdoc(); 155 + expect(() => wireSaveStatus({ provider, ydoc })).not.toThrow(); 156 + expect(() => provider._fire('save-status', { status: 'saving' })).not.toThrow(); 157 + }); 158 + });
+1 -1
tests/sheets-logic.test.ts
··· 2 2 import { cellId, colToLetter, parseRef, letterToCol } from '../src/sheets/formulas.js'; 3 3 import { normalizeRange, isInRange, getRangeRefString } from '../src/sheets/selection-utils.js'; 4 4 import { buildMergeMap, findCellMerge } from '../src/sheets/merge-utils.js'; 5 - import { formatSaveTimestamp, getSaveDisplayText } from '../src/sheets/save-indicator.js'; 5 + import { formatSaveTimestamp, getSaveDisplayText } from '../src/lib/save-indicator.js'; 6 6 7 7 describe('normalizeRange', () => { 8 8 it('returns same range when already normalized', () => {