diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 2a2bd8d04..ca558a99c 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -473,6 +473,9 @@ 77A01E2E2BB4261200F0EA38 /* CEWorkspaceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77A01E2D2BB4261200F0EA38 /* CEWorkspaceSettings.swift */; }; 77A01E432BBC3A2800F0EA38 /* CETask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77A01E422BBC3A2800F0EA38 /* CETask.swift */; }; 77A01E6D2BC3EA2A00F0EA38 /* NSWindow+Child.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77A01E6C2BC3EA2A00F0EA38 /* NSWindow+Child.swift */; }; + 77EF6C052C57DE4B00984B69 /* URL+ResouceValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77EF6C032C57DE4B00984B69 /* URL+ResouceValues.swift */; }; + 77EF6C0B2C60C80800984B69 /* URL+Filename.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77EF6C0A2C60C80800984B69 /* URL+Filename.swift */; }; + 77EF6C0D2C60E23400984B69 /* CEWorkspaceFileManager+DirectoryEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77EF6C0C2C60E23400984B69 /* CEWorkspaceFileManager+DirectoryEvents.swift */; }; 850C631029D6B01D00E1444C /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 850C630F29D6B01D00E1444C /* SettingsView.swift */; }; 850C631229D6B03400E1444C /* SettingsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 850C631129D6B03400E1444C /* SettingsPage.swift */; }; 852C7E332A587279006BA599 /* SearchableSettingsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 852C7E322A587279006BA599 /* SearchableSettingsPage.swift */; }; @@ -1116,6 +1119,9 @@ 77A01E2D2BB4261200F0EA38 /* CEWorkspaceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CEWorkspaceSettings.swift; sourceTree = ""; }; 77A01E422BBC3A2800F0EA38 /* CETask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CETask.swift; sourceTree = ""; }; 77A01E6C2BC3EA2A00F0EA38 /* NSWindow+Child.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Child.swift"; sourceTree = ""; }; + 77EF6C032C57DE4B00984B69 /* URL+ResouceValues.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URL+ResouceValues.swift"; sourceTree = ""; }; + 77EF6C0A2C60C80800984B69 /* URL+Filename.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Filename.swift"; sourceTree = ""; }; + 77EF6C0C2C60E23400984B69 /* CEWorkspaceFileManager+DirectoryEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CEWorkspaceFileManager+DirectoryEvents.swift"; sourceTree = ""; }; 850C630F29D6B01D00E1444C /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 850C631129D6B03400E1444C /* SettingsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPage.swift; sourceTree = ""; }; 852C7E322A587279006BA599 /* SearchableSettingsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchableSettingsPage.swift; sourceTree = ""; }; @@ -2338,6 +2344,7 @@ 5894E59629FEF7740077E59C /* CEWorkspaceFile+Recursion.swift */, 58A2E40629C3975D005CB615 /* CEWorkspaceFileIcon.swift */, 58710158298EB80000951BA4 /* CEWorkspaceFileManager.swift */, + 77EF6C0C2C60E23400984B69 /* CEWorkspaceFileManager+DirectoryEvents.swift */, 6CB52DC82AC8DC3E002E75B3 /* CEWorkspaceFileManager+FileManagement.swift */, 6C049A362A49E2DB00D42923 /* DirectoryEventStream.swift */, ); @@ -2420,6 +2427,7 @@ 6C82D6C429C0129E00495C54 /* NSApplication */, 5831E3D02934036D00D5A6D2 /* NSTableView */, 77A01E922BCA9C0400F0EA38 /* NSWindow */, + 77EF6C042C57DE4B00984B69 /* URL */, 58D01C8B293167DC00C5B6B4 /* String */, 5831E3CB2933E89A00D5A6D2 /* SwiftTerm */, 6CBD1BC42978DE3E006639D5 /* Text */, @@ -2995,6 +3003,15 @@ path = NSWindow; sourceTree = ""; }; + 77EF6C042C57DE4B00984B69 /* URL */ = { + isa = PBXGroup; + children = ( + 77EF6C032C57DE4B00984B69 /* URL+ResouceValues.swift */, + 77EF6C0A2C60C80800984B69 /* URL+Filename.swift */, + ); + path = URL; + sourceTree = ""; + }; 85E412282A46C8B900183F2B /* Models */ = { isa = PBXGroup; children = ( @@ -3876,6 +3893,7 @@ 04BA7C192AE2D7C600584E1C /* GitClient+Branches.swift in Sources */, 587B9E8829301D8F00AC7927 /* GitHubFiles.swift in Sources */, 587B9DA729300ABD00AC7927 /* HelpButton.swift in Sources */, + 77EF6C0B2C60C80800984B69 /* URL+Filename.swift in Sources */, 30B088172C0D53080063A882 /* LSPUtil.swift in Sources */, 6C5B63DE29C76213005454BA /* WindowCodeFileView.swift in Sources */, 58F2EB08292FB2B0004A9BDE /* TextEditingSettings.swift in Sources */, @@ -4045,6 +4063,7 @@ 201169E22837B3D800F92B46 /* SourceControlNavigatorChangesView.swift in Sources */, 850C631029D6B01D00E1444C /* SettingsView.swift in Sources */, 77A01E6D2BC3EA2A00F0EA38 /* NSWindow+Child.swift in Sources */, + 77EF6C0D2C60E23400984B69 /* CEWorkspaceFileManager+DirectoryEvents.swift in Sources */, 581550CF29FBD30400684881 /* StandardTableViewCell.swift in Sources */, B62AEDB82A1FE2DC009A9F52 /* UtilityAreaOutputView.swift in Sources */, B67DB0FC2AFDF71F002DC647 /* IconToggleStyle.swift in Sources */, @@ -4131,6 +4150,7 @@ 58D01C94293167DC00C5B6B4 /* Color+HEX.swift in Sources */, 6C578D8729CD345900DC73B2 /* ExtensionSceneView.swift in Sources */, 617DB3D02C25AFAE00B58BFE /* TaskNotificationHandler.swift in Sources */, + 77EF6C052C57DE4B00984B69 /* URL+ResouceValues.swift in Sources */, B640A9A129E2188F00715F20 /* View+NavigationBarBackButtonVisible.swift in Sources */, 587B9E7929301D8F00AC7927 /* GitHubIssueRouter.swift in Sources */, 587B9E8029301D8F00AC7927 /* GitHubConfiguration.swift in Sources */, diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift index 818578965..f9ece260d 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift @@ -34,16 +34,14 @@ import Combine final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, EditorTabRepresentable { /// The id of the ``CEWorkspaceFile``. - /// - /// This is equal to `url.relativePath` - var id: String { url.relativePath } + var id: String /// Returns the file name (e.g.: `Package.swift`) var name: String { url.lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines) } /// Returns the extension of the file or an empty string if no extension is present. var type: FileIcon.FileType { - let filename = url.lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines) + let filename = url.fileName /// First, check if there is a valid file extension. if let type = FileIcon.FileType(rawValue: filename) { @@ -63,6 +61,11 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor /// Returns the URL of the ``CEWorkspaceFile`` let url: URL + /// Returns the resolved symlink url of this object. + lazy var resolvedURL: URL = { + url.isSymbolicLink ? url.resolvingSymlinksInPath() : url + }() + /// Return the icon of the file as `Image` var icon: Image { if let customImage = NSImage.symbol(named: systemImage) { @@ -113,7 +116,7 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor /// Returns a boolean that is true if the resource represented by this object is a directory. lazy var isFolder: Bool = { - (try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true + resolvedURL.isFolder }() /// Returns a boolean that is true if the contents of the directory at this path are @@ -121,7 +124,7 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor /// Does not indicate if this is a folder, see ``isFolder`` to first check if this object is also a directory. var isEmptyFolder: Bool { (try? CEWorkspaceFile.fileManager.contentsOfDirectory( - at: url, + at: resolvedURL, includingPropertiesForKeys: nil, options: .skipsSubdirectoryDescendants ).isEmpty) ?? true @@ -151,7 +154,7 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor /// Return the file's UTType var contentType: UTType? { - try? url.resourceValues(forKeys: [.contentTypeKey]).contentType + url.contentType } /// Returns a `Color` for a specific `fileType` @@ -162,16 +165,33 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor } init( + id: String, url: URL, changeType: GitStatus? = nil, staged: Bool? = false ) { + self.id = id self.url = url self.gitStatus = changeType self.staged = staged } + convenience init( + url: URL, + changeType: GitStatus? = nil, + staged: Bool? = false + ) { + self.init( + id: url.relativePath, + url: url, + changeType: changeType, + staged: staged + ) + } + enum CodingKeys: String, CodingKey { + case id + case name case url case changeType case staged @@ -179,6 +199,7 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor required init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) url = try values.decode(URL.self, forKey: .url) gitStatus = try values.decode(GitStatus.self, forKey: .changeType) staged = try values.decode(Bool.self, forKey: .staged) @@ -186,6 +207,8 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) try container.encode(url, forKey: .url) try container.encode(gitStatus, forKey: .changeType) try container.encode(staged, forKey: .staged) diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift new file mode 100644 index 000000000..fd1482267 --- /dev/null +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift @@ -0,0 +1,206 @@ +// +// CEWorkspaceFileManager+DirectoryEvents.swift +// CodeEdit +// +// Created by Axel Martinez on 5/8/24. +// + +import Foundation + +/// This extension handles the file system events triggered by changes in the root folder. +extension CEWorkspaceFileManager { + /// Called by `fsEventStream` when an event occurs. + /// + /// This method may be called on a background thread, but all work done by this function will be queued on the main + /// thread. + /// - Parameter events: An array of events that occurred. + func fileSystemEventReceived(events: [DirectoryEventStream.Event]) { + DispatchQueue.main.async { + var files: Set = [] + for event in events { + // Event returns file/folder that was changed, but in tree we need to update it's parent + let parentUrl = "/" + event.path.split(separator: "/").dropLast().joined(separator: "/") + // Find all folders pointing to the parent's file url. + let fileItems = self.flattenedFileItems.filter({ + $0.value.resolvedURL.path == parentUrl + }).map { $0.value } + + switch event.eventType { + case .changeInDirectory, .itemChangedOwner, .itemModified: + // Can be ignored for now, these I think not related to tree changes + continue + case .rootChanged: + // TODO: Handle workspace root changing. + continue + case .itemCreated, .itemCloned, .itemRemoved, .itemRenamed: + for fileItem in fileItems { + do { + try self.rebuildFiles(fromItem: fileItem) + } catch { + // swiftlint:disable:next line_length + self.logger.error("Failed to rebuild files for event: \(event.eventType.rawValue), path: \(event.path, privacy: .sensitive)") + } + files.insert(fileItem) + } + } + } + if !files.isEmpty { + self.notifyObservers(updatedItems: files) + } + + self.handleGitEvents(events: events) + } + } + + func handleGitEvents(events: [DirectoryEventStream.Event]) { + // Changes excluding .git folder + let notGitChanges = events.filter({ !$0.path.contains(".git/") }) + + // .git folder was changed + let gitFolderChange = events.first(where: { + $0.path == "\(self.folderUrl.relativePath)/.git" + }) + + // Change made to git index file, staged/unstaged files + let gitIndexChange = events.first(where: { + $0.path == "\(self.folderUrl.relativePath)/.git/index" + }) + + // Change made to git stash + let gitStashChange = events.first(where: { + $0.path == "\(self.folderUrl.relativePath)/.git/refs/stash" + }) + + // Changes made to git branches + let gitBranchChange = events.first(where: { + $0.path.contains("\(self.folderUrl.relativePath)/.git/refs/heads") + }) + + // Changes made to git HEAD - current branch changed + let gitHeadChange = events.first(where: { + $0.path.contains("\(self.folderUrl.relativePath)/.git/HEAD") + }) + + // Change made to remotes by looking at .git/config + let gitConfigChange = events.first(where: { + $0.path == "\(self.folderUrl.relativePath)/.git/config" + }) + + // If changes were made to project OR files were staged, refresh changes + if !notGitChanges.isEmpty || gitIndexChange != nil { + Task { + await self.sourceControlManager?.refreshAllChangedFiles() + } + } + + // If changes were stashed, refresh stashed entries + if gitStashChange != nil { + Task { + try await self.sourceControlManager?.refreshStashEntries() + } + } + + // If branches were added or removed, refresh branches + if gitBranchChange != nil { + Task { + await self.sourceControlManager?.refreshBranches() + } + } + + // If HEAD was changed, refresh the current branch + if gitHeadChange != nil { + Task { + await self.sourceControlManager?.refreshCurrentBranch() + } + } + + // If git config changed, refresh remotes + if gitConfigChange != nil { + Task { + try await self.sourceControlManager?.refreshRemotes() + } + } + + // If .git folder was added or removed, check if repository is valid + if gitFolderChange != nil { + Task { + try await self.sourceControlManager?.validate() + } + } + } + + /// Creates or deletes children of the ``CEWorkspaceFile`` so that they are accurate with the file system, + /// instead of creating an entirely new ``CEWorkspaceFile``. Can optionally run a deep rebuild. + /// + /// This method will return immediately if the given file item is not a directory. + /// This will also only rebuild *already cached* directories. + /// - Parameters: + /// - fileItem: The ``CEWorkspaceFile`` to correct the children of + /// - deep: Set to `true` if this should perform the rebuild recursively. + func rebuildFiles(fromItem fileItem: CEWorkspaceFile, deep: Bool = false) throws { + // Do not index directories that are not already loaded. + guard childrenMap[fileItem.id] != nil else { return } + + // get the actual directory children + let directoryContentsUrls = try fileManager.contentsOfDirectory( + at: fileItem.resolvedURL, + includingPropertiesForKeys: nil + ) + + // test for deleted children, and remove them from the index + // Folders may or may not have slash at the end, this will normalize check + let directoryContentsUrlsRelativePaths = directoryContentsUrls.map({ $0.relativePath }) + for (idx, oldURL) in (childrenMap[fileItem.id] ?? []).map({ URL(filePath: $0) }).enumerated().reversed() + where !directoryContentsUrlsRelativePaths.contains(oldURL.relativePath) { + flattenedFileItems.removeValue(forKey: oldURL.relativePath) + childrenMap[fileItem.id]?.remove(at: idx) + } + + // test for new children, and index them + for newContent in directoryContentsUrls { + // if the child has already been indexed, continue to the next item. + guard !ignoredFilesAndFolders.contains(newContent.lastPathComponent) && + !(childrenMap[fileItem.id]?.contains(newContent.relativePath) ?? true) else { continue } + + if fileManager.fileExists(atPath: newContent.path) { + let newFileItem = createChild(newContent, forParent: fileItem) + flattenedFileItems[newFileItem.id] = newFileItem + childrenMap[fileItem.id]?.append(newFileItem.id) + } + } + + childrenMap[fileItem.id] = childrenMap[fileItem.id]? + .map { URL(filePath: $0) } + .sortItems(foldersOnTop: true) + .map { $0.relativePath } + + if deep && childrenMap[fileItem.id] != nil { + for child in (childrenMap[fileItem.id] ?? []).compactMap({ flattenedFileItems[$0] }) { + try rebuildFiles(fromItem: child) + } + } + } + + /// Notify observers that an update occurred in the watched files. + func notifyObservers(updatedItems: Set) { + observers.allObjects.reversed().forEach { delegate in + guard let delegate = delegate as? CEWorkspaceFileManagerObserver else { + observers.remove(delegate) + return + } + delegate.fileManagerUpdated(updatedItems: updatedItems) + } + } + + /// Add an observer for file system events. + /// - Parameter observer: The observer to add. + func addObserver(_ observer: CEWorkspaceFileManagerObserver) { + observers.add(observer as AnyObject) + } + + /// Remove an observer for file system events. + /// - Parameter observer: The observer to remove. + func removeObserver(_ observer: CEWorkspaceFileManagerObserver) { + observers.remove(observer as AnyObject) + } +} diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift index 162514b03..07402f957 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift @@ -42,11 +42,12 @@ final class CEWorkspaceFileManager { let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "CEWorkspaceFileManager") private(set) var fileManager: FileManager private(set) var ignoredFilesAndFolders: Set - private(set) var flattenedFileItems: [String: CEWorkspaceFile] + + var flattenedFileItems: [String: CEWorkspaceFile] /// Maps all directories to it's children's paths. - private var childrenMap: [String: [String]] = [:] - private var fsEventStream: DirectoryEventStream? - private var observers: NSHashTable = .weakObjects() + var childrenMap: [String: [String]] = [:] + var fsEventStream: DirectoryEventStream? + var observers: NSHashTable = .weakObjects() let folderUrl: URL let workspaceItem: CEWorkspaceFile @@ -139,6 +140,7 @@ final class CEWorkspaceFileManager { return childrenMap[file.id]?.compactMap { flattenedFileItems[$0] } } + return nil } @@ -149,15 +151,16 @@ final class CEWorkspaceFileManager { /// /// - Parameter file: The file item to load children for. private func loadChildrenForFile(_ file: CEWorkspaceFile) { - guard let children = urlsForDirectory(file) else { + guard let children = urlsForDirectory(file.resolvedURL) else { return } + var addedChildrenUrls: [String] = [] for child in children { - let newFileItem = CEWorkspaceFile(url: child) - newFileItem.parent = file + let newFileItem = createChild(child, forParent: file) flattenedFileItems[newFileItem.id] = newFileItem + addedChildrenUrls.append(newFileItem.id) } - childrenMap[file.id] = children.map { $0.relativePath } + childrenMap[file.id] = addedChildrenUrls Task { await sourceControlManager?.refreshAllChangedFiles() } @@ -166,9 +169,9 @@ final class CEWorkspaceFileManager { /// Creates an ordered array of all files and directories at the given file object. /// - Parameter file: The file to use. /// - Returns: An ordered array of URLs sorted alphabetically with directories first. - private func urlsForDirectory(_ file: CEWorkspaceFile) -> [URL]? { + private func urlsForDirectory(_ url: URL) -> [URL]? { try? fileManager.contentsOfDirectory( - at: file.url, + at: url, includingPropertiesForKeys: [.isDirectoryKey], options: [.includesDirectoriesPostOrder, .skipsSubdirectoryDescendants] ) @@ -180,6 +183,18 @@ final class CEWorkspaceFileManager { .sortItems(foldersOnTop: true) } + /// Creates a child item for the specified parent item. Th child item's id is based on the + /// parent's id to take into account symlinks. + /// - Parameter url: The file url of the child element. + /// - Parameter file: The parent element. + /// - Returns: A child element with an associated parent. + func createChild(_ url: URL, forParent file: CEWorkspaceFile) -> CEWorkspaceFile { + let childId = URL(filePath: file.id).appendingPathComponent(url.lastPathComponent).relativePath + let newFileItem = CEWorkspaceFile(id: childId, url: url) + newFileItem.parent = file + return newFileItem + } + #if DEBUG /// Determines if the file has had it's children loaded from disk. /// - Parameter file: The file to check. @@ -189,8 +204,6 @@ final class CEWorkspaceFileManager { } #endif - // MARK: - Directory Events - /// Run when the owner of the ``CEWorkspaceFileManager`` doesn't need it anymore. /// This de-inits most functions in the ``CEWorkspaceFileManager``, so that in case it isn't de-init'd it does not /// use up significant amounts of RAM, and clears any file system event watchers. @@ -199,200 +212,6 @@ final class CEWorkspaceFileManager { flattenedFileItems = [workspaceItem.id: workspaceItem] } - /// Called by `fsEventStream` when an event occurs. - /// - /// This method may be called on a background thread, but all work done by this function will be queued on the main - /// thread. - /// - Parameter events: An array of events that occurred. - private func fileSystemEventReceived(events: [DirectoryEventStream.Event]) { - DispatchQueue.main.async { - var files: Set = [] - for event in events { - // Event returns file/folder that was changed, but in tree we need to update it's parent - let parent = "/" + event.path.split(separator: "/").dropLast().joined(separator: "/") - guard let parentItem = self.getFile(parent) else { - continue - } - - switch event.eventType { - case .changeInDirectory, .itemChangedOwner, .itemModified: - // Can be ignored for now, these I think not related to tree changes - continue - case .rootChanged: - // TODO: Handle workspace root changing. - continue - case .itemCreated, .itemCloned, .itemRemoved, .itemRenamed: - do { - try self.rebuildFiles(fromItem: parentItem) - } catch { - // swiftlint:disable:next line_length - self.logger.error("Failed to rebuild files for event: \(event.eventType.rawValue), path: \(event.path, privacy: .sensitive)") - } - files.insert(parentItem) - } - } - if !files.isEmpty { - self.notifyObservers(updatedItems: files) - } - - self.handleGitEvents(events: events) - } - } - - func handleGitEvents(events: [DirectoryEventStream.Event]) { - // Changes excluding .git folder - let notGitChanges = events.filter({ !$0.path.contains(".git/") }) - - // .git folder was changed - let gitFolderChange = events.first(where: { - $0.path == "\(self.folderUrl.relativePath)/.git" - }) - - // Change made to git index file, staged/unstaged files - let gitIndexChange = events.first(where: { - $0.path == "\(self.folderUrl.relativePath)/.git/index" - }) - - // Change made to git stash - let gitStashChange = events.first(where: { - $0.path == "\(self.folderUrl.relativePath)/.git/refs/stash" - }) - - // Changes made to git branches - let gitBranchChange = events.first(where: { - $0.path.contains("\(self.folderUrl.relativePath)/.git/refs/heads") - }) - - // Changes made to git HEAD - current branch changed - let gitHeadChange = events.first(where: { - $0.path.contains("\(self.folderUrl.relativePath)/.git/HEAD") - }) - - // Change made to remotes by looking at .git/config - let gitConfigChange = events.first(where: { - $0.path == "\(self.folderUrl.relativePath)/.git/config" - }) - - // If changes were made to project OR files were staged, refresh changes - if !notGitChanges.isEmpty || gitIndexChange != nil { - Task { - await self.sourceControlManager?.refreshAllChangedFiles() - } - } - - // If changes were stashed, refresh stashed entries - if gitStashChange != nil { - Task { - try await self.sourceControlManager?.refreshStashEntries() - } - } - - // If branches were added or removed, refresh branches - if gitBranchChange != nil { - Task { - await self.sourceControlManager?.refreshBranches() - } - } - - // If HEAD was changed, refresh the current branch - if gitHeadChange != nil { - Task { - await self.sourceControlManager?.refreshCurrentBranch() - } - } - - // If git config changed, refresh remotes - if gitConfigChange != nil { - Task { - try await self.sourceControlManager?.refreshRemotes() - } - } - - // If .git folder was added or removed, check if repository is valid - if gitFolderChange != nil { - Task { - try await self.sourceControlManager?.validate() - } - } - } - - /// Creates or deletes children of the ``CEWorkspaceFile`` so that they are accurate with the file system, - /// instead of creating an entirely new ``CEWorkspaceFile``. Can optionally run a deep rebuild. - /// - /// This method will return immediately if the given file item is not a directory. - /// This will also only rebuild *already cached* directories. - /// - Parameters: - /// - fileItem: The ``CEWorkspaceFile`` to correct the children of - /// - deep: Set to `true` if this should perform the rebuild recursively. - func rebuildFiles(fromItem fileItem: CEWorkspaceFile, deep: Bool = false) throws { - // Do not index directories that are not already loaded. - guard childrenMap[fileItem.id] != nil else { return } - - // 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 - // Folders may or may not have slash at the end, this will normalize check - let directoryContentsUrlsRelativePaths = directoryContentsUrls.map({ $0.relativePath }) - for (idx, oldURL) in (childrenMap[fileItem.id] ?? []).map({ URL(filePath: $0) }).enumerated().reversed() - where !directoryContentsUrlsRelativePaths.contains(oldURL.relativePath) { - flattenedFileItems.removeValue(forKey: oldURL.relativePath) - childrenMap[fileItem.id]?.remove(at: idx) - } - - // test for new children, and index them - for newContent in directoryContentsUrls { - // if the child has already been indexed, continue to the next item. - guard !ignoredFilesAndFolders.contains(newContent.lastPathComponent) && - !(childrenMap[fileItem.id]?.contains(newContent.relativePath) ?? true) else { continue } - - if fileManager.fileExists(atPath: newContent.path) { - let newFileItem = CEWorkspaceFile(url: newContent) - - newFileItem.parent = fileItem - flattenedFileItems[newFileItem.id] = newFileItem - childrenMap[fileItem.id]?.append(newFileItem.id) - } - } - - childrenMap[fileItem.id] = childrenMap[fileItem.id]? - .map { URL(filePath: $0) } - .sortItems(foldersOnTop: true) - .map { $0.relativePath } - - if deep && childrenMap[fileItem.id] != nil { - for child in (childrenMap[fileItem.id] ?? []).compactMap({ flattenedFileItems[$0] }) { - try rebuildFiles(fromItem: child) - } - } - } - - /// Notify observers that an update occurred in the watched files. - func notifyObservers(updatedItems: Set) { - observers.allObjects.reversed().forEach { delegate in - guard let delegate = delegate as? CEWorkspaceFileManagerObserver else { - observers.remove(delegate) - return - } - delegate.fileManagerUpdated(updatedItems: updatedItems) - } - } - - /// Add an observer for file system events. - /// - Parameter observer: The observer to add. - func addObserver(_ observer: CEWorkspaceFileManagerObserver) { - observers.add(observer as AnyObject) - } - - /// Remove an observer for file system events. - /// - Parameter observer: The observer to remove. - func removeObserver(_ observer: CEWorkspaceFileManagerObserver) { - observers.remove(observer as AnyObject) - } - deinit { fsEventStream?.cancel() observers.removeAllObjects() diff --git a/CodeEdit/Features/Editor/Models/Editor.swift b/CodeEdit/Features/Editor/Models/Editor.swift index 801a95569..e8a4e5afb 100644 --- a/CodeEdit/Features/Editor/Models/Editor.swift +++ b/CodeEdit/Features/Editor/Models/Editor.swift @@ -213,11 +213,11 @@ final class Editor: ObservableObject, Identifiable { return } - let contentType = try item.file.url.resourceValues(forKeys: [.contentTypeKey]).contentType + let contentType = item.file.resolvedURL.contentType let codeFile = try CodeFileDocument( for: item.file.url, // TODO: FILE CONTENTS ARE READ MULTIPLE TIMES - withContentsOf: item.file.url, + withContentsOf: item.file.resolvedURL, ofType: contentType?.identifier ?? "" ) item.file.fileDocument = codeFile diff --git a/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift b/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift index 7189a7968..e5d4f9ffc 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift @@ -19,48 +19,20 @@ struct EditorAreaFileView: View { @Environment(\.edgeInsets) private var edgeInsets - var file: CEWorkspaceFile + var codeFile: CodeFileDocument var textViewCoordinators: [TextViewCoordinator] = [] - @State private var update: Bool = false - @ViewBuilder var editorAreaFileView: some View { - if let document = file.fileDocument { - - if let utType = document.utType, utType.conforms(to: .text) { - CodeFileView(codeFile: document, textViewCoordinators: textViewCoordinators) - } else { - NonTextFileView(fileDocument: document) - .padding(.top, edgeInsets.top - 1.74) // Use the magic number to fine-tune its appearance. - .padding(.bottom, StatusBarView.height + 1.26) // Use the magic number to fine-tune its appearance. - .modifier(UpdateStatusBarInfo(with: document.fileURL)) - .onDisappear { - statusBarViewModel.dimensions = nil - statusBarViewModel.fileSize = nil - } - } - + if let utType = codeFile.utType, utType.conforms(to: .text) { + CodeFileView(codeFile: codeFile, textViewCoordinators: textViewCoordinators) } else { - if update { - Spacer() - } - Spacer() - LoadingFileView(file.name) - Spacer() - .onAppear { - Task.detached { - let contentType = try await file.url.resourceValues(forKeys: [.contentTypeKey]).contentType - let codeFile = try await CodeFileDocument( - for: file.url, - withContentsOf: file.url, - ofType: contentType?.identifier ?? "" - ) - await MainActor.run { - file.fileDocument = codeFile - CodeEditDocumentController.shared.addDocument(codeFile) - update.toggle() - } - } + NonTextFileView(fileDocument: codeFile) + .padding(.top, edgeInsets.top - 1.74) + .padding(.bottom, StatusBarView.height + 1.26) + .modifier(UpdateStatusBarInfo(with: codeFile.fileURL)) + .onDisappear { + statusBarViewModel.dimensions = nil + statusBarViewModel.fileSize = nil } } } diff --git a/CodeEdit/Features/Editor/Views/EditorAreaView.swift b/CodeEdit/Features/Editor/Views/EditorAreaView.swift index 11a63f8d0..a08a0a5d6 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaView.swift @@ -23,6 +23,8 @@ struct EditorAreaView: View { @EnvironmentObject private var editorManager: EditorManager + @State var codeFile: CodeFileDocument? + var body: some View { var shouldShowTabBar: Bool { return navigationStyle == .openInTabs @@ -40,15 +42,36 @@ struct EditorAreaView: View { VStack { if let selected = editor.selectedTab { - EditorAreaFileView( - file: selected.file, - textViewCoordinators: [selected.rangeTranslator].compactMap({ $0 }) - ) - .focusedObject(editor) - .transformEnvironment(\.edgeInsets) { insets in - insets.top += editorInsetAmount + if let codeFile = codeFile { + EditorAreaFileView( + codeFile: codeFile, + textViewCoordinators: [selected.rangeTranslator].compactMap({ $0 }) + ) + .focusedObject(editor) + .transformEnvironment(\.edgeInsets) { insets in + insets.top += editorInsetAmount + } + .opacity(dimEditorsWithoutFocus && editor != editorManager.activeEditor ? 0.5 : 1) + } else { + LoadingFileView(selected.file.name) + .task { + do { + let contentType = selected.file.resolvedURL.contentType + let newCodeFile = try CodeFileDocument( + for: selected.file.url, + withContentsOf: selected.file.resolvedURL, + ofType: contentType?.identifier ?? "" + ) + + selected.file.fileDocument = newCodeFile + CodeEditDocumentController.shared.addDocument(newCodeFile) + self.codeFile = newCodeFile + } catch { + print(error.localizedDescription) + } + } } - .opacity(dimEditorsWithoutFocus && editor != editorManager.activeEditor ? 0.5 : 1) + } else { CEContentUnavailableView("No Editor") .padding(.top, editorInsetAmount) @@ -95,5 +118,8 @@ struct EditorAreaView: View { editor.temporaryTab = editor.tabs[0] } } + .onChange(of: editor.selectedTab) { newValue in + codeFile = newValue?.file.fileDocument + } } } diff --git a/CodeEdit/Features/Editor/Views/LoadingFileView.swift b/CodeEdit/Features/Editor/Views/LoadingFileView.swift index 71eb34699..f08c5af90 100644 --- a/CodeEdit/Features/Editor/Views/LoadingFileView.swift +++ b/CodeEdit/Features/Editor/Views/LoadingFileView.swift @@ -25,8 +25,10 @@ struct LoadingFileView: View { var body: some View { VStack(spacing: 10) { + Spacer() ProgressView() Text("Opening \(filename)...") + Spacer() } } } diff --git a/CodeEdit/Features/NavigatorArea/OutlineView/FileSystemTableViewCell.swift b/CodeEdit/Features/NavigatorArea/OutlineView/FileSystemTableViewCell.swift index 8b8657937..6f1763089 100644 --- a/CodeEdit/Features/NavigatorArea/OutlineView/FileSystemTableViewCell.swift +++ b/CodeEdit/Features/NavigatorArea/OutlineView/FileSystemTableViewCell.swift @@ -44,8 +44,19 @@ class FileSystemTableViewCell: StandardTableViewCell { } func addModel() { - secondaryLabel?.stringValue = fileItem?.gitStatus?.description ?? "" - if secondaryLabel?.stringValue == "?" { secondaryLabel?.stringValue = "A" } + guard let fileItem = fileItem, let secondaryLabel = secondaryLabel else { + return + } + + if fileItem.url.isSymbolicLink { secondaryLabel.stringValue = "􀰞" } + + guard let gitStatus = fileItem.gitStatus?.description else { + return + } + + if gitStatus == "?" { secondaryLabel.stringValue += "A" } else { + secondaryLabel.stringValue += gitStatus + } } /// *Not Implemented* diff --git a/CodeEdit/Utils/Extensions/Array/Array+SortURLs.swift b/CodeEdit/Utils/Extensions/Array/Array+SortURLs.swift index fb31a4c55..e3b2887b7 100644 --- a/CodeEdit/Utils/Extensions/Array/Array+SortURLs.swift +++ b/CodeEdit/Utils/Extensions/Array/Array+SortURLs.swift @@ -7,12 +7,6 @@ import Foundation -fileprivate extension URL { - var isFolder: Bool { - (try? resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false - } -} - extension Array where Element == URL { /// Sorts the elements in alphabetical order. diff --git a/CodeEdit/Utils/Extensions/URL/URL+Filename.swift b/CodeEdit/Utils/Extensions/URL/URL+Filename.swift new file mode 100644 index 000000000..be9a2dbd3 --- /dev/null +++ b/CodeEdit/Utils/Extensions/URL/URL+Filename.swift @@ -0,0 +1,14 @@ +// +// URL+Filename.swift +// CodeEdit +// +// Created by Axel Martinez on 5/8/24. +// + +import Foundation + +extension URL { + var fileName: String { + self.lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/CodeEdit/Utils/Extensions/URL/URL+ResouceValues.swift b/CodeEdit/Utils/Extensions/URL/URL+ResouceValues.swift new file mode 100644 index 000000000..94285d567 --- /dev/null +++ b/CodeEdit/Utils/Extensions/URL/URL+ResouceValues.swift @@ -0,0 +1,27 @@ +// +// URL+ResouceValues.swift +// CodeEdit +// +// Created by Axel Martinez on 27/6/24. +// + +import Foundation +import UniformTypeIdentifiers + +extension URL { + fileprivate var resourceValues: URLResourceValues? { + try? self.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey, .contentTypeKey]) + } + + var isFolder: Bool { + resourceValues?.isDirectory ?? false + } + + var isSymbolicLink: Bool { + resourceValues?.isSymbolicLink ?? false || (resourceValues?.contentType ?? .item) == .aliasFile + } + + var contentType: UTType? { + resourceValues?.contentType + } +}