diff --git a/Package.swift b/Package.swift index 3e1ee80..bb9e0c3 100644 --- a/Package.swift +++ b/Package.swift @@ -44,7 +44,7 @@ let package = Package( .target(name: "SaplingCore"), .target( name: "SaplingWorkspace", - dependencies: ["SaplingCore", "SaplingGit", "SaplingStorage"] + dependencies: ["SaplingCore"] ), .target( name: "SaplingGit", @@ -74,6 +74,10 @@ let package = Package( .testTarget( name: "SaplingEditorTests", dependencies: ["SaplingCore", "SaplingEditor"] + ), + .testTarget( + name: "SaplingWorkspaceTests", + dependencies: ["SaplingCore", "SaplingWorkspace"] ) ] ) diff --git a/Sources/SaplingApp/AppDependencies.swift b/Sources/SaplingApp/AppDependencies.swift index 1817058..f173832 100644 --- a/Sources/SaplingApp/AppDependencies.swift +++ b/Sources/SaplingApp/AppDependencies.swift @@ -7,25 +7,19 @@ import SaplingWorkspace struct AppDependencies { let gitProvider: any GitProvider let configurationStore: any ConfigurationStore - let metadataStore: any WorkspaceMetadataStore let workspaceManager: any WorkspaceManaging let logger: SaplingLogger static func live() -> AppDependencies { let gitProvider = MockGitProvider() - let metadataStore = InMemoryWorkspaceMetadataStore() let configurationStore = JSONConfigurationStore( fileURL: supportDirectory().appendingPathComponent("Configuration.json") ) - let workspaceManager = LocalWorkspaceManager( - gitProvider: gitProvider, - metadataStore: metadataStore - ) + let workspaceManager = LocalWorkspaceManager() return AppDependencies( gitProvider: gitProvider, configurationStore: configurationStore, - metadataStore: metadataStore, workspaceManager: workspaceManager, logger: SaplingLogger() ) @@ -33,17 +27,12 @@ struct AppDependencies { static func preview() -> AppDependencies { let gitProvider = MockGitProvider() - let metadataStore = InMemoryWorkspaceMetadataStore() let configurationStore = InMemoryConfigurationStore() - let workspaceManager = LocalWorkspaceManager( - gitProvider: gitProvider, - metadataStore: metadataStore - ) + let workspaceManager = LocalWorkspaceManager() return AppDependencies( gitProvider: gitProvider, configurationStore: configurationStore, - metadataStore: metadataStore, workspaceManager: workspaceManager, logger: SaplingLogger(subsystem: "app.sapling.Sapling.preview") ) diff --git a/Sources/SaplingCore/Models.swift b/Sources/SaplingCore/Models.swift index 2b0e2e5..537ac10 100644 --- a/Sources/SaplingCore/Models.swift +++ b/Sources/SaplingCore/Models.swift @@ -42,6 +42,17 @@ public indirect enum WorkspaceItem: Identifiable, Hashable, Codable, Sendable { case .subproject(let subproject): subproject.name } } + + public var children: [WorkspaceItem]? { + switch self { + case .folder(let folder): + return folder.children + case .project(let project): + return project.children + case .file, .subproject: + return nil + } + } } public struct WorkspaceFolder: Identifiable, Hashable, Codable, Sendable { @@ -92,6 +103,7 @@ public struct Project: Identifiable, Hashable, Codable, Sendable { public var id: UUID public var name: String public var repositoryURL: URL + public var children: [WorkspaceItem] public var gitRepository: GitRepository public var remotes: [GitRemote] public var branches: [GitBranch] @@ -102,6 +114,7 @@ public struct Project: Identifiable, Hashable, Codable, Sendable { id: UUID = UUID(), name: String, repositoryURL: URL, + children: [WorkspaceItem] = [], gitRepository: GitRepository, remotes: [GitRemote] = [], branches: [GitBranch] = [], @@ -111,6 +124,7 @@ public struct Project: Identifiable, Hashable, Codable, Sendable { self.id = id self.name = name self.repositoryURL = repositoryURL + self.children = children self.gitRepository = gitRepository self.remotes = remotes self.branches = branches diff --git a/Sources/SaplingStorage/Configuration.swift b/Sources/SaplingStorage/Configuration.swift index 8405b5e..c8bc31b 100644 --- a/Sources/SaplingStorage/Configuration.swift +++ b/Sources/SaplingStorage/Configuration.swift @@ -1,6 +1,7 @@ import Foundation public struct SaplingConfiguration: Hashable, Codable, Sendable { + public var lastOpenedWorkspaceURL: URL? public var recentWorkspaceURLs: [URL] public var defaultBranchName: String public var autosavesDrafts: Bool @@ -8,12 +9,14 @@ public struct SaplingConfiguration: Hashable, Codable, Sendable { public var preferredEditorFontSize: Double public init( + lastOpenedWorkspaceURL: URL? = nil, recentWorkspaceURLs: [URL] = [], defaultBranchName: String = "main", autosavesDrafts: Bool = true, showsHiddenFiles: Bool = false, preferredEditorFontSize: Double = 15 ) { + self.lastOpenedWorkspaceURL = lastOpenedWorkspaceURL self.recentWorkspaceURLs = recentWorkspaceURLs self.defaultBranchName = defaultBranchName self.autosavesDrafts = autosavesDrafts diff --git a/Sources/SaplingWorkspace/WorkspaceManager.swift b/Sources/SaplingWorkspace/WorkspaceManager.swift index 4258ae5..df3f234 100644 --- a/Sources/SaplingWorkspace/WorkspaceManager.swift +++ b/Sources/SaplingWorkspace/WorkspaceManager.swift @@ -1,7 +1,5 @@ import Foundation import SaplingCore -import SaplingGit -import SaplingStorage public protocol WorkspaceManaging: Sendable { func openWorkspace(at url: URL) async throws -> Workspace @@ -9,17 +7,9 @@ public protocol WorkspaceManaging: Sendable { } public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable { - private let gitProvider: any GitProvider - private let metadataStore: any WorkspaceMetadataStore private let fileManager: FileManager - public init( - gitProvider: any GitProvider, - metadataStore: any WorkspaceMetadataStore, - fileManager: FileManager = .default - ) { - self.gitProvider = gitProvider - self.metadataStore = metadataStore + public init(fileManager: FileManager = .default) { self.fileManager = fileManager } @@ -27,7 +17,6 @@ public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable let items = try scanItems(at: url, relativeTo: url) let workspace = Workspace(name: url.lastPathComponent, rootURL: url, items: items) try SaplingRules.validateWorkspace(workspace) - try metadataStore.saveMetadata(WorkspaceMetadata(workspaceID: workspace.id)) return workspace } @@ -39,7 +28,7 @@ public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable guard let children = try? fileManager.contentsOfDirectory( at: url, includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles] + options: directoryOptions ) else { return [] } @@ -47,12 +36,12 @@ public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable return try children .sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } .map { childURL in - if isGitRepository(at: childURL) { - return .project(project(at: childURL)) - } - let values = try childURL.resourceValues(forKeys: [.isDirectoryKey]) if values.isDirectory == true { + if isGitRepository(at: childURL) { + return .project(try project(at: childURL, relativeTo: rootURL)) + } + return .folder( WorkspaceFolder( name: childURL.lastPathComponent, @@ -72,11 +61,15 @@ public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable } } + private var directoryOptions: FileManager.DirectoryEnumerationOptions { + [.skipsHiddenFiles, .skipsPackageDescendants] + } + private func isGitRepository(at url: URL) -> Bool { fileManager.fileExists(atPath: url.appendingPathComponent(".git").path) } - private func project(at url: URL) -> Project { + private func project(at url: URL, relativeTo rootURL: URL) throws -> Project { let repository = GitRepository( name: url.lastPathComponent, rootURL: url, @@ -85,6 +78,7 @@ public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable return Project( name: url.lastPathComponent, repositoryURL: url, + children: try scanItems(at: url, relativeTo: rootURL), gitRepository: repository ) } diff --git a/Tests/SaplingWorkspaceTests/WorkspaceManagerTests.swift b/Tests/SaplingWorkspaceTests/WorkspaceManagerTests.swift new file mode 100644 index 0000000..fff105e --- /dev/null +++ b/Tests/SaplingWorkspaceTests/WorkspaceManagerTests.swift @@ -0,0 +1,140 @@ +import Foundation +import SaplingCore +import SaplingWorkspace +import XCTest + +final class WorkspaceManagerTests: XCTestCase { + private var temporaryRoots: [URL] = [] + + override func tearDownWithError() throws { + for url in temporaryRoots { + try? FileManager.default.removeItem(at: url) + } + temporaryRoots = [] + } + + func testOpenWorkspaceBuildsFilesystemTree() async throws { + let rootURL = try makeTemporaryWorkspace() + try createDirectory("Notes", in: rootURL) + try writeFile("Notes/Index.md", in: rootURL, contents: "# Notes") + try writeFile("Notes/todo.txt", in: rootURL, contents: "todo") + try createDirectory("Research/Archive", in: rootURL) + try writeFile("Research/Archive/Paper.markdown", in: rootURL, contents: "# Paper") + + let workspace = try await LocalWorkspaceManager().openWorkspace(at: rootURL) + + XCTAssertEqual(workspace.rootURL, rootURL) + XCTAssertEqual(workspace.items.map(\.displayName), ["Notes", "Research"]) + + let notes = try XCTUnwrap(workspace.folder(named: "Notes")) + XCTAssertEqual(notes.children.map(\.displayName), ["Index.md", "todo.txt"]) + XCTAssertEqual(try XCTUnwrap(notes.file(named: "Index.md")).kind, .markdown) + XCTAssertEqual(try XCTUnwrap(notes.file(named: "todo.txt")).kind, .other) + + let archive = try XCTUnwrap(workspace.folder(named: "Research")?.folder(named: "Archive")) + XCTAssertEqual(try XCTUnwrap(archive.file(named: "Paper.markdown")).kind, .markdown) + } + + func testGitRepositoriesAreDetectedAsProjectsWithChildren() async throws { + let rootURL = try makeTemporaryWorkspace() + try createGitRepository("Sapling", in: rootURL) + try writeFile("Sapling/README.md", in: rootURL, contents: "# Sapling") + try createGitRepository("Research", in: rootURL) + try writeFile("Research/Notes.md", in: rootURL, contents: "# Research") + try createDirectory("Ordinary", in: rootURL) + + let workspace = try await LocalWorkspaceManager().openWorkspace(at: rootURL) + + XCTAssertEqual(workspace.items.map(\.displayName), ["Ordinary", "Research", "Sapling"]) + let research = try XCTUnwrap(workspace.project(named: "Research")) + let sapling = try XCTUnwrap(workspace.project(named: "Sapling")) + XCTAssertEqual(research.gitRepository.statusSummary, .unknown) + XCTAssertEqual(sapling.gitRepository.rootURL.lastPathComponent, "Sapling") + XCTAssertEqual(research.children.map(\.displayName), ["Notes.md"]) + XCTAssertNil(research.folder(named: ".git")) + } + + func testHiddenFilesAndGitInternalsAreExcluded() async throws { + let rootURL = try makeTemporaryWorkspace() + try writeFile(".hidden.md", in: rootURL, contents: "# Hidden") + try createGitRepository("Project", in: rootURL) + try writeFile("Project/.git/config", in: rootURL, contents: "[core]") + try writeFile("Project/Visible.md", in: rootURL, contents: "# Visible") + + let workspace = try await LocalWorkspaceManager().openWorkspace(at: rootURL) + + XCTAssertEqual(workspace.items.map(\.displayName), ["Project"]) + let project = try XCTUnwrap(workspace.project(named: "Project")) + XCTAssertEqual(project.children.map(\.displayName), ["Visible.md"]) + } + + private func makeTemporaryWorkspace() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("SaplingWorkspaceTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + temporaryRoots.append(url) + return url + } + + private func createDirectory(_ path: String, in rootURL: URL) throws { + try FileManager.default.createDirectory( + at: rootURL.appendingPathComponent(path, isDirectory: true), + withIntermediateDirectories: true + ) + } + + private func createGitRepository(_ path: String, in rootURL: URL) throws { + try createDirectory(path, in: rootURL) + try createDirectory("\(path)/.git", in: rootURL) + } + + private func writeFile(_ path: String, in rootURL: URL, contents: String) throws { + let url = rootURL.appendingPathComponent(path) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try contents.write(to: url, atomically: true, encoding: .utf8) + } +} + +private extension Workspace { + func folder(named name: String) -> WorkspaceFolder? { + items.compactMap { item -> WorkspaceFolder? in + guard case .folder(let folder) = item, folder.name == name else { return nil } + return folder + }.first + } + + func project(named name: String) -> Project? { + items.compactMap { item -> Project? in + guard case .project(let project) = item, project.name == name else { return nil } + return project + }.first + } +} + +private extension WorkspaceFolder { + func folder(named name: String) -> WorkspaceFolder? { + children.compactMap { item -> WorkspaceFolder? in + guard case .folder(let folder) = item, folder.name == name else { return nil } + return folder + }.first + } + + func file(named name: String) -> WorkspaceFile? { + children.compactMap { item -> WorkspaceFile? in + guard case .file(let file) = item, file.name == name else { return nil } + return file + }.first + } +} + +private extension Project { + func folder(named name: String) -> WorkspaceFolder? { + children.compactMap { item -> WorkspaceFolder? in + guard case .folder(let folder) = item, folder.name == name else { return nil } + return folder + }.first + } +}