diff --git a/Sources/SaplingEditor/DocumentSessionStore.swift b/Sources/SaplingEditor/DocumentSessionStore.swift new file mode 100644 index 0000000..ed6d343 --- /dev/null +++ b/Sources/SaplingEditor/DocumentSessionStore.swift @@ -0,0 +1,72 @@ +import Foundation +import SaplingCore + +@MainActor +public final class MarkdownDocumentSession: Identifiable, ObservableObject { + public let id: UUID + public let documentURL: URL + public let viewModel: HybridMarkdownEditorViewModel + + public init( + id: UUID = UUID(), + documentURL: URL, + viewModel: HybridMarkdownEditorViewModel + ) { + self.id = id + self.documentURL = documentURL + self.viewModel = viewModel + } +} + +@MainActor +public final class DocumentSessionStore: ObservableObject { + @Published public private(set) var sessions: [MarkdownDocumentSession] + @Published public private(set) var activeSession: MarkdownDocumentSession? + + public init(sessions: [MarkdownDocumentSession] = []) { + self.sessions = sessions + self.activeSession = sessions.first + } + + @discardableResult + public func openDocument( + at url: URL, + loadDocument: @MainActor (URL) throws -> MarkdownDocument = HybridMarkdownEditorViewModel.loadDocument(at:) + ) throws -> MarkdownDocumentSession { + let key = sessionKey(for: url) + if let existingSession = sessions.first(where: { sessionKey(for: $0.documentURL) == key }) { + activeSession = existingSession + return existingSession + } + + let document = try loadDocument(url) + let session = MarkdownDocumentSession( + documentURL: url, + viewModel: HybridMarkdownEditorViewModel(document: document) + ) + sessions.append(session) + activeSession = session + return session + } + + @discardableResult + public func activateDocument(at url: URL) -> Bool { + let key = sessionKey(for: url) + guard let session = sessions.first(where: { sessionKey(for: $0.documentURL) == key }) else { + return false + } + + activeSession = session + return true + } + + public func updateActiveSessionFromViewModel() { + guard let activeSession else { return } + objectWillChange.send() + activeSession.objectWillChange.send() + } + + private func sessionKey(for url: URL) -> String { + url.standardizedFileURL.path + } +} diff --git a/Tests/SaplingEditorTests/DocumentSessionStoreTests.swift b/Tests/SaplingEditorTests/DocumentSessionStoreTests.swift new file mode 100644 index 0000000..077280d --- /dev/null +++ b/Tests/SaplingEditorTests/DocumentSessionStoreTests.swift @@ -0,0 +1,55 @@ +import Foundation +import SaplingCore +import SaplingEditor +import XCTest + +@MainActor +final class DocumentSessionStoreTests: XCTestCase { + func testOpeningSameFileReusesExistingSession() throws { + let url = URL(fileURLWithPath: "/tmp/Sapling/Notes.md") + var loadCount = 0 + let store = DocumentSessionStore() + + let first = try store.openDocument(at: url) { url in + loadCount += 1 + return MarkdownDocument(url: url, title: "Notes", content: "# Notes") + } + + let second = try store.openDocument(at: url.standardizedFileURL) { url in + loadCount += 1 + return MarkdownDocument(url: url, title: "Notes", content: "# Reloaded") + } + + XCTAssertIdentical(first, second) + XCTAssertIdentical(store.activeSession, first) + XCTAssertEqual(store.sessions.count, 1) + XCTAssertEqual(loadCount, 1) + XCTAssertEqual(first.viewModel.document.content, "# Notes") + } + + func testOpeningDifferentFilesCreatesSeparateSessionsAndActivatesLatest() throws { + let firstURL = URL(fileURLWithPath: "/tmp/Sapling/One.md") + let secondURL = URL(fileURLWithPath: "/tmp/Sapling/Two.md") + let store = DocumentSessionStore() + + let first = try store.openDocument(at: firstURL) { url in + MarkdownDocument(url: url, title: "One", content: "# One") + } + let second = try store.openDocument(at: secondURL) { url in + MarkdownDocument(url: url, title: "Two", content: "# Two") + } + + XCTAssertEqual(store.sessions.count, 2) + XCTAssertIdentical(store.activeSession, second) + + XCTAssertTrue(store.activateDocument(at: firstURL)) + XCTAssertIdentical(store.activeSession, first) + } + + func testActivateMissingDocumentReturnsFalse() { + let store = DocumentSessionStore() + + XCTAssertFalse(store.activateDocument(at: URL(fileURLWithPath: "/tmp/missing.md"))) + XCTAssertNil(store.activeSession) + } +}