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
@@ -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)
}