Skip to content

Commit a4a76a2

Browse files
🧹 Refactor of the WorkspaceClient (#1157)
1 parent fa94af1 commit a4a76a2

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+1017
-813
lines changed

CodeEdit.xcodeproj/project.pbxproj

Lines changed: 54 additions & 46 deletions
Large diffs are not rendered by default.

CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift

Lines changed: 483 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
//
2+
// FileSystemClient.swift
3+
// CodeEdit
4+
//
5+
// Created by Matthijs Eikelenboom on 04/02/2023.
6+
//
7+
8+
import Combine
9+
import Foundation
10+
11+
/// This class is used to load the files of the machine into a CodeEdit workspace.
12+
final class CEWorkspaceFileManager {
13+
enum FileSystemClientError: Error {
14+
case fileNotExist
15+
}
16+
17+
private var subject = CurrentValueSubject<[CEWorkspaceFile], Never>([])
18+
private var isRunning = false
19+
private var anotherInstanceRan = 0
20+
21+
private(set) var fileManager = FileManager.default
22+
private(set) var ignoredFilesAndFolders: [String]
23+
private(set) var flattenedFileItems: [String: CEWorkspaceFile]
24+
25+
var onRefresh: () -> Void = {}
26+
var getFiles: AnyPublisher<[CEWorkspaceFile], Never> =
27+
CurrentValueSubject<[CEWorkspaceFile], Never>([]).eraseToAnyPublisher()
28+
29+
let folderUrl: URL
30+
let workspaceItem: CEWorkspaceFile
31+
32+
init(folderUrl: URL, ignoredFilesAndFolders: [String]) {
33+
self.folderUrl = folderUrl
34+
self.ignoredFilesAndFolders = ignoredFilesAndFolders
35+
36+
self.workspaceItem = CEWorkspaceFile(url: folderUrl, children: [])
37+
self.flattenedFileItems = [workspaceItem.id: workspaceItem]
38+
39+
self.setup()
40+
}
41+
42+
private func setup() {
43+
// initial load
44+
var workspaceFiles: [CEWorkspaceFile]
45+
do {
46+
workspaceFiles = try loadFiles(fromUrl: self.folderUrl)
47+
} catch {
48+
fatalError("Failed to loadFiles")
49+
}
50+
51+
// workspace fileItem
52+
let workspaceFile = CEWorkspaceFile(url: self.folderUrl, children: workspaceFiles)
53+
flattenedFileItems[workspaceFile.id] = workspaceFile
54+
workspaceFiles.forEach { item in
55+
item.parent = workspaceFile
56+
}
57+
58+
// By using `CurrentValueSubject` we can define a starting value.
59+
// The value passed during init it's going to be send as soon as the
60+
// consumer subscribes to the publisher.
61+
let subject = CurrentValueSubject<[CEWorkspaceFile], Never>(workspaceFiles)
62+
63+
self.getFiles = subject
64+
.handleEvents(receiveCancel: {
65+
for item in self.flattenedFileItems.values {
66+
item.watcher?.cancel()
67+
item.watcher = nil
68+
}
69+
})
70+
.receive(on: RunLoop.main)
71+
.eraseToAnyPublisher()
72+
73+
workspaceFile.watcherCode = { sourceFileItem in
74+
self.reloadFromWatcher(sourceFileItem: sourceFileItem)
75+
}
76+
reloadFromWatcher(sourceFileItem: workspaceFile)
77+
}
78+
79+
/// Recursive loading of files into `FileItem`s
80+
/// - Parameter url: The URL of the directory to load the items of
81+
/// - Returns: `[FileItem]` representing the contents of the directory
82+
private func loadFiles(fromUrl url: URL) throws -> [CEWorkspaceFile] {
83+
let directoryContents = try fileManager.contentsOfDirectory(
84+
at: url.resolvingSymlinksInPath(),
85+
includingPropertiesForKeys: nil
86+
)
87+
var items: [CEWorkspaceFile] = []
88+
89+
for itemURL in directoryContents {
90+
guard !ignoredFilesAndFolders.contains(itemURL.lastPathComponent) else { continue }
91+
92+
var isDir: ObjCBool = false
93+
94+
if fileManager.fileExists(atPath: itemURL.path, isDirectory: &isDir) {
95+
var subItems: [CEWorkspaceFile]?
96+
97+
if isDir.boolValue {
98+
// Recursively fetch subdirectories and files if the path points to a directory
99+
subItems = try loadFiles(fromUrl: itemURL)
100+
}
101+
102+
let newFileItem = CEWorkspaceFile(
103+
url: itemURL,
104+
children: subItems?.sortItems(foldersOnTop: true)
105+
)
106+
107+
// note: watcher code will be applied after the workspaceItem is created
108+
newFileItem.watcherCode = { sourceFileItem in
109+
self.reloadFromWatcher(sourceFileItem: sourceFileItem)
110+
}
111+
subItems?.forEach { $0.parent = newFileItem }
112+
items.append(newFileItem)
113+
flattenedFileItems[newFileItem.id] = newFileItem
114+
}
115+
}
116+
117+
return items
118+
}
119+
120+
/// A function that, given a file's path, returns a `FileItem` if it exists
121+
/// within the scope of the `FileSystemClient`.
122+
/// - Parameter id: The file's full path
123+
/// - Returns: The file item corresponding to the file
124+
func getFileItem(_ id: String) throws -> CEWorkspaceFile {
125+
guard let item = flattenedFileItems[id] else {
126+
throw FileSystemClientError.fileNotExist
127+
}
128+
129+
return item
130+
}
131+
132+
/// Usually run when the owner of the `FileSystemClient` doesn't need it anymore.
133+
/// This de-inits most functions in the `FileSystemClient`, so that in case it isn't de-init'd it does not use up
134+
/// significant amounts of RAM.
135+
func cleanUp() {
136+
stopListeningToDirectory()
137+
workspaceItem.children = []
138+
flattenedFileItems = [workspaceItem.id: workspaceItem]
139+
print("Cleaned up watchers and file items")
140+
}
141+
142+
// run by dispatchsource watchers. Multiple instances may be concurrent,
143+
// so we need to be careful to avoid EXC_BAD_ACCESS errors.
144+
/// This is a function run by `DispatchSource` file watchers. Due to the nature of watchers, multiple
145+
/// instances may be running concurrently, so the function prevents more than one instance of it from
146+
/// running the main code body.
147+
/// - Parameter sourceFileItem: The `FileItem` corresponding to the file that triggered the `DispatchSource`
148+
func reloadFromWatcher(sourceFileItem: CEWorkspaceFile) {
149+
// Something has changed inside the directory
150+
// We should reload the files.
151+
guard !isRunning else { // this runs when a file change is detected but is already running
152+
anotherInstanceRan += 1
153+
return
154+
}
155+
isRunning = true
156+
157+
// inital reload of files
158+
_ = try? rebuildFiles(fromItem: sourceFileItem)
159+
160+
// re-reload if another instance tried to run while this instance was running
161+
// TODO: optimise
162+
while anotherInstanceRan > 0 {
163+
let somethingChanged = try? rebuildFiles(fromItem: workspaceItem)
164+
anotherInstanceRan = !(somethingChanged ?? false) ? 0 : anotherInstanceRan - 1
165+
}
166+
167+
subject.send(workspaceItem.children ?? [])
168+
isRunning = false
169+
anotherInstanceRan = 0
170+
171+
// reload data in outline view controller through the main thread
172+
DispatchQueue.main.async {
173+
self.onRefresh()
174+
}
175+
}
176+
177+
/// A function to kill the watcher of a specific directory, or all directories.
178+
/// - Parameter directory: The directory to stop watching, or nil to stop watching everything.
179+
func stopListeningToDirectory(directory: URL? = nil) {
180+
if directory != nil {
181+
flattenedFileItems[directory!.relativePath]?.watcher?.cancel()
182+
} else {
183+
for item in flattenedFileItems.values {
184+
item.watcher?.cancel()
185+
item.watcher = nil
186+
}
187+
}
188+
}
189+
190+
/// Recursive function similar to `loadFiles`, but creates or deletes children of the
191+
/// `FileItem` so that they are accurate with the file system, instead of creating an
192+
/// entirely new `FileItem`, to prevent the `OutlineView` from going crazy with folding.
193+
/// - Parameter fileItem: The `FileItem` to correct the children of
194+
@discardableResult
195+
func rebuildFiles(fromItem fileItem: CEWorkspaceFile) throws -> Bool {
196+
var didChangeSomething = false
197+
198+
// get the actual directory children
199+
let directoryContentsUrls = try fileManager.contentsOfDirectory(
200+
at: fileItem.url.resolvingSymlinksInPath(),
201+
includingPropertiesForKeys: nil
202+
)
203+
204+
// test for deleted children, and remove them from the index
205+
for oldContent in fileItem.children ?? [] where !directoryContentsUrls.contains(oldContent.url) {
206+
if let removeAt = fileItem.children?.firstIndex(of: oldContent) {
207+
fileItem.children?[removeAt].watcher?.cancel()
208+
fileItem.children?.remove(at: removeAt)
209+
flattenedFileItems.removeValue(forKey: oldContent.id)
210+
didChangeSomething = true
211+
}
212+
}
213+
214+
// test for new children, and index them using loadFiles
215+
for newContent in directoryContentsUrls {
216+
guard !ignoredFilesAndFolders.contains(newContent.lastPathComponent) else { continue }
217+
218+
// if the child has already been indexed, continue to the next item.
219+
guard !(fileItem.children?.map({ $0.url }).contains(newContent) ?? false) else { continue }
220+
221+
var isDir: ObjCBool = false
222+
if fileManager.fileExists(atPath: newContent.path, isDirectory: &isDir) {
223+
var subItems: [CEWorkspaceFile]?
224+
225+
if isDir.boolValue { subItems = try loadFiles(fromUrl: newContent) }
226+
227+
let newFileItem = CEWorkspaceFile(
228+
url: newContent,
229+
children: subItems?.sortItems(foldersOnTop: true)
230+
)
231+
232+
newFileItem.watcherCode = { sourceFileItem in
233+
self.reloadFromWatcher(sourceFileItem: sourceFileItem)
234+
}
235+
236+
subItems?.forEach { $0.parent = newFileItem }
237+
238+
newFileItem.parent = fileItem
239+
flattenedFileItems[newFileItem.id] = newFileItem
240+
fileItem.children?.append(newFileItem)
241+
didChangeSomething = true
242+
}
243+
}
244+
245+
fileItem.children = fileItem.children?.sortItems(foldersOnTop: true)
246+
fileItem.children?.forEach({
247+
if $0.isFolder {
248+
let childChanged = try? rebuildFiles(fromItem: $0)
249+
didChangeSomething = (childChanged ?? false) ? true : didChangeSomething
250+
}
251+
flattenedFileItems[$0.id] = $0
252+
})
253+
254+
return didChangeSomething
255+
}
256+
257+
}

CodeEdit/Features/CodeEditUI/Views/ToolbarBranchPicker.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import CodeEditSymbols
1010

1111
/// A view that pops up a branch picker.
1212
struct ToolbarBranchPicker: View {
13-
private var workspace: WorkspaceClient?
13+
private var workspaceFileManager: CEWorkspaceFileManager?
1414
private var gitClient: GitClient?
1515

1616
@Environment(\.controlActiveState)
@@ -30,10 +30,10 @@ struct ToolbarBranchPicker: View {
3030
/// - Parameter workspace: An instance of the current `WorkspaceClient`
3131
init(
3232
shellClient: ShellClient,
33-
workspace: WorkspaceClient?
33+
workspaceFileManager: CEWorkspaceFileManager?
3434
) {
35-
self.workspace = workspace
36-
if let folderURL = workspace?.folderURL() {
35+
self.workspaceFileManager = workspaceFileManager
36+
if let folderURL = workspaceFileManager?.folderUrl {
3737
self.gitClient = GitClient(directoryURL: folderURL, shellClient: shellClient)
3838
}
3939
self._currentBranch = State(initialValue: try? gitClient?.getCurrentBranchName())
@@ -94,7 +94,7 @@ struct ToolbarBranchPicker: View {
9494
}
9595

9696
private var title: String {
97-
workspace?.folderURL()?.lastPathComponent ?? "Empty"
97+
workspaceFileManager?.folderUrl.lastPathComponent ?? "Empty"
9898
}
9999

100100
// MARK: Popover View

CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate {
209209
let view = NSHostingView(
210210
rootView: ToolbarBranchPicker(
211211
shellClient: currentWorld.shellClient,
212-
workspace: workspace?.workspaceClient
212+
workspaceFileManager: workspace?.workspaceFileManager
213213
)
214214
)
215215
toolbarItem.view = view

CodeEdit/Features/Documents/Views/WorkspaceCodeFileView.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ struct WorkspaceCodeFileView: View {
1616
@EnvironmentObject
1717
private var tabgroup: TabGroupData
1818

19-
var file: WorkspaceClient.FileItem
19+
var file: CEWorkspaceFile
2020

2121
@StateObject
2222
private var prefs: AppPreferencesModel = .shared
@@ -37,7 +37,7 @@ struct WorkspaceCodeFileView: View {
3737
Spacer()
3838
VStack(spacing: 10) {
3939
ProgressView()
40-
Text("Opening \(file.fileName)...")
40+
Text("Opening \(file.name)...")
4141
}
4242
Spacer()
4343
}
@@ -46,7 +46,7 @@ struct WorkspaceCodeFileView: View {
4646
@ViewBuilder
4747
private func otherFileView(
4848
_ otherFile: CodeFileDocument,
49-
for item: WorkspaceClient.FileItem
49+
for item: CEWorkspaceFile
5050
) -> some View {
5151
VStack(spacing: 0) {
5252

CodeEdit/Features/Documents/WorkspaceDocument+Listeners.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@ import Foundation
99
import Combine
1010

1111
class WorkspaceNotificationModel: ObservableObject {
12+
13+
@Published
14+
var highlightedFileItem: CEWorkspaceFile?
15+
1216
init() {
1317
highlightedFileItem = nil
1418
}
1519

16-
@Published var highlightedFileItem: WorkspaceClient.FileItem?
1720
}

CodeEdit/Features/Documents/WorkspaceDocument+Search.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ extension WorkspaceDocument {
7474
// - Lazily load strings using `FileHandle.AsyncBytes`
7575
// https://developer.apple.com/documentation/foundation/filehandle/3766681-bytes
7676
filePaths.map { url in
77-
WorkspaceClient.FileItem(url: url, children: nil)
77+
CEWorkspaceFile(url: url, children: nil)
7878
}.forEach { fileItem in
7979
guard let data = try? Data(contentsOf: fileItem.url),
8080
let string = String(data: data, encoding: .utf8) else { return }

0 commit comments

Comments
 (0)