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: "SaplingCore"),
.target( .target(
name: "SaplingWorkspace", name: "SaplingWorkspace",
dependencies: ["SaplingCore", "SaplingGit", "SaplingStorage"] dependencies: ["SaplingCore"]
), ),
.target( .target(
name: "SaplingGit", name: "SaplingGit",
@ -74,6 +74,10 @@ let package = Package(
.testTarget( .testTarget(
name: "SaplingEditorTests", name: "SaplingEditorTests",
dependencies: ["SaplingCore", "SaplingEditor"] dependencies: ["SaplingCore", "SaplingEditor"]
),
.testTarget(
name: "SaplingWorkspaceTests",
dependencies: ["SaplingCore", "SaplingWorkspace"]
) )
] ]
) )

View file

@ -7,25 +7,19 @@ import SaplingWorkspace
struct AppDependencies { struct AppDependencies {
let gitProvider: any GitProvider let gitProvider: any GitProvider
let configurationStore: any ConfigurationStore let configurationStore: any ConfigurationStore
let metadataStore: any WorkspaceMetadataStore
let workspaceManager: any WorkspaceManaging let workspaceManager: any WorkspaceManaging
let logger: SaplingLogger let logger: SaplingLogger
static func live() -> AppDependencies { static func live() -> AppDependencies {
let gitProvider = MockGitProvider() let gitProvider = MockGitProvider()
let metadataStore = InMemoryWorkspaceMetadataStore()
let configurationStore = JSONConfigurationStore( let configurationStore = JSONConfigurationStore(
fileURL: supportDirectory().appendingPathComponent("Configuration.json") fileURL: supportDirectory().appendingPathComponent("Configuration.json")
) )
let workspaceManager = LocalWorkspaceManager( let workspaceManager = LocalWorkspaceManager()
gitProvider: gitProvider,
metadataStore: metadataStore
)
return AppDependencies( return AppDependencies(
gitProvider: gitProvider, gitProvider: gitProvider,
configurationStore: configurationStore, configurationStore: configurationStore,
metadataStore: metadataStore,
workspaceManager: workspaceManager, workspaceManager: workspaceManager,
logger: SaplingLogger() logger: SaplingLogger()
) )
@ -33,17 +27,12 @@ struct AppDependencies {
static func preview() -> AppDependencies { static func preview() -> AppDependencies {
let gitProvider = MockGitProvider() let gitProvider = MockGitProvider()
let metadataStore = InMemoryWorkspaceMetadataStore()
let configurationStore = InMemoryConfigurationStore() let configurationStore = InMemoryConfigurationStore()
let workspaceManager = LocalWorkspaceManager( let workspaceManager = LocalWorkspaceManager()
gitProvider: gitProvider,
metadataStore: metadataStore
)
return AppDependencies( return AppDependencies(
gitProvider: gitProvider, gitProvider: gitProvider,
configurationStore: configurationStore, configurationStore: configurationStore,
metadataStore: metadataStore,
workspaceManager: workspaceManager, workspaceManager: workspaceManager,
logger: SaplingLogger(subsystem: "app.sapling.Sapling.preview") 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 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 { public struct WorkspaceFolder: Identifiable, Hashable, Codable, Sendable {
@ -92,6 +103,7 @@ public struct Project: Identifiable, Hashable, Codable, Sendable {
public var id: UUID public var id: UUID
public var name: String public var name: String
public var repositoryURL: URL public var repositoryURL: URL
public var children: [WorkspaceItem]
public var gitRepository: GitRepository public var gitRepository: GitRepository
public var remotes: [GitRemote] public var remotes: [GitRemote]
public var branches: [GitBranch] public var branches: [GitBranch]
@ -102,6 +114,7 @@ public struct Project: Identifiable, Hashable, Codable, Sendable {
id: UUID = UUID(), id: UUID = UUID(),
name: String, name: String,
repositoryURL: URL, repositoryURL: URL,
children: [WorkspaceItem] = [],
gitRepository: GitRepository, gitRepository: GitRepository,
remotes: [GitRemote] = [], remotes: [GitRemote] = [],
branches: [GitBranch] = [], branches: [GitBranch] = [],
@ -111,6 +124,7 @@ public struct Project: Identifiable, Hashable, Codable, Sendable {
self.id = id self.id = id
self.name = name self.name = name
self.repositoryURL = repositoryURL self.repositoryURL = repositoryURL
self.children = children
self.gitRepository = gitRepository self.gitRepository = gitRepository
self.remotes = remotes self.remotes = remotes
self.branches = branches self.branches = branches

View file

@ -1,6 +1,7 @@
import Foundation import Foundation
public struct SaplingConfiguration: Hashable, Codable, Sendable { public struct SaplingConfiguration: Hashable, Codable, Sendable {
public var lastOpenedWorkspaceURL: URL?
public var recentWorkspaceURLs: [URL] public var recentWorkspaceURLs: [URL]
public var defaultBranchName: String public var defaultBranchName: String
public var autosavesDrafts: Bool public var autosavesDrafts: Bool
@ -8,12 +9,14 @@ public struct SaplingConfiguration: Hashable, Codable, Sendable {
public var preferredEditorFontSize: Double public var preferredEditorFontSize: Double
public init( public init(
lastOpenedWorkspaceURL: URL? = nil,
recentWorkspaceURLs: [URL] = [], recentWorkspaceURLs: [URL] = [],
defaultBranchName: String = "main", defaultBranchName: String = "main",
autosavesDrafts: Bool = true, autosavesDrafts: Bool = true,
showsHiddenFiles: Bool = false, showsHiddenFiles: Bool = false,
preferredEditorFontSize: Double = 15 preferredEditorFontSize: Double = 15
) { ) {
self.lastOpenedWorkspaceURL = lastOpenedWorkspaceURL
self.recentWorkspaceURLs = recentWorkspaceURLs self.recentWorkspaceURLs = recentWorkspaceURLs
self.defaultBranchName = defaultBranchName self.defaultBranchName = defaultBranchName
self.autosavesDrafts = autosavesDrafts self.autosavesDrafts = autosavesDrafts

View file

@ -1,7 +1,5 @@
import Foundation import Foundation
import SaplingCore import SaplingCore
import SaplingGit
import SaplingStorage
public protocol WorkspaceManaging: Sendable { public protocol WorkspaceManaging: Sendable {
func openWorkspace(at url: URL) async throws -> Workspace func openWorkspace(at url: URL) async throws -> Workspace
@ -9,17 +7,9 @@ public protocol WorkspaceManaging: Sendable {
} }
public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable { public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable {
private let gitProvider: any GitProvider
private let metadataStore: any WorkspaceMetadataStore
private let fileManager: FileManager private let fileManager: FileManager
public init( public init(fileManager: FileManager = .default) {
gitProvider: any GitProvider,
metadataStore: any WorkspaceMetadataStore,
fileManager: FileManager = .default
) {
self.gitProvider = gitProvider
self.metadataStore = metadataStore
self.fileManager = fileManager self.fileManager = fileManager
} }
@ -27,7 +17,6 @@ public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable
let items = try scanItems(at: url, relativeTo: url) let items = try scanItems(at: url, relativeTo: url)
let workspace = Workspace(name: url.lastPathComponent, rootURL: url, items: items) let workspace = Workspace(name: url.lastPathComponent, rootURL: url, items: items)
try SaplingRules.validateWorkspace(workspace) try SaplingRules.validateWorkspace(workspace)
try metadataStore.saveMetadata(WorkspaceMetadata(workspaceID: workspace.id))
return workspace return workspace
} }
@ -39,7 +28,7 @@ public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable
guard let children = try? fileManager.contentsOfDirectory( guard let children = try? fileManager.contentsOfDirectory(
at: url, at: url,
includingPropertiesForKeys: [.isDirectoryKey], includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles] options: directoryOptions
) else { ) else {
return [] return []
} }
@ -47,12 +36,12 @@ public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable
return try children return try children
.sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } .sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending }
.map { childURL in .map { childURL in
if isGitRepository(at: childURL) {
return .project(project(at: childURL))
}
let values = try childURL.resourceValues(forKeys: [.isDirectoryKey]) let values = try childURL.resourceValues(forKeys: [.isDirectoryKey])
if values.isDirectory == true { if values.isDirectory == true {
if isGitRepository(at: childURL) {
return .project(try project(at: childURL, relativeTo: rootURL))
}
return .folder( return .folder(
WorkspaceFolder( WorkspaceFolder(
name: childURL.lastPathComponent, 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 { private func isGitRepository(at url: URL) -> Bool {
fileManager.fileExists(atPath: url.appendingPathComponent(".git").path) 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( let repository = GitRepository(
name: url.lastPathComponent, name: url.lastPathComponent,
rootURL: url, rootURL: url,
@ -85,6 +78,7 @@ public final class LocalWorkspaceManager: WorkspaceManaging, @unchecked Sendable
return Project( return Project(
name: url.lastPathComponent, name: url.lastPathComponent,
repositoryURL: url, repositoryURL: url,
children: try scanItems(at: url, relativeTo: rootURL),
gitRepository: repository 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
}
}