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