Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 54 additions & 46 deletions CodeEdit.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

483 changes: 483 additions & 0 deletions CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift

Large diffs are not rendered by default.

257 changes: 257 additions & 0 deletions CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
//
// FileSystemClient.swift
// CodeEdit
//
// Created by Matthijs Eikelenboom on 04/02/2023.
//

import Combine
import Foundation

/// This class is used to load the files of the machine into a CodeEdit workspace.
final class CEWorkspaceFileManager {
enum FileSystemClientError: Error {
case fileNotExist
}

private var subject = CurrentValueSubject<[CEWorkspaceFile], Never>([])
private var isRunning = false
private var anotherInstanceRan = 0

private(set) var fileManager = FileManager.default
private(set) var ignoredFilesAndFolders: [String]
private(set) var flattenedFileItems: [String: CEWorkspaceFile]

var onRefresh: () -> Void = {}
var getFiles: AnyPublisher<[CEWorkspaceFile], Never> =
CurrentValueSubject<[CEWorkspaceFile], Never>([]).eraseToAnyPublisher()

let folderUrl: URL
let workspaceItem: CEWorkspaceFile

init(folderUrl: URL, ignoredFilesAndFolders: [String]) {
self.folderUrl = folderUrl
self.ignoredFilesAndFolders = ignoredFilesAndFolders

self.workspaceItem = CEWorkspaceFile(url: folderUrl, children: [])
self.flattenedFileItems = [workspaceItem.id: workspaceItem]

self.setup()
}

private func setup() {
// initial load
var workspaceFiles: [CEWorkspaceFile]
do {
workspaceFiles = try loadFiles(fromUrl: self.folderUrl)
} catch {
fatalError("Failed to loadFiles")
}

// workspace fileItem
let workspaceFile = CEWorkspaceFile(url: self.folderUrl, children: workspaceFiles)
flattenedFileItems[workspaceFile.id] = workspaceFile
workspaceFiles.forEach { item in
item.parent = workspaceFile
}

// By using `CurrentValueSubject` we can define a starting value.
// The value passed during init it's going to be send as soon as the
// consumer subscribes to the publisher.
let subject = CurrentValueSubject<[CEWorkspaceFile], Never>(workspaceFiles)

self.getFiles = subject
.handleEvents(receiveCancel: {
for item in self.flattenedFileItems.values {
item.watcher?.cancel()
item.watcher = nil
}
})
.receive(on: RunLoop.main)
.eraseToAnyPublisher()

workspaceFile.watcherCode = { sourceFileItem in
self.reloadFromWatcher(sourceFileItem: sourceFileItem)
}
reloadFromWatcher(sourceFileItem: workspaceFile)
}

/// Recursive loading of files into `FileItem`s
/// - Parameter url: The URL of the directory to load the items of
/// - Returns: `[FileItem]` representing the contents of the directory
private func loadFiles(fromUrl url: URL) throws -> [CEWorkspaceFile] {
let directoryContents = try fileManager.contentsOfDirectory(
at: url.resolvingSymlinksInPath(),
includingPropertiesForKeys: nil
)
var items: [CEWorkspaceFile] = []

for itemURL in directoryContents {
guard !ignoredFilesAndFolders.contains(itemURL.lastPathComponent) else { continue }

var isDir: ObjCBool = false

if fileManager.fileExists(atPath: itemURL.path, isDirectory: &isDir) {
var subItems: [CEWorkspaceFile]?

if isDir.boolValue {
// Recursively fetch subdirectories and files if the path points to a directory
subItems = try loadFiles(fromUrl: itemURL)
}

let newFileItem = CEWorkspaceFile(
url: itemURL,
children: subItems?.sortItems(foldersOnTop: true)
)

// note: watcher code will be applied after the workspaceItem is created
newFileItem.watcherCode = { sourceFileItem in
self.reloadFromWatcher(sourceFileItem: sourceFileItem)
}
subItems?.forEach { $0.parent = newFileItem }
items.append(newFileItem)
flattenedFileItems[newFileItem.id] = newFileItem
}
}

return items
}

/// A function that, given a file's path, returns a `FileItem` if it exists
/// within the scope of the `FileSystemClient`.
/// - Parameter id: The file's full path
/// - Returns: The file item corresponding to the file
func getFileItem(_ id: String) throws -> CEWorkspaceFile {
guard let item = flattenedFileItems[id] else {
throw FileSystemClientError.fileNotExist
}

return item
}

/// Usually run when the owner of the `FileSystemClient` doesn't need it anymore.
/// This de-inits most functions in the `FileSystemClient`, so that in case it isn't de-init'd it does not use up
/// significant amounts of RAM.
func cleanUp() {
stopListeningToDirectory()
workspaceItem.children = []
flattenedFileItems = [workspaceItem.id: workspaceItem]
print("Cleaned up watchers and file items")
}

// run by dispatchsource watchers. Multiple instances may be concurrent,
// so we need to be careful to avoid EXC_BAD_ACCESS errors.
/// This is a function run by `DispatchSource` file watchers. Due to the nature of watchers, multiple
/// instances may be running concurrently, so the function prevents more than one instance of it from
/// running the main code body.
/// - Parameter sourceFileItem: The `FileItem` corresponding to the file that triggered the `DispatchSource`
func reloadFromWatcher(sourceFileItem: CEWorkspaceFile) {
// Something has changed inside the directory
// We should reload the files.
guard !isRunning else { // this runs when a file change is detected but is already running
anotherInstanceRan += 1
return
}
isRunning = true

// inital reload of files
_ = try? rebuildFiles(fromItem: sourceFileItem)

// re-reload if another instance tried to run while this instance was running
// TODO: optimise
while anotherInstanceRan > 0 {
let somethingChanged = try? rebuildFiles(fromItem: workspaceItem)
anotherInstanceRan = !(somethingChanged ?? false) ? 0 : anotherInstanceRan - 1
}

subject.send(workspaceItem.children ?? [])
isRunning = false
anotherInstanceRan = 0

// reload data in outline view controller through the main thread
DispatchQueue.main.async {
self.onRefresh()
}
}

/// A function to kill the watcher of a specific directory, or all directories.
/// - Parameter directory: The directory to stop watching, or nil to stop watching everything.
func stopListeningToDirectory(directory: URL? = nil) {
if directory != nil {
flattenedFileItems[directory!.relativePath]?.watcher?.cancel()
} else {
for item in flattenedFileItems.values {
item.watcher?.cancel()
item.watcher = nil
}
}
}

/// Recursive function similar to `loadFiles`, but creates or deletes children of the
/// `FileItem` so that they are accurate with the file system, instead of creating an
/// entirely new `FileItem`, to prevent the `OutlineView` from going crazy with folding.
/// - Parameter fileItem: The `FileItem` to correct the children of
@discardableResult
func rebuildFiles(fromItem fileItem: CEWorkspaceFile) throws -> Bool {
var didChangeSomething = false

// get the actual directory children
let directoryContentsUrls = try fileManager.contentsOfDirectory(
at: fileItem.url.resolvingSymlinksInPath(),
includingPropertiesForKeys: nil
)

// test for deleted children, and remove them from the index
for oldContent in fileItem.children ?? [] where !directoryContentsUrls.contains(oldContent.url) {
if let removeAt = fileItem.children?.firstIndex(of: oldContent) {
fileItem.children?[removeAt].watcher?.cancel()
fileItem.children?.remove(at: removeAt)
flattenedFileItems.removeValue(forKey: oldContent.id)
didChangeSomething = true
}
}

// test for new children, and index them using loadFiles
for newContent in directoryContentsUrls {
guard !ignoredFilesAndFolders.contains(newContent.lastPathComponent) else { continue }

// if the child has already been indexed, continue to the next item.
guard !(fileItem.children?.map({ $0.url }).contains(newContent) ?? false) else { continue }

var isDir: ObjCBool = false
if fileManager.fileExists(atPath: newContent.path, isDirectory: &isDir) {
var subItems: [CEWorkspaceFile]?

if isDir.boolValue { subItems = try loadFiles(fromUrl: newContent) }

let newFileItem = CEWorkspaceFile(
url: newContent,
children: subItems?.sortItems(foldersOnTop: true)
)

newFileItem.watcherCode = { sourceFileItem in
self.reloadFromWatcher(sourceFileItem: sourceFileItem)
}

subItems?.forEach { $0.parent = newFileItem }

newFileItem.parent = fileItem
flattenedFileItems[newFileItem.id] = newFileItem
fileItem.children?.append(newFileItem)
didChangeSomething = true
}
}

fileItem.children = fileItem.children?.sortItems(foldersOnTop: true)
fileItem.children?.forEach({
if $0.isFolder {
let childChanged = try? rebuildFiles(fromItem: $0)
didChangeSomething = (childChanged ?? false) ? true : didChangeSomething
}
flattenedFileItems[$0.id] = $0
})

return didChangeSomething
}

}
10 changes: 5 additions & 5 deletions CodeEdit/Features/CodeEditUI/Views/ToolbarBranchPicker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import CodeEditSymbols

