Skip to content

Add multi-tab support to scratchpad#1

Open
bakedbean wants to merge 25 commits intomainfrom
eben/add-tabs
Open

Add multi-tab support to scratchpad#1
bakedbean wants to merge 25 commits intomainfrom
eben/add-tabs

Conversation

@bakedbean
Copy link
Copy Markdown
Owner

Summary

Converts the scratchpad from a single contenteditable into a multi-tab editor. Each tab keeps its own content, undo history, and cursor position; tabs and content sync across devices via Firefox Sync the same way the single pad did. Existing single-pad content migrates seamlessly to a tab named "Scratchpad" on first launch.

The monolithic scratchpad.js is split into focused modules sharing a single window.Scratchpad namespace:

flowchart LR
    HTML[scratchpad.html] --> S[storage.js]
    HTML --> E[editor.js]
    HTML --> M[migration.js]
    HTML --> T[tabs.js]
    HTML --> SY[sync.js]
    HTML --> SP[scratchpad.js<br/>lifecycle/wiring]
    SP --> S
    SP --> M
    SP --> T
    SP --> SY
    T --> S
    T --> E
    T --> M
    SY --> S
    SY --> T
Loading

Storage layout in browser.storage.sync:

  • tabIndex — ordered [{id, name}] array
  • tab_<id> — one item per tab's HTML content (stays under the ~8KB per-item sync quota)
  • activeTabId lives in browser.storage.local so each device remembers its own focus

Highlights:

  • Unlimited tabs with overflow dropdown
  • Double-click to rename; × close with 5-second Undo toast; cannot close the last tab
  • Keyboard: Ctrl+Alt+T (new), Ctrl+Alt+W (close), Ctrl+Tab / Ctrl+Shift+Tab (cycle); Ctrl+B/I/U/K formatting unchanged
  • Cross-device sync for both content and tab list, with a per-tab "user is typing" guard, orphan recovery for concurrent tab creation, and a "Save as new tab?" banner when an active tab is deleted on another device
  • Per-tab fallback to storage.local when the sync quota is exceeded
  • Migration is self-healing: every load sweeps any orphaned scratchpadContent left by a partial-failure migration

Manifest version bumped to 2.0. Spec at docs/superpowers/specs/2026-05-03-tabs-design.md, plan at docs/superpowers/plans/2026-05-03-tabs-implementation.md.

Complexity Notes

A few areas to look at carefully:

  • Per-tab save chain (tabs.js _saveTab/_saveTabNow) — saves for the same tab serialize via s.saveChain so two concurrent Storage.set calls cannot land out of order. _removeTabInternal sets a disposed flag and cancels the pending timer to prevent a debounced save from resurrecting a just-closed tab.
  • Sync._applyIndex ordering — captures the active tab's content before mutation (because applyRemoteIndex may relocate it), runs orphan recovery, then applies adds/removes/renames. The "Save as new tab?" banner removes the deleted tab placeholder before persisting so the persisted tabIndex doesn't re-insert it on other devices.
  • Local-fallback bypass in syncSync.pollOnce and _handleChanges skip per-tab content updates for tabs in Tabs._localFallbackTabs. Without this, the next 10s poll would clobber the user's local-fallback content with stale sync data.
  • Document-level shortcuts ignore the rename input_installShortcuts early-returns when the focus target is a .tab-rename-input, so Ctrl+Alt+W and Ctrl+Tab don't hijack mid-rename.
  • Last-writer-wins is accepted by design for both content and tabIndex. Orphan recovery converts that into eventual consistency for content (tab_<id> keys are never permanently lost), but two simultaneous renames or two simultaneous deletes will still resolve to a single winner.
  • No automated tests in this repo — verification is manual via "Load Temporary Add-on" in Firefox. See test steps below.

Test Steps

Load the build via about:debugging#/runtime/this-firefoxLoad Temporary Add-on → select manifest.json, then click the toolbar icon to open the scratchpad. Verify:

  1. Migration: if you previously had content in scratchpadContent, it now appears under a tab named "Scratchpad". Fresh installs show one empty "Tab 1". Browser console shows the relevant [Migration] log.
  2. Tab CRUD: click + (or Ctrl+Alt+T) to create. Double-click a label → edit → Enter / blur / Esc. Click × (or Ctrl+Alt+W) → undo within 5s. With one tab open, × is hidden and Ctrl+Alt+W is a no-op.
  3. Save-after-close fix (C1): type a few characters and immediately close the tab — reload the page; the closed tab does NOT reappear as a recovered orphan.
  4. Shortcut/rename fix (C2): start renaming a tab, then press Ctrl+Alt+W while the input is focused — the rename input keeps focus; the tab is NOT closed.
  5. Active-tab persistence: switch to a non-first tab, reload — the same tab is active.
  6. Per-tab editor state: type in tab A, switch to B, type, switch back — both contents intact. Ctrl+Z undoes only within the active tab.
  7. Keyboard shortcuts: Ctrl+B/I/U/K formatting; Ctrl+Tab and Ctrl+Shift+Tab cycle (subject to Firefox interception in some configs).
  8. Overflow: shrink the window until tabs no longer fit — button appears; clicking shows hidden tabs; selecting one activates it. Click outside the menu to close.
  9. Cross-device sync (optional, requires two profiles signed into the same Firefox Account): edit a tab on profile A, observe profile B updates within ~30s. Create / rename / close on A; B reflects. Close on A while B has that tab active — B shows the "Save as new tab?" banner with intact content. Concurrent create while one device is offline → both tabs end up on both devices via orphan recovery.
  10. Quota fallback (best-effort): in the console, fill sync near the cap (e.g. await browser.storage.sync.set({ scratchpadBig: 'x'.repeat(120 * 1024) })), then type into a tab. The yellow "Sync storage full" banner should appear; subsequent typing in that tab continues to save (locally). Clean up with await browser.storage.sync.remove('scratchpadBig').

