feat: global Ctrl/Cmd+S keyboard shortcut to export all notes (save all)#474
feat: global Ctrl/Cmd+S keyboard shortcut to export all notes (save all)#474Copilot wants to merge 8 commits into
Conversation
…as markdown Agent-Logs-Url: https://github.com/ayanimea/aurorae-haven/sessions/2c1e9744-43c2-4205-8457-fde50573b7a7 Co-authored-by: ayanimea <13433370+ayanimea@users.noreply.github.com>
|
@copilot The ctrl+s should behaves like a "save all", not only the current note |
There was a problem hiding this comment.
Pull request overview
Step-by-step (what this PR changes)
- Adds a
window-levelkeydownlistener on the Notes page to intercept Ctrl/Cmd+S and trigger a Markdown export for the currently selected note. - Extends the NoteEditor textarea Ctrl+S guard to also cover Cmd+S (Mac) to prevent the browser “Save As” dialog when the textarea is focused.
- Adds test coverage to verify Ctrl+S export, Cmd+S export, and no export when no note is selected.
Changes:
- Global Ctrl/Cmd+S now exports the current note as Markdown (instead of being intercepted and discarded).
- Textarea keydown guard now prevents Cmd+S as well as Ctrl+S.
- New tests validate keyboard-triggered export behavior.
Findings
- Bug:
e.key === 's'is case-sensitive and can miss Ctrl/Cmd+S in some scenarios (Shift/CapsLock/layout). Also consider guarding againstAltGraph(e.altKey) to avoid unintended exports. - Bug/UX: The global handler shows a success toast even when
exportNoteToFile()does nothing (empty content), which can mislead users. - Performance/Maintainability: The global key listener effect is re-registered on essentially every render/keystroke due to dependencies (
title,content,showToastNotification), which is avoidable. - Testing: One new test relies on an arbitrary real timeout; this can add flakiness and slowdowns.
Action checklist
- Normalize the shortcut key check (
e.key.toLowerCase()ore.code === 'KeyS') and consider excludinge.altKey. - Only show the “exported” toast when an export actually occurs (and optionally warn when there’s nothing to export).
- Make the global
keydownlistener stable (register once; read latest state from refs or otherwise avoid per-keystroke re-registration). - Replace the arbitrary
setTimeout(50)in the new no-op test with a less timing-dependent assertion approach.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| src/pages/Notes.jsx | Adds global Ctrl/Cmd+S handler to export the current note and show a toast. |
| src/components/Notes/NoteEditor.jsx | Prevents browser Save dialog for Cmd+S as well as Ctrl+S in the editor. |
| src/tests/Notes.test.js | Adds tests for Ctrl+S/Cmd+S export behavior and no-op when no note is selected. |
| // Global Ctrl/Cmd+S shortcut: export current note as markdown | ||
| useEffect(() => { | ||
| const handleGlobalKeyDown = (e) => { | ||
| if ((e.ctrlKey || e.metaKey) && e.key === 's') { |
| useEffect(() => { | ||
| const handleGlobalKeyDown = (e) => { | ||
| if ((e.ctrlKey || e.metaKey) && e.key === 's') { | ||
| e.preventDefault() | ||
| if (currentNoteId) { | ||
| exportNoteToFile(title, content) | ||
| showToastNotification('✓ Note exported') | ||
| } | ||
| } | ||
| } | ||
| window.addEventListener('keydown', handleGlobalKeyDown) | ||
| return () => window.removeEventListener('keydown', handleGlobalKeyDown) | ||
| }, [currentNoteId, title, content, showToastNotification]) |
| const handleGlobalKeyDown = (e) => { | ||
| if ((e.ctrlKey || e.metaKey) && e.key === 's') { | ||
| e.preventDefault() | ||
| if (currentNoteId) { |
| }, 0) | ||
| } | ||
| } else if (e.ctrlKey && e.key === 's') { | ||
| } else if ((e.ctrlKey || e.metaKey) && e.key === 's') { |
| // Empty localStorage — no notes, so currentNoteId stays null | ||
| render(<Notes />) | ||
|
|
||
| // Fire global Ctrl+S — should be a no-op because currentNoteId is null | ||
| fireEvent.keyDown(window, { key: 's', ctrlKey: true }) | ||
|
|
||
| // Give time for any async operations | ||
| await new Promise((resolve) => setTimeout(resolve, 50)) | ||
|
|
||
| expect(mockClick).not.toHaveBeenCalled() |
…tiple Agent-Logs-Url: https://github.com/ayanimea/aurorae-haven/sessions/93c49ebb-9035-49b8-ab61-2acd9a242eda Co-authored-by: ayanimea <13433370+ayanimea@users.noreply.github.com>
| if (notes.length === 1) { | ||
| const [note] = notes | ||
| exportNoteToFile(note.title, note.content) | ||
| return |
| // Global Ctrl/Cmd+S shortcut: export all notes as markdown (save all) | ||
| useEffect(() => { | ||
| const handleGlobalKeyDown = (e) => { | ||
| if ((e.ctrlKey || e.metaKey) && e.key === 's') { |
| exportAllNotesToMarkdownZip(notes).then(() => { | ||
| showToastNotification( | ||
| notes.length === 1 | ||
| ? '✓ Note exported' | ||
| : '✓ All notes exported as ZIP' | ||
| ) | ||
| }) |
| }, 0) | ||
| } | ||
| } else if (e.ctrlKey && e.key === 's') { | ||
| } else if ((e.ctrlKey || e.metaKey) && e.key === 's') { |
| // Fire global Ctrl+S — should be a no-op because notes array is empty | ||
| fireEvent.keyDown(window, { key: 's', ctrlKey: true }) | ||
|
|
||
| // Give time for any async operations | ||
| await new Promise((resolve) => setTimeout(resolve, 50)) | ||
|
|
|
@copilot apply changes based on the comments in this thread |
|
…always-export, test reliability Agent-Logs-Url: https://github.com/ayanimea/aurorae-haven/sessions/538582f4-a849-4db7-88b4-0b6b8d78be13 Co-authored-by: ayanimea <13433370+ayanimea@users.noreply.github.com>
Done in 0e1d2da. All five review points addressed:
|
|
@copilot apply changes based on the comments in this thread |
… makeUniqueZipEntryName helper Agent-Logs-Url: https://github.com/ayanimea/aurorae-haven/sessions/2d729ece-9afe-4aec-9515-a9d492776c94 Co-authored-by: ayanimea <13433370+ayanimea@users.noreply.github.com>
Done in ccd3bdd. All three items addressed:
|
|
@copilot Workflows are failing |
|
@copilot retry |
…ar all vulnerabilities Agent-Logs-Url: https://github.com/ayanimea/aurorae-haven/sessions/1a1ebff7-b4c4-4e33-bd06-dfeed3805c2a Co-authored-by: ayanimea <13433370+ayanimea@users.noreply.github.com>
Fixed in 388927d. The CI failure was an
Both |
| await waitFor(() => { | ||
| expect(mockClick).not.toHaveBeenCalled() | ||
| }) |
|
@copilot apply changes based on the comments in this thread |
…ous assertion Agent-Logs-Url: https://github.com/ayanimea/aurorae-haven/sessions/0eb78939-d083-4c31-9b1b-e2e312e108e8 Co-authored-by: ayanimea <13433370+ayanimea@users.noreply.github.com>
Done in 33ce29a. Replaced the |
| const blob = new Blob([note.content ?? ''], { type: 'text/markdown' }) | ||
| downloadBlob(blob, generateBrainDumpFilename(note.title)) |
| export async function exportAllNotesToMarkdownZip(notes) { | ||
| if (!Array.isArray(notes) || notes.length === 0) return | ||
|
|
||
| if (notes.length === 1) { | ||
| const [note] = notes | ||
| const blob = new Blob([note.content ?? ''], { type: 'text/markdown' }) | ||
| downloadBlob(blob, generateBrainDumpFilename(note.title)) | ||
| return | ||
| } | ||
|
|
||
| const zip = new JSZip() | ||
| const usedEntryNames = new Set() | ||
|
|
||
| for (const [index, note] of notes.entries()) { | ||
| const entryName = makeUniqueZipEntryName(note, index, '.md', usedEntryNames) | ||
| zip.file(entryName, note.content || '') | ||
| } | ||
|
|
||
| const zipBlob = await zip.generateAsync({ type: 'blob' }) | ||
| downloadBlob(zipBlob, `braindump_md_export_${new Date().toISOString().slice(0, 10)}.zip`) | ||
| } |
|
@copilot apply changes based on the comments in this thread |
…ort behavior Agent-Logs-Url: https://github.com/ayanimea/aurorae-haven/sessions/c2829452-07d2-41dd-949b-8c7797660e57 Co-authored-by: ayanimea <13433370+ayanimea@users.noreply.github.com>
Done in c298dbd. Both points addressed:
|
Ctrl+S was intercepted in the textarea but silently discarded — no export occurred. The shortcut now triggers a "save all" markdown export of every note from anywhere on the page.
Changes
src/utils/notes/noteOperations.js— AddsexportAllNotesToMarkdownZip(notes): exports a single.mdfile when there is one note, or a.ziparchive containing one.mdfile per note when there are multiple notes.src/pages/Notes.jsx— Adds a globalwindowkeydownlistener forCtrl+S/Cmd+S. CallsexportAllNotesToMarkdownZip(notes)(all notes, not just the current one) and shows a contextual toast. Listener is cleaned up on unmount.src/components/Notes/NoteEditor.jsx— Extends the existing textareaonKeyDownguard to also covermetaKey(Mac Cmd+S), preventing the browser "Save As" dialog when the textarea has focus.src/__tests__/Notes.test.js— Adds tests for single-note Ctrl+S (.md), single-note Cmd+S (.md), multi-note Ctrl+S (.zip), and no-op when there are no notes.docs/NOTES_USAGE.md— Updates the Export section and keyboard reference card to describe the save-all behaviour.Behaviour
braindump_<title>_YYYYMMDD_HHmm.mdbraindump_md_export_YYYY-MM-DD.zipcontaining one.mdper note