/// A view that pops up a branch picker.
struct ToolbarBranchPicker: View {
private var workspace: WorkspaceClient?
private var workspaceFileManager: CEWorkspaceFileManager?
private var gitClient: GitClient?

@Environment(\.controlActiveState)
Expand All @@ -30,10 +30,10 @@ struct ToolbarBranchPicker: View {
/// - Parameter workspace: An instance of the current `WorkspaceClient`
init(
shellClient: ShellClient,
workspace: WorkspaceClient?
workspaceFileManager: CEWorkspaceFileManager?
) {
self.workspace = workspace
if let folderURL = workspace?.folderURL() {
self.workspaceFileManager = workspaceFileManager
if let folderURL = workspaceFileManager?.folderUrl {
self.gitClient = GitClient(directoryURL: folderURL, shellClient: shellClient)
}
self._currentBranch = State(initialValue: try? gitClient?.getCurrentBranchName())
Expand Down Expand Up @@ -94,7 +94,7 @@ struct ToolbarBranchPicker: View {
}

private var title: String {
workspace?.folderURL()?.lastPathComponent ?? "Empty"
workspaceFileManager?.folderUrl.lastPathComponent ?? "Empty"
}

// MARK: Popover View
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate {
let view = NSHostingView(
rootView: ToolbarBranchPicker(
shellClient: currentWorld.shellClient,
workspace: workspace?.workspaceClient
workspaceFileManager: workspace?.workspaceFileManager
)
)
toolbarItem.view = view
Expand Down
6 changes: 3 additions & 3 deletions CodeEdit/Features/Documents/Views/WorkspaceCodeFileView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ struct WorkspaceCodeFileView: View {
@EnvironmentObject
private var tabgroup: TabGroupData

var file: WorkspaceClient.FileItem
var file: CEWorkspaceFile

@StateObject
private var prefs: AppPreferencesModel = .shared
Expand All @@ -37,7 +37,7 @@ struct WorkspaceCodeFileView: View {
Spacer()
VStack(spacing: 10) {
ProgressView()
Text("Opening \(file.fileName)...")
Text("Opening \(file.name)...")
}
Spacer()
}
Expand All @@ -46,7 +46,7 @@ struct WorkspaceCodeFileView: View {
@ViewBuilder
private func otherFileView(
_ otherFile: CodeFileDocument,
for item: WorkspaceClient.FileItem
for item: CEWorkspaceFile
) -> some View {
VStack(spacing: 0) {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ import Foundation
import Combine

class WorkspaceNotificationModel: ObservableObject {

@Published
var highlightedFileItem: CEWorkspaceFile?

init() {
highlightedFileItem = nil
}

@Published var highlightedFileItem: WorkspaceClient.FileItem?
}
2 changes: 1 addition & 1 deletion CodeEdit/Features/Documents/WorkspaceDocument+Search.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ extension WorkspaceDocument {
// - Lazily load strings using `FileHandle.AsyncBytes`
// https://developer.apple.com/documentation/foundation/filehandle/3766681-bytes
filePaths.map { url in
WorkspaceClient.FileItem(url: url, children: nil)
CEWorkspaceFile(url: url, children: nil)
}.forEach { fileItem in
guard let data = try? Data(contentsOf: fileItem.url),
let string = String(data: data, encoding: .utf8) else { return }
Expand Down
Loading