Checklist

  • Tests added/updated (N/A — repo has no automated test framework; manual verification only)
  • Documentation updated — README.md refreshed; spec + implementation plan committed under docs/superpowers/

bakedbean added 25 commits May 3, 2026 12:30
Captures decisions, storage schema, sync semantics, and code
organization for adding tabs to the extension.
15 bite-sized tasks covering module split, migration, tab CRUD,
sync semantics, orphan recovery, overflow, and quota fallback.
Each task ends with manual verification steps in Firefox since
the project has no automated test framework.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR upgrades the Scratchpad Firefox extension from a single editor into a multi-tab editor with per-tab content, undo/cursor state, and cross-device sync, while migrating existing single-pad content into a default “Scratchpad” tab.

Changes:

  • Introduces a tab model (tabIndex + per-tab tab_<id> keys) with CRUD, rename, undo-close, and overflow UI.
  • Refactors the monolithic scratchpad logic into modules (storage, editor, migration, tabs, sync) under window.Scratchpad.
  • Adds sync logic for tab list + content, including orphan recovery and a “tab deleted on another device” banner.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
tabs.js Implements tab state, editor-per-tab creation, saving, rename/close/undo, shortcuts, overflow menu, and quota fallback.
sync.js Adds storage.onChanged + polling sync logic for tabIndex and tab_<id> entries, plus orphan recovery and deletion banner flow.
storage.js Wraps sync/local storage selection and provides a primary-area change listener + local helper area.
scratchpad.js Rewires lifecycle to initialize modules, hydrate tabs from storage, run orphan recovery, and hook toolbar/refresh/intervals.
scratchpad.html Adds tab bar + editor stack containers and loads new module scripts in order.
scratchpad.css Styles tab bar, overflow menu, editor stack, undo toast, and banners.
migration.js Migrates legacy scratchpadContent into tabbed storage and sweeps partial migrations.
editor.js Centralizes HTML/URL sanitization and editor shortcut behavior.
manifest.json Bumps extension version to 2.0 and updates description.
README.md Updates documentation for multi-tab behavior, shortcuts, and storage layout.
docs/...tabs-design.md Adds design spec for the multi-tab approach.
docs/...tabs-implementation.md Adds detailed implementation plan and manual verification steps.
Comments suppressed due to low confidence (1)

scratchpad.js:49

  • updateStatus('error') sets inline backgroundColor / color, but non-error statuses never clear those inline styles. After the first error, subsequent "Saving"/"Saved"/"Ready" states can remain red because inline styles override the .status / .status.saving / .status.saved CSS. Reset statusDiv.style.backgroundColor / statusDiv.style.color at the start of updateStatus, or use a dedicated .status.error CSS class instead of inline styles.
function updateStatus(status, message = '') {
  statusDiv.className = 'status';
  switch (status) {
    case 'saving':
      statusDiv.textContent = 'Saving...';
      statusDiv.classList.add('saving');
      break;
    case 'saved':
      const storageType = Scratchpad.Storage.usingSync ? 'Synced' : 'Saved';
      statusDiv.textContent = storageType;
      statusDiv.classList.add('saved');
      setTimeout(() => {
        if (statusDiv.textContent === storageType) updateStatus('ready');
      }, 2000);
      break;
    case 'loaded':
    case 'ready':
      statusDiv.textContent = Scratchpad.Storage.usingSync ? 'Ready (Synced)' : 'Ready (Local)';
      break;
    case 'error':
      statusDiv.textContent = message ? `Error: ${message}` : 'Error';
      statusDiv.style.backgroundColor = '#f8d7da';
      statusDiv.style.color = '#721c24';
      break;

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tabs.js
Comment on lines +570 to +573
try {
await Scratchpad.Storage.set({ tabIndex: Tabs._buildIndex() });
await Scratchpad.Storage.remove('tab_' + id);
} catch (e) {
Comment thread tabs.js
Comment on lines +222 to +245
const writeKey = 'tab_' + id;
const useLocal = Tabs._localFallbackTabs.has(id);
const target = useLocal ? Scratchpad.Storage.Local : Scratchpad.Storage;

try {
await target.set({ [writeKey]: current });
s.lastSavedContent = current;
s.contentChanged = false;
Tabs.onStatus('saved');
Tabs.onUpdate();
console.log(`[Tabs] ${id}: saved ${current.length} chars (${useLocal ? 'local fallback' : 'sync'})`);
} catch (e) {
if (Tabs._isQuotaError(e) && !useLocal) {
console.warn(`[Tabs] ${id}: quota exceeded; falling back to local storage`);
Tabs._localFallbackTabs.add(id);
Tabs._showQuotaBanner();
// Retry once with local.
try {
await Scratchpad.Storage.Local.set({ [writeKey]: current });
s.lastSavedContent = current;
s.contentChanged = false;
Tabs.onStatus('saved');
Tabs.onUpdate();
} catch (e2) {
Comment thread scratchpad.js
Comment on lines +109 to +114
const result = await Scratchpad.Storage.get('tabIndex');
const tabIndex = Array.isArray(result.tabIndex) ? result.tabIndex : [];
const contentKeys = tabIndex.map(t => 'tab_' + t.id);
const contents = contentKeys.length > 0 ? await Scratchpad.Storage.get(contentKeys) : {};
const contentMap = {};
for (const t of tabIndex) contentMap[t.id] = contents['tab_' + t.id] || '';
Comment thread tabs.js
Comment on lines +77 to +89
const tabEl = document.createElement('div');
tabEl.className = 'tab';
tabEl.dataset.tabId = meta.id;
const labelEl = document.createElement('span');
labelEl.className = 'tab-label';
labelEl.textContent = meta.name;
const closeBtn = document.createElement('button');
closeBtn.className = 'tab-close';
closeBtn.title = 'Close tab';
closeBtn.textContent = '×';
tabEl.appendChild(labelEl);
tabEl.appendChild(closeBtn);
Tabs.tabListEl.appendChild(tabEl);
Comment thread tabs.js
Comment on lines +372 to +396
saveBtn.addEventListener('click', async () => {
banner.remove();
// Drop the deleted-tab placeholder from local state BEFORE persisting,
// so the new tabIndex written to sync storage does not include the
// deleted id (which would otherwise resurrect on other devices).
Tabs._removeTabInternal(tabId);
const newId = Scratchpad.Migration.newTabId();
Tabs._addTabInternal({ id: newId, name: tabName }, content);
try {
await Tabs._persistIndexAndContent(newId, content);
Tabs.switchToTab(newId);
Tabs.onUpdate();
} catch (e) {
console.error('[Tabs] save-as-new-tab failed:', e);
Tabs._removeTabInternal(newId);
Tabs.onStatus('error', 'Save as new tab failed');
}
Tabs._refreshOnlyTabClass();
});

dismissBtn.addEventListener('click', () => {
banner.remove();
Tabs._removeTabInternal(tabId);
Tabs._refreshOnlyTabClass();
});
Comment thread tabs.js
// Serialize per-tab saves: chain onto any in-flight save so writes for the
// same tab apply in call order. Without this, two concurrent Storage.set
// calls have no ordering guarantee, and a stale write could land last.
s.saveChain = (s.saveChain || Promise.resolve()).then(() => Tabs._saveTabNow(id));
Comment thread sync.js
Comment on lines +94 to +125
// Capture state about the active tab before applying changes (we may need it for the banner).
const activeId = Tabs.activeTabId;
const activeState = activeId ? Tabs.tabs.get(activeId) : null;
const activeName = activeState?.name;
const activeContent = activeState ? Scratchpad.Editor.getContent(activeState.editor) : '';

// Orphan recovery: if storage has tab_<id> keys not in the new index, append them.
const merged = await Sync._recoverOrphans(newIndex);

// Apply additions/removals/renames (the function leaves a removed-active tab in DOM for now).
const result = Tabs.applyRemoteIndex(merged, contentMap);

// For added tabs whose content wasn't in the change payload, fetch from storage.
const missingContent = result.added.filter(meta => !(meta.id in contentMap));
if (missingContent.length) {
const keys = missingContent.map(m => 'tab_' + m.id);
Scratchpad.Storage.get(keys).then(res => {
for (const meta of missingContent) {
if (Scratchpad.Tabs.isLocalFallback(meta.id)) continue;
const v = res['tab_' + meta.id];
if (typeof v === 'string') Scratchpad.Tabs.applyRemoteContent(meta.id, v);
}
}).catch(e => console.error('[Sync] backfill content failed:', e));
}

// If the active tab was removed remotely, show the banner.
if (result.activeRemoved && activeId) {
// Switch to a neighboring tab first.
const neighbor = merged[0]?.id;
if (neighbor) Tabs.switchToTab(neighbor);
Tabs.showTabDeletedBanner(activeId, activeName, activeContent);
}
Comment thread tabs.js
},

async _restoreClosedTab(meta, content, removedIndex) {
const state = Tabs._addTabInternal(meta, content);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants