feat(workspace): implement filesystem scanner
This commit is contained in:
parent
d05b211ded
commit
1ca75c60bd
6 changed files with 176 additions and 32 deletions
|
|
@ -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"]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
140
Tests/SaplingWorkspaceTests/WorkspaceManagerTests.swift
Normal file
140
Tests/SaplingWorkspaceTests/WorkspaceManagerTests.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue