From ed6230780bd0d3629878e42624137784bedd0b44 Mon Sep 17 00:00:00 2001 From: abue-ammar Date: Sun, 27 Sep 2026 01:47:50 +0600 Subject: [PATCH] Keep a Notes or Snippets folder change from reusing the old folder's contents --- Tests/notes-test.swift | 36 +++++++++++++++++++ .../Features/Notes/Service/NotesStore.swift | 19 ++++++++-- .../Snippets/Model/SnippetsStore.swift | 6 ++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/Tests/notes-test.swift b/Tests/notes-test.swift index 4f1a8d1d..90b5266e 100644 --- a/Tests/notes-test.swift +++ b/Tests/notes-test.swift @@ -17,6 +17,7 @@ struct NotesTests { try await testStoreCollectionAndAutosave() try await testCollectionMutationsFlushTheDraft() try await testStoreRecoversFromFailures() + try await testStoreRelocates() print(failures == 0 ? "Notes tests passed" : "\(failures) tests failed") exit(failures == 0 ? 0 : 1) @@ -414,6 +415,41 @@ struct NotesTests { store.stop() } + private static func testStoreRelocates() async throws { + let root = temporaryRoot("relocation") + defer { try? FileManager.default.removeItem(at: root) } + let first = try repository(in: root, support: root.appendingPathComponent("first")) + let second = try repository(in: root, support: root.appendingPathComponent("second")) + let fm = FileManager.default + try fm.createDirectory(at: first.notesDirectory, withIntermediateDirectories: true) + try fm.createDirectory(at: second.notesDirectory, withIntermediateDirectories: true) + let firstURL = first.fileURL(for: NoteID(rawValue: "Plan.md")) + try Data("plan".utf8).write(to: firstURL, options: .atomic) + let unreadable = second.fileURL(for: NoteID(rawValue: "Broken.md")) + try Data([0xFF]).write(to: unreadable, options: .atomic) + + let store = NotesStore(repository: first) + _ = await store.start() + store.updateSource("unsaved plan") + try fm.setAttributes([.posixPermissions: 0o555], ofItemAtPath: first.notesDirectory.path) + await store.relocate(to: second) + try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: first.notesDirectory.path) + check( + "a draft the old folder can't take keeps the store there", + store.notesDirectory == first.notesDirectory && store.source == "unsaved plan") + + _ = await store.retrySave() + for _ in 0..<100 where store.notesDirectory != second.notesDirectory { + try await Task.sleep(for: .milliseconds(10)) + } + check( + "the draft is saved where it was, then the move goes ahead", + try String(contentsOf: firstURL, encoding: .utf8) == "unsaved plan" + && store.notesDirectory == second.notesDirectory) + check("a folder that fails to load leaves no old note open", store.activeID == nil) + store.stop() + } + private static func testMarkdownParser() { let tiled = ["a\nb\n", "a\r\nb", "\n\n", "x", "a\u{2029}b\rc"] check("an empty source is one empty line", NoteMarkdownParser.parse("").lines.map(\.kind) == [.blank]) diff --git a/Tinycast/Features/Notes/Service/NotesStore.swift b/Tinycast/Features/Notes/Service/NotesStore.swift index ba6237fe..b95a7d31 100644 --- a/Tinycast/Features/Notes/Service/NotesStore.swift +++ b/Tinycast/Features/Notes/Service/NotesStore.swift @@ -39,6 +39,8 @@ final class NotesStore { @ObservationIgnored private var searchTask: Task? @ObservationIgnored private var searchWorker: Task<[NoteSearchResult], Never>? private var saveFailed = false + /// A folder change that waits on a draft the old folder could not take yet. + @ObservationIgnored private var pendingRelocation: NotesRepository? private var searchGeneration = 0 init( @@ -63,19 +65,25 @@ final class NotesStore { guard isLoaded else { return await reload(preferredID: loadSelection()) } let repository = repository let result = await detached({ try repository.list() }, recover: { repository.notesDirectory }) + guard repository.notesDirectory == notesDirectory else { return true } if case .success(let summaries) = result { self.summaries = summaries } return true } - /// Moves to another folder: the open draft is saved where it was, then the new one lists. + /// Moves to another folder once the open draft is saved where it was, then the new one lists. func relocate(to repository: NotesRepository) async { + pendingRelocation = nil guard repository.notesDirectory != notesDirectory else { return } - await flush() + guard await flush() else { + pendingRelocation = repository + return + } cancelSearch() self.repository = repository notesDirectory = repository.notesDirectory - saveFailed = false guard isLoaded else { return } + // Cleared first, so a folder that fails to load leaves no old note to save into it. + apply(nil, summaries: []) _ = await reload(preferredID: nil) } @@ -305,6 +313,8 @@ final class NotesStore { } recover: { repository.notesDirectory } + // A relocation while this ran owns the editor now, and loads it itself. + guard repository.notesDirectory == notesDirectory else { return true } switch result { case .success(let payload): apply(payload.1, summaries: payload.0) @@ -338,6 +348,9 @@ final class NotesStore { case .success(let summaries): self.summaries = summaries isDirty = savedSource != source + if !isDirty, let pending = pendingRelocation { + Task { [weak self] in await self?.relocate(to: pending) } + } case .failure(let failure): saveFailed = true publish(.save(failure)) diff --git a/Tinycast/Features/Snippets/Model/SnippetsStore.swift b/Tinycast/Features/Snippets/Model/SnippetsStore.swift index 1f2300ee..f4eb0f40 100644 --- a/Tinycast/Features/Snippets/Model/SnippetsStore.swift +++ b/Tinycast/Features/Snippets/Model/SnippetsStore.swift @@ -63,6 +63,12 @@ final class SnippetsStore { stop() self.repository = repository snippetsDirectory = repository.snippetsDirectory + // Emptied first, so a folder that fails to load leaves no old snippet expanding. + if !snippets.isEmpty || !issues.isEmpty { + snippets = [] + issues = [] + onSnapshot?(SnippetRepository.Snapshot(records: [], issues: [])) + } guard wasStarted else { return } await start() }