Skip to content

Commit e98a622

Browse files
Fix for symlinks bug (#1840)
* Added check for folder symlink on the file manager * Created new directory events file manager extension, added label to symlinks in navigator, bugfixes * Updated CodeFileDocument url to linked url when opening files * Fix for the loop when saving symlinks * Renamed linkedURL, fixed typo and updated isSymbolicLink property * Restored named computed property * Fixed typos * Update CodeEdit/Features/Editor/Views/EditorAreaFileView.swift --------- Co-authored-by: Tom Ludwig <[email protected]>
1 parent efe2baf commit e98a622

File tree

12 files changed

+383
-269
lines changed

12 files changed

+383
-269
lines changed

CodeEdit.xcodeproj/project.pbxproj

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,9 @@
473473
77A01E2E2BB4261200F0EA38 /* CEWorkspaceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77A01E2D2BB4261200F0EA38 /* CEWorkspaceSettings.swift */; };
474474
77A01E432BBC3A2800F0EA38 /* CETask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77A01E422BBC3A2800F0EA38 /* CETask.swift */; };
475475
77A01E6D2BC3EA2A00F0EA38 /* NSWindow+Child.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77A01E6C2BC3EA2A00F0EA38 /* NSWindow+Child.swift */; };
476+
77EF6C052C57DE4B00984B69 /* URL+ResouceValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77EF6C032C57DE4B00984B69 /* URL+ResouceValues.swift */; };
477+
77EF6C0B2C60C80800984B69 /* URL+Filename.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77EF6C0A2C60C80800984B69 /* URL+Filename.swift */; };
478+
77EF6C0D2C60E23400984B69 /* CEWorkspaceFileManager+DirectoryEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77EF6C0C2C60E23400984B69 /* CEWorkspaceFileManager+DirectoryEvents.swift */; };
476479
850C631029D6B01D00E1444C /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 850C630F29D6B01D00E1444C /* SettingsView.swift */; };
477480
850C631229D6B03400E1444C /* SettingsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 850C631129D6B03400E1444C /* SettingsPage.swift */; };
478481
852C7E332A587279006BA599 /* SearchableSettingsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 852C7E322A587279006BA599 /* SearchableSettingsPage.swift */; };
@@ -1116,6 +1119,9 @@
11161119
77A01E2D2BB4261200F0EA38 /* CEWorkspaceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CEWorkspaceSettings.swift; sourceTree = "<group>"; };
11171120
77A01E422BBC3A2800F0EA38 /* CETask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CETask.swift; sourceTree = "<group>"; };
11181121
77A01E6C2BC3EA2A00F0EA38 /* NSWindow+Child.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Child.swift"; sourceTree = "<group>"; };
1122+
77EF6C032C57DE4B00984B69 /* URL+ResouceValues.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URL+ResouceValues.swift"; sourceTree = "<group>"; };
1123+
77EF6C0A2C60C80800984B69 /* URL+Filename.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Filename.swift"; sourceTree = "<group>"; };
1124+
77EF6C0C2C60E23400984B69 /* CEWorkspaceFileManager+DirectoryEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CEWorkspaceFileManager+DirectoryEvents.swift"; sourceTree = "<group>"; };
11191125
850C630F29D6B01D00E1444C /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
11201126
850C631129D6B03400E1444C /* SettingsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPage.swift; sourceTree = "<group>"; };
11211127
852C7E322A587279006BA599 /* SearchableSettingsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchableSettingsPage.swift; sourceTree = "<group>"; };
@@ -2338,6 +2344,7 @@
23382344
5894E59629FEF7740077E59C /* CEWorkspaceFile+Recursion.swift */,
23392345
58A2E40629C3975D005CB615 /* CEWorkspaceFileIcon.swift */,
23402346
58710158298EB80000951BA4 /* CEWorkspaceFileManager.swift */,
2347+
77EF6C0C2C60E23400984B69 /* CEWorkspaceFileManager+DirectoryEvents.swift */,
23412348
6CB52DC82AC8DC3E002E75B3 /* CEWorkspaceFileManager+FileManagement.swift */,
23422349
6C049A362A49E2DB00D42923 /* DirectoryEventStream.swift */,
23432350
);
@@ -2420,6 +2427,7 @@
24202427
6C82D6C429C0129E00495C54 /* NSApplication */,
24212428
5831E3D02934036D00D5A6D2 /* NSTableView */,
24222429
77A01E922BCA9C0400F0EA38 /* NSWindow */,
2430+
77EF6C042C57DE4B00984B69 /* URL */,
24232431
58D01C8B293167DC00C5B6B4 /* String */,
24242432
5831E3CB2933E89A00D5A6D2 /* SwiftTerm */,
24252433
6CBD1BC42978DE3E006639D5 /* Text */,
@@ -2995,6 +3003,15 @@
29953003
path = NSWindow;
29963004
sourceTree = "<group>";
29973005
};
3006+
77EF6C042C57DE4B00984B69 /* URL */ = {
3007+
isa = PBXGroup;
3008+
children = (
3009+
77EF6C032C57DE4B00984B69 /* URL+ResouceValues.swift */,
3010+
77EF6C0A2C60C80800984B69 /* URL+Filename.swift */,
3011+
);
3012+
path = URL;
3013+
sourceTree = "<group>";
3014+
};
29983015
85E412282A46C8B900183F2B /* Models */ = {
29993016
isa = PBXGroup;
30003017
children = (
@@ -3876,6 +3893,7 @@
38763893
04BA7C192AE2D7C600584E1C /* GitClient+Branches.swift in Sources */,
38773894
587B9E8829301D8F00AC7927 /* GitHubFiles.swift in Sources */,
38783895
587B9DA729300ABD00AC7927 /* HelpButton.swift in Sources */,
3896+
77EF6C0B2C60C80800984B69 /* URL+Filename.swift in Sources */,
38793897
30B088172C0D53080063A882 /* LSPUtil.swift in Sources */,
38803898
6C5B63DE29C76213005454BA /* WindowCodeFileView.swift in Sources */,
38813899
58F2EB08292FB2B0004A9BDE /* TextEditingSettings.swift in Sources */,
@@ -4045,6 +4063,7 @@
40454063
201169E22837B3D800F92B46 /* SourceControlNavigatorChangesView.swift in Sources */,
40464064
850C631029D6B01D00E1444C /* SettingsView.swift in Sources */,
40474065
77A01E6D2BC3EA2A00F0EA38 /* NSWindow+Child.swift in Sources */,
4066+
77EF6C0D2C60E23400984B69 /* CEWorkspaceFileManager+DirectoryEvents.swift in Sources */,
40484067
581550CF29FBD30400684881 /* StandardTableViewCell.swift in Sources */,
40494068
B62AEDB82A1FE2DC009A9F52 /* UtilityAreaOutputView.swift in Sources */,
40504069
B67DB0FC2AFDF71F002DC647 /* IconToggleStyle.swift in Sources */,
@@ -4131,6 +4150,7 @@
41314150
58D01C94293167DC00C5B6B4 /* Color+HEX.swift in Sources */,
41324151
6C578D8729CD345900DC73B2 /* ExtensionSceneView.swift in Sources */,
41334152
617DB3D02C25AFAE00B58BFE /* TaskNotificationHandler.swift in Sources */,
4153+
77EF6C052C57DE4B00984B69 /* URL+ResouceValues.swift in Sources */,
41344154
B640A9A129E2188F00715F20 /* View+NavigationBarBackButtonVisible.swift in Sources */,
41354155
587B9E7929301D8F00AC7927 /* GitHubIssueRouter.swift in Sources */,
41364156
587B9E8029301D8F00AC7927 /* GitHubConfiguration.swift in Sources */,

CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,14 @@ import Combine
3434
final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, EditorTabRepresentable {
3535

3636
/// The id of the ``CEWorkspaceFile``.
37-
///
38-
/// This is equal to `url.relativePath`
39-
var id: String { url.relativePath }
37+
var id: String
4038

4139
/// Returns the file name (e.g.: `Package.swift`)
4240
var name: String { url.lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines) }
4341

4442
/// Returns the extension of the file or an empty string if no extension is present.
4543
var type: FileIcon.FileType {
46-
let filename = url.lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines)
44+
let filename = url.fileName
4745

4846
/// First, check if there is a valid file extension.
4947
if let type = FileIcon.FileType(rawValue: filename) {
@@ -63,6 +61,11 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor
6361
/// Returns the URL of the ``CEWorkspaceFile``
6462
let url: URL
6563

64+
/// Returns the resolved symlink url of this object.
65+
lazy var resolvedURL: URL = {
66+
url.isSymbolicLink ? url.resolvingSymlinksInPath() : url
67+
}()
68+
6669
/// Return the icon of the file as `Image`
6770
var icon: Image {
6871
if let customImage = NSImage.symbol(named: systemImage) {
@@ -113,15 +116,15 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor
113116

114117
/// Returns a boolean that is true if the resource represented by this object is a directory.
115118
lazy var isFolder: Bool = {
116-
(try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true
119+
resolvedURL.isFolder
117120
}()
118121

119122
/// Returns a boolean that is true if the contents of the directory at this path are
120123
///
121124
/// Does not indicate if this is a folder, see ``isFolder`` to first check if this object is also a directory.
122125
var isEmptyFolder: Bool {
123126
(try? CEWorkspaceFile.fileManager.contentsOfDirectory(
124-
at: url,
127+
at: resolvedURL,
125128
includingPropertiesForKeys: nil,
126129
options: .skipsSubdirectoryDescendants
127130
).isEmpty) ?? true
@@ -151,7 +154,7 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor
151154

152155
/// Return the file's UTType
153156
var contentType: UTType? {
154-
try? url.resourceValues(forKeys: [.contentTypeKey]).contentType
157+
url.contentType
155158
}
156159

157160
/// Returns a `Color` for a specific `fileType`
@@ -162,30 +165,50 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor
162165
}
163166

164167
init(
168+
id: String,
165169
url: URL,
166170
changeType: GitStatus? = nil,
167171
staged: Bool? = false
168172
) {
173+
self.id = id
169174
self.url = url
170175
self.gitStatus = changeType
171176
self.staged = staged
172177
}
173178

179+
convenience init(
180+
url: URL,
181+
changeType: GitStatus? = nil,
182+
staged: Bool? = false
183+
) {
184+
self.init(
185+
id: url.relativePath,
186+
url: url,
187+
changeType: changeType,
188+
staged: staged
189+
)
190+
}
191+
174192
enum CodingKeys: String, CodingKey {
193+
case id
194+
case name
175195
case url
176196
case changeType
177197
case staged
178198
}
179199

180200
required init(from decoder: Decoder) throws {
181201
let values = try decoder.container(keyedBy: CodingKeys.self)
202+
id = try values.decode(String.self, forKey: .id)
182203
url = try values.decode(URL.self, forKey: .url)
183204
gitStatus = try values.decode(GitStatus.self, forKey: .changeType)
184205
staged = try values.decode(Bool.self, forKey: .staged)
185206
}
186207

187208
func encode(to encoder: Encoder) throws {
188209
var container = encoder.container(keyedBy: CodingKeys.self)
210+
try container.encode(id, forKey: .id)
211+
try container.encode(name, forKey: .name)
189212
try container.encode(url, forKey: .url)
190213
try container.encode(gitStatus, forKey: .changeType)
191214
try container.encode(staged, forKey: .staged)
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
//
2+
// CEWorkspaceFileManager+DirectoryEvents.swift
3+
// CodeEdit
4+
//
5+
// Created by Axel Martinez on 5/8/24.
6+
//
7+
8+
import Foundation
9+
10+
/// This extension handles the file system events triggered by changes in the root folder.
11+
extension CEWorkspaceFileManager {
12+
/// Called by `fsEventStream` when an event occurs.
13+
///
14+
/// This method may be called on a background thread, but all work done by this function will be queued on the main
15+
/// thread.
16+
/// - Parameter events: An array of events that occurred.
17+
func fileSystemEventReceived(events: [DirectoryEventStream.Event]) {
18+
DispatchQueue.main.async {
19+
var files: Set<CEWorkspaceFile> = []
20+
for event in events {
21+
// Event returns file/folder that was changed, but in tree we need to update it's parent
22+
let parentUrl = "/" + event.path.split(separator: "/").dropLast().joined(separator: "/")
23+
// Find all folders pointing to the parent's file url.
24+
let fileItems = self.flattenedFileItems.filter({
25+
$0.value.resolvedURL.path == parentUrl
26+
}).map { $0.value }
27+
28+
switch event.eventType {
29+
case .changeInDirectory, .itemChangedOwner, .itemModified:
30+
// Can be ignored for now, these I think not related to tree changes
31+
continue
32+
case .rootChanged:
33+
// TODO: Handle workspace root changing.
34+
continue
35+
case .itemCreated, .itemCloned, .itemRemoved, .itemRenamed:
36+
for fileItem in fileItems {
37+
do {
38+
try self.rebuildFiles(fromItem: fileItem)
39+
} catch {
40+
// swiftlint:disable:next line_length
41+
self.logger.error("Failed to rebuild files for event: \(event.eventType.rawValue), path: \(event.path, privacy: .sensitive)")
42+
}
43+
files.insert(fileItem)
44+
}
45+
}
46+
}
47+
if !files.isEmpty {
48+
self.notifyObservers(updatedItems: files)
49+
}
50+
51+
self.handleGitEvents(events: events)
52+
}
53+
}
54+
55+
func handleGitEvents(events: [DirectoryEventStream.Event]) {
56+
// Changes excluding .git folder
57+
let notGitChanges = events.filter({ !$0.path.contains(".git/") })
58+
59+
// .git folder was changed
60+
let gitFolderChange = events.first(where: {
61+
$0.path == "\(self.folderUrl.relativePath)/.git"
62+
})
63+
64+
// Change made to git index file, staged/unstaged files
65+
let gitIndexChange = events.first(where: {
66+
$0.path == "\(self.folderUrl.relativePath)/.git/index"
67+
})
68+
69+
// Change made to git stash
70+
let gitStashChange = events.first(where: {
71+
$0.path == "\(self.folderUrl.relativePath)/.git/refs/stash"
72+
})
73+
74+
// Changes made to git branches
75+
let gitBranchChange = events.first(where: {
76+
$0.path.contains("\(self.folderUrl.relativePath)/.git/refs/heads")
77+
})
78+
79+
// Changes made to git HEAD - current branch changed
80+
let gitHeadChange = events.first(where: {
81+
$0.path.contains("\(self.folderUrl.relativePath)/.git/HEAD")
82+
})
83+
84+
// Change made to remotes by looking at .git/config
85+
let gitConfigChange = events.first(where: {
86+
$0.path == "\(self.folderUrl.relativePath)/.git/config"
87+
})
88+
89+
// If changes were made to project OR files were staged, refresh changes
90+
if !notGitChanges.isEmpty || gitIndexChange != nil {
91+
Task {
92+
await self.sourceControlManager?.refreshAllChangedFiles()
93+
}
94+
}
95+
96+
// If changes were stashed, refresh stashed entries
97+
if gitStashChange != nil {
98+
Task {
99+
try await self.sourceControlManager?.refreshStashEntries()
100+
}
101+
}
102+
103+
// If branches were added or removed, refresh branches
104+
if gitBranchChange != nil {
105+
Task {
106+
await self.sourceControlManager?.refreshBranches()
107+
}
108+
}
109+
110+
// If HEAD was changed, refresh the current branch
111+
if gitHeadChange != nil {
112+
Task {
113+
await self.sourceControlManager?.refreshCurrentBranch()
114+
}
115+
}
116+
117+
// If git config changed, refresh remotes
118+
if gitConfigChange != nil {
119+
Task {
120+
try await self.sourceControlManager?.refreshRemotes()
121+
}
122+
}
123+
124+
// If .git folder was added or removed, check if repository is valid
125+
if gitFolderChange != nil {
126+
Task {
127+
try await self.sourceControlManager?.validate()
128+
}
129+
}
130+
}
131+
132+
/// Creates or deletes children of the ``CEWorkspaceFile`` so that they are accurate with the file system,
133+
/// instead of creating an entirely new ``CEWorkspaceFile``. Can optionally run a deep rebuild.
134+
///
135+
/// This method will return immediately if the given file item is not a directory.
136+
/// This will also only rebuild *already cached* directories.
137+
/// - Parameters:
138+
/// - fileItem: The ``CEWorkspaceFile`` to correct the children of
139+
/// - deep: Set to `true` if this should perform the rebuild recursively.
140+
func rebuildFiles(fromItem fileItem: CEWorkspaceFile, deep: Bool = false) throws {
141+
// Do not index directories that are not already loaded.
142+
guard childrenMap[fileItem.id] != nil else { return }
143+
144+
// get the actual directory children
145+
let directoryContentsUrls = try fileManager.contentsOfDirectory(
146+
at: fileItem.resolvedURL,
147+
includingPropertiesForKeys: nil
148+
)
149+
150+
// test for deleted children, and remove them from the index
151+
// Folders may or may not have slash at the end, this will normalize check
152+
let directoryContentsUrlsRelativePaths = directoryContentsUrls.map({ $0.relativePath })
153+
for (idx, oldURL) in (childrenMap[fileItem.id] ?? []).map({ URL(filePath: $0) }).enumerated().reversed()
154+
where !directoryContentsUrlsRelativePaths.contains(oldURL.relativePath) {
155+
flattenedFileItems.removeValue(forKey: oldURL.relativePath)
156+
childrenMap[fileItem.id]?.remove(at: idx)
157+
}
158+
159+
// test for new children, and index them
160+
for newContent in directoryContentsUrls {
161+
// if the child has already been indexed, continue to the next item.
162+
guard !ignoredFilesAndFolders.contains(newContent.lastPathComponent) &&
163+
!(childrenMap[fileItem.id]?.contains(newContent.relativePath) ?? true) else { continue }
164+
165+
if fileManager.fileExists(atPath: newContent.path) {
166+
let newFileItem = createChild(newContent, forParent: fileItem)
167+
flattenedFileItems[newFileItem.id] = newFileItem
168+
childrenMap[fileItem.id]?.append(newFileItem.id)
169+
}
170+
}
171+
172+
childrenMap[fileItem.id] = childrenMap[fileItem.id]?
173+
.map { URL(filePath: $0) }
174+
.sortItems(foldersOnTop: true)
175+
.map { $0.relativePath }
176+
177+
if deep && childrenMap[fileItem.id] != nil {
178+
for child in (childrenMap[fileItem.id] ?? []).compactMap({ flattenedFileItems[$0] }) {
179+
try rebuildFiles(fromItem: child)
180+
}
181+
}
182+
}
183+
184+
/// Notify observers that an update occurred in the watched files.
185+
func notifyObservers(updatedItems: Set<CEWorkspaceFile>) {
186+
observers.allObjects.reversed().forEach { delegate in
187+
guard let delegate = delegate as? CEWorkspaceFileManagerObserver else {
188+
observers.remove(delegate)
189+
return
190+
}
191+
delegate.fileManagerUpdated(updatedItems: updatedItems)
192+
}
193+
}
194+
195+
/// Add an observer for file system events.
196+
/// - Parameter observer: The observer to add.
197+
func addObserver(_ observer: CEWorkspaceFileManagerObserver) {
198+
observers.add(observer as AnyObject)
199+
}
200+
201+
/// Remove an observer for file system events.
202+
/// - Parameter observer: The observer to remove.
203+
func removeObserver(_ observer: CEWorkspaceFileManagerObserver) {
204+
observers.remove(observer as AnyObject)
205+
}
206+
}

0 commit comments

Comments
 (0)