feat(workspace): implement filesystem scanner

This commit is contained in:
Feror 2026-06-02 14:38:27 +02:00
parent d05b211ded
commit 1ca75c60bd
6 changed files with 176 additions and 32 deletions

View file

@ -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"]
)
]
)

View file

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

View file

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

View file

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

View file

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

View 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
}
}