feat: delete the selected note

This commit is contained in:
2026-10-19 19:43:25 +02:00
parent 0d9f7e0f00
commit 63d0387339
7 changed files with 156 additions and 8 deletions
+2 -2
View File
@@ -141,9 +141,9 @@ Search for **Conjure Notes**, identified by its centered 📔 icon, or assign it
Notes uses a standard macOS window with system-inherited appearance and only the native Close button visible; minimize and zoom are hidden. The editor uses monospaced text, including Markdown headings, bold, and italic, while retaining Conjure’s warm palette. Closing still saves first and keeps the window open if saving fails. Other launcher views remain borderless with their existing dark appearance.
Actions (Command-K) opens the same searchable popup style used throughout Conjure, with **Browse Notes** (Command-P) and **New Note** (Command-N). Command-N creates a note while the editor or either popup is focused. Command-P toggles Browse Notes even while its title-bar controls are covered. Escape or clicking outside closes an open popup and returns focus to the editor without leaving Notes; another Escape dismisses the launcher. Control-J/K navigates only while a popup is open, preserving normal editor commands when it is closed.
Actions (Command-K) opens the same searchable popup style used throughout Conjure, with **Browse Notes** (Command-P), **New Note** (Command-N), and **Delete Note**. Delete Note asks for confirmation before permanently deleting the currently selected note and its embedded images. Cancel is the default; deletion cannot be undone. After deletion, Notes selects a remaining note or creates a fresh blank note if none remain. Save or deletion failures preserve the current note and editor buffer. Command-N creates a note while the editor or either popup is focused. Command-P toggles Browse Notes even while its title-bar controls are covered. Escape or clicking outside closes an open popup and returns focus to the editor without leaving Notes; another Escape dismisses the launcher. Control-J/K navigates only while a popup is open, preserving normal editor commands when it is closed.
The bottom strip applies Markdown headings, bold, italic, strikethrough, inline code, links, fenced code blocks, quotes, numbered lists, bullet lists, and checklists. Formatting preserves embedded images and supports Undo. List buttons convert existing list types; clicking the current type removes its marker. The strip’s × hides formatting controls; the text-format icon restores them. Successful autosaves stay unobtrusive; only save or load errors appear below the editor.
The bottom strip applies Markdown headings, bold, italic, strikethrough, inline code, links, fenced code blocks, quotes, numbered lists, bullet lists, and checklists. Formatting preserves embedded images and supports Undo. List buttons convert existing list types; clicking the current type removes its marker. The strip’s × hides formatting controls; the text-format icon restores them. Successful autosaves stay unobtrusive; only save, load, or deletion errors appear below the editor.
Edit Markdown directly: headings, bold, italic, lists, links, inline code, and fenced code blocks are highlighted while the source remains visible, except for list markers rendered as inline tokens. Bullet (`-`, `*`, `+`), numbered (`1.`, `1)`), and native checkbox markers share a marker column, so item text aligns at the same nesting depth even with long numbers; wrapped lines align with the item text, not the marker. Paste images or drag image files into the editor to embed them locally; no file-picker button is needed. Return continues lists at the same indentation, incrementing numbers and resetting task checkboxes. On an empty item, Return exits one nesting level; repeat to leave the list entirely. Nesting has no fixed depth limit. Prose and fenced code retain ordinary newlines. Command-Z undoes edits; Command-Shift-Z redoes them. Undo history is cleared when switching notes so edits cannot spill into another note.
@@ -36,6 +36,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
var selectNote: ((UUID) -> Void)?
var browseNotes: (() -> Void)?
var newNote: (() -> Void)?
var deleteNote: (() -> Void)?
private enum ChoiceID: Equatable {
case language(String)
case note(UUID)
@@ -84,7 +85,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
ActionEntry(title: "MyMemory (no key)", symbol: "checkmark", color: SunsetPalette.text),
ActionEntry(title: "OpenAI · gpt-6-luna", symbol: "checkmark", color: SunsetPalette.text),
ActionEntry(title: "Browse Notes", symbol: "list.bullet", color: SunsetPalette.text, commandKey: "P"),
ActionEntry(title: "New Note", symbol: "square.and.pencil", color: SunsetPalette.text, commandKey: "N")
ActionEntry(title: "New Note", symbol: "square.and.pencil", color: SunsetPalette.text, commandKey: "N"),
ActionEntry(title: "Delete Note", symbol: "trash", color: .systemRed)
]
view = ActionsPanelView()
view.wantsLayer = true
@@ -257,7 +259,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
}
func showNotesActions(relativeTo anchor: NSView) {
show(title: "Notes", relativeTo: anchor, available: [12, 13],
show(title: "Notes", relativeTo: anchor, available: [12, 13, 14],
searchAccessibilityLabel: "Search notes actions", belowAnchor: true)
}
@@ -464,7 +466,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
}
private func handleCommandKey(_ key: String) -> Bool {
guard isShown, availableEntries == [12, 13] else { return false }
guard isShown, availableEntries == [12, 13, 14] else { return false }
switch key {
case "p": close(); browseNotes?()
case "n": close(); newNote?()
@@ -537,6 +539,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
case 11: selectProvider?(true)
case 12: browseNotes?()
case 13: newNote?()
case 14: deleteNote?()
default: break
}
}
+29 -1
View File
@@ -76,6 +76,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
actions.selectNote = { [weak self] in self?.switchNote($0) }
actions.newNote = { [weak self] in self?.newNote() }
actions.browseNotes = { [weak self] in self?.browseNotes() }
actions.deleteNote = { [weak self] in self?.deleteNote() }
let tools = [
("textformat.size", "Heading"), ("bold", "Bold"), ("italic", "Italic"),
("strikethrough", "Strikethrough"), ("chevron.left.forwardslash.chevron.right", "Inline code"),
@@ -509,6 +510,33 @@ final class NotesController: NSViewController, NSTextViewDelegate {
catch { showError(error) }
}
private func deleteNote() {
guard let window = view.window, window.attachedSheet == nil, let id = selectedID else { return }
actions.close(animated: false)
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Delete “\(noteTitle.stringValue)”?"
alert.informativeText = "This permanently deletes the note and its embedded images. This cannot be undone."
alert.addButton(withTitle: "Cancel")
alert.addButton(withTitle: "Delete Note").hasDestructiveAction = true
alert.beginSheetModal(for: window) { [weak self] response in
guard let self else { return }
defer { self.focusEditor() }
guard response == .alertSecondButtonReturn, self.selectedID == id,
self.save(), let store = self.store else { return }
do {
try store.delete(id: id)
self.selectedID = nil
self.defaults.removeObject(forKey: "selectedNote")
self.editor.setMarkdownContents(NSAttributedString(string: ""))
self.editor.undoManager?.removeAllActions()
self.editor.isEditable = false
self.noteTitle.stringValue = "Untitled Note"
self.select(try store.notes.first?.id ?? store.create())
} catch { self.showError(error) }
}
}
private func switchNote(_ id: UUID) {
guard save() else { return }
select(id)
@@ -532,7 +560,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
private func showError(_ error: Error) {
status.isHidden = false
status.stringValue = "Couldn’t save or load notes: \(error.localizedDescription)"
status.stringValue = "Couldn’t save, load, or delete notes: \(error.localizedDescription)"
status.toolTip = status.stringValue
status.textColor = .systemRed
}
+8
View File
@@ -63,6 +63,14 @@ public final class NotesStore {
notes[index] = note
}
public func delete(id: UUID) throws {
guard let index = notes.firstIndex(where: { $0.id == id }) else {
throw CocoaError(.fileNoSuchFile)
}
try FileManager.default.removeItem(at: directory.appendingPathComponent(id.uuidString + ".rtfd"))
notes.remove(at: index)
}
private func save(_ note: Note) throws {
let wrapper = try note.contents.fileWrapper(
from: NSRange(location: 0, length: note.contents.length),
+2 -2
View File
@@ -4,7 +4,7 @@
- [x] bug: looks like dragging around the conjure windows can cause other conjure windows of difference window sizes to spawn offset the corner instead of from the center of the windows
- when moving a conjure window, the saved position should always be the center of the window, and windows should always be spawned at some position taking to mean their center.
- this should keep window placement invariant of corner window sizes
- [ ] conjure notes:
- add action to delete a note (currently selected note)
- [x] conjure notes:
- [x] add action to delete a note (currently selected note)
- [ ] the guidelines for centering the application when dragged are blurry on some external monitors.
- track down the cause and fix it.
@@ -1495,3 +1495,82 @@ func notesFormattingPreservesAttachmentsMarkdownSelectionAndUndo() throws {
#expect(saved.contents.string == before)
#expect(saved.contents.attribute(.attachment, at: saved.contents.length - 1, effectiveRange: nil) is NSTextAttachment)
}
@Test @MainActor
func notesDeletionRequiresConfirmationPreservesFailuresAndReplacesTheLastNote() async throws {
_ = NSApplication.shared
let suite = "NotesDeletion.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
let backup = directory.appendingPathExtension("backup")
defer {
defaults.removePersistentDomain(forName: suite)
try? FileManager.default.removeItem(at: directory)
try? FileManager.default.removeItem(at: backup)
}
let store = try NotesStore(directory: directory)
let first = try store.create()
let second = try store.create()
try store.update(id: first, contents: NSAttributedString(string: "Delete this note"))
try store.update(id: second, contents: NSAttributedString(string: "Keep this note"))
defaults.set(first.uuidString, forKey: "selectedNote")
let controller = NotesController(defaults: defaults, directory: directory)
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 570, height: 404),
styleMask: [.titled], backing: .buffered, defer: false)
window.contentViewController = controller
defer { _ = controller.close(); window.orderOut(nil) }
window.orderFront(nil)
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
func delete(confirm: Bool) async throws {
controller.showActions()
let search = try #require(descendants(controller.view).compactMap { $0 as? NSSearchField }.first)
search.stringValue = "delete"
search.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: search))
#expect(controller.handle(#selector(NSResponder.insertNewline(_:))))
let sheet = try #require(window.attachedSheet)
let content = try #require(sheet.contentView)
let button = try #require(descendants(content).compactMap { $0 as? NSButton }
.first { $0.title == (confirm ? "Delete Note" : "Cancel") })
button.performClick(nil)
// Scheduling deadline only; deletion does not depend on wall-clock time.
for _ in 0..<1_000 {
if window.attachedSheet == nil, window.firstResponder === editor { return }
try await Task.sleep(for: .milliseconds(1))
}
try #require(window.attachedSheet == nil && window.firstResponder === editor)
}
try await delete(confirm: false)
#expect(defaults.string(forKey: "selectedNote") == first.uuidString)
#expect(editor.markdownString == "Delete this note")
#expect(Set(try NotesStore(directory: directory).notes.map(\.id)) == Set([first, second]))
// A failed save must not let confirmation discard an unsaved buffer.
try FileManager.default.moveItem(at: directory, to: backup)
try Data("Blocked storage".utf8).write(to: directory)
editor.insertText("Unsaved changes", replacementRange: NSRange(location: 0, length: editor.string.utf16.count))
try await delete(confirm: true)
#expect(defaults.string(forKey: "selectedNote") == first.uuidString)
#expect(editor.markdownString == "Unsaved changes")
#expect(!controller.save())
try FileManager.default.removeItem(at: directory)
try FileManager.default.moveItem(at: backup, to: directory)
#expect(try NotesStore(directory: directory).notes.first { $0.id == first }?.contents.string == "Delete this note")
try await delete(confirm: true)
#expect(try NotesStore(directory: directory).notes.map(\.id) == [second])
#expect(defaults.string(forKey: "selectedNote") == second.uuidString)
#expect(editor.markdownString == "Keep this note")
#expect(editor.undoManager?.canUndo != true)
try await delete(confirm: true)
let replacement = try #require(NotesStore(directory: directory).notes.first)
#expect(replacement.id != first && replacement.id != second)
#expect(replacement.contents.string.isEmpty)
#expect(defaults.string(forKey: "selectedNote") == replacement.id.uuidString)
#expect(editor.markdownString.isEmpty)
#expect(editor.isEditable)
#expect(editor.undoManager?.canUndo != true)
let reopened = NotesController(defaults: defaults, directory: directory)
#expect(descendants(reopened.view).compactMap { $0 as? NotesTextView }.first?.markdownString == "")
#expect(defaults.string(forKey: "selectedNote") == replacement.id.uuidString)
}
@@ -101,3 +101,33 @@ func noteTitlesUseFirstNonemptyLineAndCapAtSixtyCharacters() throws {
try store.update(id: id, contents: NSAttributedString(string: " \t\n\n"))
#expect(store.notes.first?.title == "Untitled Note")
}
@Test @MainActor
func deletingNotesPersistsWithoutChangingOtherNotesAndPreservesMemoryOnFailure() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let backup = directory.appendingPathExtension("backup")
defer {
try? FileManager.default.removeItem(at: directory)
try? FileManager.default.removeItem(at: backup)
}
let store = try NotesStore(directory: directory)
let deleted = try store.create()
let kept = try store.create()
try store.update(id: kept, contents: NSAttributedString(string: "Keep this note"))
try store.delete(id: deleted)
#expect(store.notes.map(\.id) == [kept])
#expect(try NotesStore(directory: directory).notes.map(\.id) == [kept])
#expect(throws: (any Error).self) { try store.delete(id: deleted) }
try FileManager.default.moveItem(at: directory, to: backup)
try Data("Blocked storage".utf8).write(to: directory)
#expect(throws: (any Error).self) { try store.delete(id: kept) }
#expect(store.notes.map(\.id) == [kept])
#expect(store.notes.first?.contents.string == "Keep this note")
try FileManager.default.removeItem(at: directory)
try FileManager.default.moveItem(at: backup, to: directory)
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "Keep this note")
try store.delete(id: kept)
#expect(store.notes.isEmpty)
#expect(try NotesStore(directory: directory).notes.isEmpty)
}