Skip to content

feat: global Ctrl/Cmd+S keyboard shortcut to export all notes (save all)#474

Draft
Copilot wants to merge 8 commits into
mainfrom
copilot/automatic-global-export-ctrl-s
Draft

feat: global Ctrl/Cmd+S keyboard shortcut to export all notes (save all)#474
Copilot wants to merge 8 commits into
mainfrom
copilot/automatic-global-export-ctrl-s

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented May 5, 2026

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 — Adds exportAllNotesToMarkdownZip(notes): exports a single .md file when there is one note, or a .zip archive containing one .md file per note when there are multiple notes.

  • src/pages/Notes.jsx — Adds a global window keydown listener for Ctrl+S / Cmd+S. Calls exportAllNotesToMarkdownZip(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 textarea onKeyDown guard to also cover metaKey (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

Notes count Ctrl/Cmd+S result
0 No-op
1 Downloads braindump_<title>_YYYYMMDD_HHmm.md
2+ Downloads braindump_md_export_YYYY-MM-DD.zip containing one .md per note

Copilot AI changed the title [WIP] Add automatic global export on ctrl+s keystroke feat: global Ctrl/Cmd+S keyboard shortcut to export current note May 5, 2026
Copilot AI requested a review from ayanimea May 5, 2026 07:00
Copilot AI temporarily deployed to github-pages May 5, 2026 07:33 Inactive
@ayanimea ayanimea requested a review from Copilot May 5, 2026 08:58
@ayanimea
Copy link
Copy Markdown
Owner

ayanimea commented May 5, 2026

@copilot The ctrl+s should behaves like a "save all", not only the current note

Copy link
Copy Markdown
Contributor

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

Step-by-step (what this PR changes)

  1. Adds a window-level keydown listener on the Notes page to intercept Ctrl/Cmd+S and trigger a Markdown export for the currently selected note.
  2. 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.
  3. 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 against AltGraph (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() or e.code === 'KeyS') and consider excluding e.altKey.
  • Only show the “exported” toast when an export actually occurs (and optionally warn when there’s nothing to export).
  • Make the global keydown listener 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.

Comment thread src/pages/Notes.jsx Outdated
// Global Ctrl/Cmd+S shortcut: export current note as markdown
useEffect(() => {
const handleGlobalKeyDown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
Comment thread src/pages/Notes.jsx Outdated
Comment on lines +110 to +122
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])
Comment thread src/pages/Notes.jsx Outdated
const handleGlobalKeyDown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
if (currentNoteId) {
Comment thread src/components/Notes/NoteEditor.jsx Outdated
}, 0)
}
} else if (e.ctrlKey && e.key === 's') {
} else if ((e.ctrlKey || e.metaKey) && e.key === 's') {
Comment thread src/__tests__/Notes.test.js Outdated
Comment on lines +676 to +685
// 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()
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 5, 2026

@copilot The ctrl+s should behaves like a "save all", not only the current note

Done in e3f217b. Ctrl+S now exports all notes:

  • 1 note → downloads a single .md file directly
  • Multiple notes → downloads braindump_md_export_YYYY-MM-DD.zip containing one .md file per note

Copilot AI changed the title feat: global Ctrl/Cmd+S keyboard shortcut to export current note feat: global Ctrl/Cmd+S keyboard shortcut to export all notes (save all) May 5, 2026
Copilot AI temporarily deployed to github-pages May 5, 2026 09:48 Inactive
@ayanimea ayanimea requested a review from Copilot May 5, 2026 10:01
Copy link
Copy Markdown
Contributor

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

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

Comment on lines +805 to +808
if (notes.length === 1) {
const [note] = notes
exportNoteToFile(note.title, note.content)
return
Comment thread src/pages/Notes.jsx Outdated
// Global Ctrl/Cmd+S shortcut: export all notes as markdown (save all)
useEffect(() => {
const handleGlobalKeyDown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
Comment thread src/pages/Notes.jsx Outdated
Comment on lines +116 to +122
exportAllNotesToMarkdownZip(notes).then(() => {
showToastNotification(
notes.length === 1
? '✓ Note exported'
: '✓ All notes exported as ZIP'
)
})
Comment thread src/components/Notes/NoteEditor.jsx Outdated
}, 0)
}
} else if (e.ctrlKey && e.key === 's') {
} else if ((e.ctrlKey || e.metaKey) && e.key === 's') {
Comment on lines +718 to +723
// 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))

@ayanimea
Copy link
Copy Markdown
Owner

ayanimea commented May 5, 2026

@copilot apply changes based on the comments in this thread

@ayanimea ayanimea closed this May 5, 2026
@ayanimea ayanimea reopened this May 5, 2026
@ayanimea
Copy link
Copy Markdown
Owner

ayanimea commented May 5, 2026

@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>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 5, 2026

@copilot apply changes based on the comments in this thread

Done in 0e1d2da. All five review points addressed:

  • Case sensitivitye.key.toLowerCase() === 's' in both Notes.jsx and NoteEditor.jsx (CapsLock/Shift safe)
  • Error handling — Ctrl+S export now uses .catch() with logger.error + '⚠️ Export failed.' toast, matching the other export handlers
  • Always-export single noteexportAllNotesToMarkdownZip single-note branch now directly creates the Blob with note.content ?? '' and calls downloadBlob, so empty notes are still exported (consistent with the ZIP path)
  • No-op test reliabilitysetTimeout(50) replaced with waitFor(() => expect(screen.getByLabelText('Export')).toBeInTheDocument()) to ensure effects are attached before the key event fires, and the assertion uses waitFor rather than a bare timeout

@ayanimea
Copy link
Copy Markdown
Owner

ayanimea commented May 5, 2026

@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>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 5, 2026

@copilot apply changes based on the comments in this thread

Done in ccd3bdd. All three items addressed:

  • Stable listenernotesRef and showToastRef are updated directly during render so they always hold the latest values; the keydown effect now has an empty [] dependency array and is registered exactly once, with no add/remove churn while typing.
  • e.repeat guarde.preventDefault() still runs on every keydown (including repeats, to suppress the browser Save dialog), but if (e.repeat) return immediately skips the export — so holding Ctrl/Cmd+S triggers only a single download.
  • Shared makeUniqueZipEntryName helper — extracted as a module-level function taking (note, index, ext, usedSet); both exportAllNotesToMarkdownZip (.md) and exportAllNotesToOdtZip (.odt) delegate to it, removing the duplicated deconfliction logic.

@ayanimea
Copy link
Copy Markdown
Owner

@copilot Workflows are failing

@ayanimea
Copy link
Copy Markdown
Owner

@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>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 11, 2026

@copilot retry

Fixed in 388927d. The CI failure was an npm audit failure — 2 moderate vulnerabilities in production deps (ip-address XSS via express-rate-limit) and 2 high in dev deps (@babel/plugin-transform-modules-systemjs, fast-uri). Resolved by:

  • express-rate-limit ^8.2.2^8.5.1 (pulls in ip-address 10.2.0, clear of the XSS advisory)
  • npm audit fix to patch the two HIGH dev-dep transitive issues

Both npm audit --omit=dev and npm audit now report 0 vulnerabilities.

Copy link
Copy Markdown
Contributor

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

Copilot reviewed 6 out of 7 changed files in this pull request and generated 1 comment.

Comment thread src/__tests__/Notes.test.js Outdated
Comment on lines +726 to +728
await waitFor(() => {
expect(mockClick).not.toHaveBeenCalled()
})
@ayanimea
Copy link
Copy Markdown
Owner

@copilot apply changes based on the comments in this thread

Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 11, 2026

@copilot apply changes based on the comments in this thread

Done in 33ce29a. Replaced the waitFor wrapper with await act(async () => {}) (which flushes all pending microtasks and React effects) followed by a direct synchronous expect(mockClick).not.toHaveBeenCalled(). This ensures any async export path has had the opportunity to run before the assertion, eliminating the false-positive.

Copy link
Copy Markdown
Contributor

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

Copilot reviewed 6 out of 7 changed files in this pull request and generated 2 comments.

Comment on lines +846 to +847
const blob = new Blob([note.content ?? ''], { type: 'text/markdown' })
downloadBlob(blob, generateBrainDumpFilename(note.title))
Comment on lines +841 to +861
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`)
}
@ayanimea
Copy link
Copy Markdown
Owner

@copilot apply changes based on the comments in this thread

Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 11, 2026

@copilot apply changes based on the comments in this thread

Done in c298dbd. Both points addressed:

  • Single-note branch behavior: Added an explicit comment documenting that exportAllNotesToMarkdownZip always exports, even for empty content — intentionally different from exportNoteToFile() (which returns early for falsy content) to stay consistent with the multi-note ZIP path.
  • Unit tests for exportAllNotesToMarkdownZip: Added a new describe block in noteOperations.test.js with 5 tests:
    • No-op for empty/invalid input
    • Single note → .md with correct content and braindump_<title>_YYYYMMDD_HHmm.md filename
    • Single note with empty content → still downloads .md (explicit behavior test)
    • Multi-note → .zip with one .md per note containing correct content
    • Deconflicted ZIP entry names for notes with identical titles

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.

Automatic global export when using the ctrl+s keystroke

3 participants