diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 3edecc7d2..2a2bd8d04 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -30,7 +30,7 @@ 04C3255C2801F86900C8DA2D /* ProjectNavigatorMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 285FEC7127FE4EEF00E57D53 /* ProjectNavigatorMenu.swift */; }; 0FD96BCE2BEF42530025A697 /* CodeEditWindowController+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FD96BCD2BEF42530025A697 /* CodeEditWindowController+Toolbar.swift */; }; 201169D72837B2E300F92B46 /* SourceControlNavigatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201169D62837B2E300F92B46 /* SourceControlNavigatorView.swift */; }; - 201169DB2837B34000F92B46 /* SourceControlNavigatorChangedFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201169DA2837B34000F92B46 /* SourceControlNavigatorChangedFileView.swift */; }; + 201169DB2837B34000F92B46 /* GitChangedFileListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201169DA2837B34000F92B46 /* GitChangedFileListView.swift */; }; 201169DD2837B3AC00F92B46 /* SourceControlNavigatorToolbarBottom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201169DC2837B3AC00F92B46 /* SourceControlNavigatorToolbarBottom.swift */; }; 201169E22837B3D800F92B46 /* SourceControlNavigatorChangesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201169E12837B3D800F92B46 /* SourceControlNavigatorChangesView.swift */; }; 201169E52837B40300F92B46 /* SourceControlNavigatorRepositoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201169E42837B40300F92B46 /* SourceControlNavigatorRepositoryView.swift */; }; @@ -225,7 +225,7 @@ 587B9E9729301D8F00AC7927 /* BitBucketAccount+Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B9E5029301D8F00AC7927 /* BitBucketAccount+Token.swift */; }; 587B9E9829301D8F00AC7927 /* GitCommit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B9E5329301D8F00AC7927 /* GitCommit.swift */; }; 587B9E9929301D8F00AC7927 /* GitChangedFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B9E5429301D8F00AC7927 /* GitChangedFile.swift */; }; - 587B9E9A29301D8F00AC7927 /* GitType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B9E5529301D8F00AC7927 /* GitType.swift */; }; + 587B9E9A29301D8F00AC7927 /* GitStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B9E5529301D8F00AC7927 /* GitStatus.swift */; }; 587FB99029C1246400B519DD /* EditorTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587FB98F29C1246400B519DD /* EditorTabView.swift */; }; 58822524292C280D00E83CDE /* StatusBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58822509292C280D00E83CDE /* StatusBarView.swift */; }; 58822525292C280D00E83CDE /* StatusBarMenuStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5882250B292C280D00E83CDE /* StatusBarMenuStyle.swift */; }; @@ -376,6 +376,7 @@ 6C1CC9982B1E770B0002349B /* AsyncFileIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1CC9972B1E770B0002349B /* AsyncFileIterator.swift */; }; 6C1CC99B2B1E7CBC0002349B /* FindNavigatorIndexBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1CC99A2B1E7CBC0002349B /* FindNavigatorIndexBar.swift */; }; 6C1F3DA22C18C55800F6DEF6 /* ShellIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1F3DA12C18C55800F6DEF6 /* ShellIntegrationTests.swift */; }; + 6C23842F2C796B4C003FBDD4 /* GitChangedFileLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C23842E2C796B4C003FBDD4 /* GitChangedFileLabel.swift */; }; 6C2C155829B4F49100EA60A5 /* SplitViewItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2C155729B4F49100EA60A5 /* SplitViewItem.swift */; }; 6C2C155A29B4F4CC00EA60A5 /* Variadic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2C155929B4F4CC00EA60A5 /* Variadic.swift */; }; 6C2C155D29B4F4E500EA60A5 /* SplitViewReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2C155C29B4F4E500EA60A5 /* SplitViewReader.swift */; }; @@ -561,7 +562,6 @@ B6C4F2A32B3CA74800B2B140 /* CommitDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C4F2A22B3CA74800B2B140 /* CommitDetailsView.swift */; }; B6C4F2A62B3CABD200B2B140 /* HistoryInspectorItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C4F2A52B3CABD200B2B140 /* HistoryInspectorItemView.swift */; }; B6C4F2A92B3CB00100B2B140 /* CommitDetailsHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C4F2A82B3CB00100B2B140 /* CommitDetailsHeaderView.swift */; }; - B6C4F2AC2B3CC4D000B2B140 /* CommitChangedFileListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C4F2AB2B3CC4D000B2B140 /* CommitChangedFileListItemView.swift */; }; B6C6A42A297716A500A3D28F /* EditorTabCloseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C6A429297716A500A3D28F /* EditorTabCloseButton.swift */; }; B6C6A42E29771A8D00A3D28F /* EditorTabButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C6A42D29771A8D00A3D28F /* EditorTabButtonStyle.swift */; }; B6C6A43029771F7100A3D28F /* EditorTabBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C6A42F29771F7100A3D28F /* EditorTabBackground.swift */; }; @@ -687,7 +687,7 @@ 04BC1CDD2AD9B4B000A83EA5 /* EditorFileTabCloseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorFileTabCloseButton.swift; sourceTree = ""; }; 0FD96BCD2BEF42530025A697 /* CodeEditWindowController+Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CodeEditWindowController+Toolbar.swift"; sourceTree = ""; }; 201169D62837B2E300F92B46 /* SourceControlNavigatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlNavigatorView.swift; sourceTree = ""; }; - 201169DA2837B34000F92B46 /* SourceControlNavigatorChangedFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlNavigatorChangedFileView.swift; sourceTree = ""; }; + 201169DA2837B34000F92B46 /* GitChangedFileListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitChangedFileListView.swift; sourceTree = ""; }; 201169DC2837B3AC00F92B46 /* SourceControlNavigatorToolbarBottom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlNavigatorToolbarBottom.swift; sourceTree = ""; }; 201169E12837B3D800F92B46 /* SourceControlNavigatorChangesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlNavigatorChangesView.swift; sourceTree = ""; }; 201169E42837B40300F92B46 /* SourceControlNavigatorRepositoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlNavigatorRepositoryView.swift; sourceTree = ""; }; @@ -886,7 +886,7 @@ 587B9E5029301D8F00AC7927 /* BitBucketAccount+Token.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "BitBucketAccount+Token.swift"; sourceTree = ""; }; 587B9E5329301D8F00AC7927 /* GitCommit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitCommit.swift; sourceTree = ""; }; 587B9E5429301D8F00AC7927 /* GitChangedFile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitChangedFile.swift; sourceTree = ""; }; - 587B9E5529301D8F00AC7927 /* GitType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitType.swift; sourceTree = ""; }; + 587B9E5529301D8F00AC7927 /* GitStatus.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitStatus.swift; sourceTree = ""; }; 587FB98F29C1246400B519DD /* EditorTabView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditorTabView.swift; sourceTree = ""; }; 58822509292C280D00E83CDE /* StatusBarView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusBarView.swift; sourceTree = ""; }; 5882250B292C280D00E83CDE /* StatusBarMenuStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusBarMenuStyle.swift; sourceTree = ""; }; @@ -1034,6 +1034,7 @@ 6C1CC9972B1E770B0002349B /* AsyncFileIterator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncFileIterator.swift; sourceTree = ""; }; 6C1CC99A2B1E7CBC0002349B /* FindNavigatorIndexBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindNavigatorIndexBar.swift; sourceTree = ""; }; 6C1F3DA12C18C55800F6DEF6 /* ShellIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellIntegrationTests.swift; sourceTree = ""; }; + 6C23842E2C796B4C003FBDD4 /* GitChangedFileLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitChangedFileLabel.swift; sourceTree = ""; }; 6C2C155729B4F49100EA60A5 /* SplitViewItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitViewItem.swift; sourceTree = ""; }; 6C2C155929B4F4CC00EA60A5 /* Variadic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Variadic.swift; sourceTree = ""; }; 6C2C155C29B4F4E500EA60A5 /* SplitViewReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitViewReader.swift; sourceTree = ""; }; @@ -1210,7 +1211,6 @@ B6C4F2A22B3CA74800B2B140 /* CommitDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitDetailsView.swift; sourceTree = ""; }; B6C4F2A52B3CABD200B2B140 /* HistoryInspectorItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryInspectorItemView.swift; sourceTree = ""; }; B6C4F2A82B3CB00100B2B140 /* CommitDetailsHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitDetailsHeaderView.swift; sourceTree = ""; }; - B6C4F2AB2B3CC4D000B2B140 /* CommitChangedFileListItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitChangedFileListItemView.swift; sourceTree = ""; }; B6C6A429297716A500A3D28F /* EditorTabCloseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorTabCloseButton.swift; sourceTree = ""; }; B6C6A42D29771A8D00A3D28F /* EditorTabButtonStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditorTabButtonStyle.swift; sourceTree = ""; }; B6C6A42F29771F7100A3D28F /* EditorTabBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorTabBackground.swift; sourceTree = ""; }; @@ -1339,6 +1339,7 @@ 201169DE2837B3C700F92B46 /* Views */ = { isa = PBXGroup; children = ( + 6C2384302C796EBD003FBDD4 /* ChangedFile */, 201169D62837B2E300F92B46 /* SourceControlNavigatorView.swift */, 201169DC2837B3AC00F92B46 /* SourceControlNavigatorToolbarBottom.swift */, ); @@ -2247,7 +2248,7 @@ 587B9E5329301D8F00AC7927 /* GitCommit.swift */, B65B10F12B07D34F002852CF /* GitRemote.swift */, B607181F2B0C6CE7009CDAB4 /* GitStashEntry.swift */, - 587B9E5529301D8F00AC7927 /* GitType.swift */, + 587B9E5529301D8F00AC7927 /* GitStatus.swift */, ); path = Models; sourceTree = ""; @@ -2818,6 +2819,15 @@ path = TerminalEmulator; sourceTree = ""; }; + 6C2384302C796EBD003FBDD4 /* ChangedFile */ = { + isa = PBXGroup; + children = ( + 201169DA2837B34000F92B46 /* GitChangedFileListView.swift */, + 6C23842E2C796B4C003FBDD4 /* GitChangedFileLabel.swift */, + ); + path = ChangedFile; + sourceTree = ""; + }; 6C48B5DB2C0D664A001E9955 /* Model */ = { isa = PBXGroup; children = ( @@ -3016,11 +3026,10 @@ isa = PBXGroup; children = ( 201169E12837B3D800F92B46 /* SourceControlNavigatorChangesView.swift */, - 201169DA2837B34000F92B46 /* SourceControlNavigatorChangedFileView.swift */, 04BA7C0D2AE2A76E00584E1C /* SourceControlNavigatorChangesCommitView.swift */, + B65B10FD2B08B07D002852CF /* SourceControlNavigatorChangesList.swift */, 04BA7C232AE2E7CD00584E1C /* SourceControlNavigatorSyncView.swift */, B65B10F72B081A34002852CF /* SourceControlNavigatorNoRemotesView.swift */, - B65B10FD2B08B07D002852CF /* SourceControlNavigatorChangesList.swift */, ); path = Views; sourceTree = ""; @@ -3183,7 +3192,6 @@ 20EBB504280C329800F3A5DA /* CommitListItemView.swift */, B6C4F2A22B3CA74800B2B140 /* CommitDetailsView.swift */, B6C4F2A82B3CB00100B2B140 /* CommitDetailsHeaderView.swift */, - B6C4F2AB2B3CC4D000B2B140 /* CommitChangedFileListItemView.swift */, ); path = Views; sourceTree = ""; @@ -3871,7 +3879,7 @@ 30B088172C0D53080063A882 /* LSPUtil.swift in Sources */, 6C5B63DE29C76213005454BA /* WindowCodeFileView.swift in Sources */, 58F2EB08292FB2B0004A9BDE /* TextEditingSettings.swift in Sources */, - 201169DB2837B34000F92B46 /* SourceControlNavigatorChangedFileView.swift in Sources */, + 201169DB2837B34000F92B46 /* GitChangedFileListView.swift in Sources */, 61A3E3DD2C33132F00076BD3 /* CEWorkspaceSettingsView.swift in Sources */, 5882252E292C280D00E83CDE /* UtilityAreaMaximizeButton.swift in Sources */, 30B0880D2C0D53080063A882 /* LanguageServer+References.swift in Sources */, @@ -3949,7 +3957,6 @@ 581BFB672926431000D251EC /* WelcomeWindowView.swift in Sources */, 58A5DFA329339F6400D1BD5D /* CommandManager.swift in Sources */, 58798284292ED0FB0085B254 /* TerminalEmulatorView.swift in Sources */, - B6C4F2AC2B3CC4D000B2B140 /* CommitChangedFileListItemView.swift in Sources */, 61A3E3E12C331B4A00076BD3 /* AddCETaskView.swift in Sources */, 6C82D6B329BFD88700495C54 /* NavigateCommands.swift in Sources */, 617DB3D82C25B04D00B58BFE /* CECircularProgressView.swift in Sources */, @@ -3970,6 +3977,7 @@ 30B088092C0D53080063A882 /* LanguageServer+Formatting.swift in Sources */, 30B088102C0D53080063A882 /* LanguageServer+SemanticTokens.swift in Sources */, B6C6A43029771F7100A3D28F /* EditorTabBackground.swift in Sources */, + 6C23842F2C796B4C003FBDD4 /* GitChangedFileLabel.swift in Sources */, B60718372B170638009CDAB4 /* SourceControlRenameBranchView.swift in Sources */, 6C578D8129CD294800DC73B2 /* ExtensionActivatorView.swift in Sources */, B6F0517D29D9E4B100D72287 /* TerminalSettingsView.swift in Sources */, @@ -4218,7 +4226,7 @@ 61A3E3DF2C3318C900076BD3 /* CEWorkspaceSettingsTaskListView.swift in Sources */, 58F2EB07292FB2B0004A9BDE /* GeneralSettings.swift in Sources */, B6041F4D29D7A4E9000F3454 /* SettingsPageView.swift in Sources */, - 587B9E9A29301D8F00AC7927 /* GitType.swift in Sources */, + 587B9E9A29301D8F00AC7927 /* GitStatus.swift in Sources */, B65B10F82B081A34002852CF /* SourceControlNavigatorNoRemotesView.swift in Sources */, 58D01C97293167DC00C5B6B4 /* String+SHA256.swift in Sources */, 61A3E3D92C33126F00076BD3 /* CEWorkspaceSettingsData.swift in Sources */, diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift index 53606c219..818578965 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift @@ -102,8 +102,8 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor var fileIdentifier = UUID().uuidString - /// Returns the Git status of a file as ``GitType`` - var gitStatus: GitType? + /// Returns the Git status of a file as ``GitStatus`` + var gitStatus: GitStatus? /// Returns a boolean that is true if the file is staged for commit var staged: Bool? @@ -163,7 +163,7 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor init( url: URL, - changeType: GitType? = nil, + changeType: GitStatus? = nil, staged: Bool? = false ) { self.url = url @@ -180,7 +180,7 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor required init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) url = try values.decode(URL.self, forKey: .url) - gitStatus = try values.decode(GitType.self, forKey: .changeType) + gitStatus = try values.decode(GitStatus.self, forKey: .changeType) staged = try values.decode(Bool.self, forKey: .staged) } diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileIcon.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileIcon.swift index fbaa0893d..11869086c 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileIcon.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileIcon.swift @@ -93,7 +93,7 @@ enum FileIcon { /// Returns a string describing a SFSymbol for files /// If not specified otherwise this will return `"doc"` - static func fileIcon(fileType: FileType) -> String { // swiftlint:disable:this cyclomatic_complexity function_body_length line_length + static func fileIcon(fileType: FileType?) -> String { // swiftlint:disable:this cyclomatic_complexity function_body_length line_length switch fileType { case .json, .yml, .resolved: return "doc.json" @@ -171,7 +171,7 @@ enum FileIcon { /// Returns a `Color` for a specific `fileType` /// If not specified otherwise this will return `Color.accentColor` - static func iconColor(fileType: FileType) -> Color { // swiftlint:disable:this cyclomatic_complexity + static func iconColor(fileType: FileType?) -> Color { // swiftlint:disable:this cyclomatic_complexity switch fileType { case .swift, .html: return .orange diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift index 3f3df8bfc..83eb1a045 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift @@ -113,6 +113,13 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { // MARK: Set Up Workspace private func initWorkspaceState(_ url: URL) throws { + // Ensure the URL ends with a "/" to prevent certain URL(filePath:relativeTo) initializers from + // placing the file one directory above our workspace. This quick fix appends a "/" if needed. + var url = url + if !url.absoluteString.hasSuffix("/") { + url = URL(filePath: url.absoluteURL.path(percentEncoded: false) + "/") + } + self.fileURL = url self.displayName = url.lastPathComponent diff --git a/CodeEdit/Features/Editor/Models/EditorManager.swift b/CodeEdit/Features/Editor/Models/EditorManager.swift index 669d01354..5c60da34b 100644 --- a/CodeEdit/Features/Editor/Models/EditorManager.swift +++ b/CodeEdit/Features/Editor/Models/EditorManager.swift @@ -91,9 +91,10 @@ class EditorManager: ObservableObject { /// - Parameters: /// - item: The tab to open. /// - editor: The editor to add the tab to. If nil, it is added to the active tab group. - func openTab(item: CEWorkspaceFile, in editor: Editor? = nil) { + /// - asTemporary: Indicates whether the tab should be opened as a temporary tab or a permanent tab. + func openTab(item: CEWorkspaceFile, in editor: Editor? = nil, asTemporary: Bool = false) { let editor = editor ?? activeEditor - editor.openTab(file: item, asTemporary: false) + editor.openTab(file: item, asTemporary: asTemporary) } /// bind active tap group to listen to file selection changes. diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorChangedFileView.swift b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorChangedFileView.swift deleted file mode 100644 index eb527a064..000000000 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorChangedFileView.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// SourceControlNavigatorChangedFileView.swift -// CodeEdit -// -// Created by Nanashi Li on 2022/05/20. -// - -import SwiftUI - -struct SourceControlNavigatorChangedFileView: View { - @AppSettings(\.general.fileIconStyle) - var fileIconStyle - - @EnvironmentObject var sourceControlManager: SourceControlManager - @Binding var changedFile: CEWorkspaceFile - @State var staged: Bool - - init(changedFile: Binding) { - self._changedFile = changedFile - _staged = State(initialValue: changedFile.wrappedValue.staged ?? false) - } - - var folder: String? { - let rootPath = sourceControlManager.gitClient.directoryURL.relativePath - let filePath = changedFile.url.relativePath - - // Should not happen, but just in case - if !filePath.hasPrefix(rootPath) { - return nil - } - - let relativePath = filePath - .dropFirst(rootPath.count + 1) // Drop root folder - .dropLast(changedFile.name.count + 1) // Drop file name - return relativePath.isEmpty ? nil : "\(relativePath)/" - } - - var body: some View { - HStack(spacing: 6) { - Toggle("", isOn: $staged) - .labelsHidden() - .onChange(of: staged) { newStaged in - Task { - if changedFile.staged != newStaged { - if newStaged { - try await sourceControlManager.add([changedFile]) - } else { - try await sourceControlManager.reset([changedFile]) - } - } - } - } - Label(title: { - Text(changedFile.name) - .lineLimit(1) - .truncationMode(.middle) - }, icon: { - Image(nsImage: changedFile.nsIcon) - .foregroundStyle(fileIconStyle == .color ? changedFile.iconColor : Color("CoolGray")) - }) - - Spacer() - Text(changedFile.gitStatus?.description ?? "") - .font(.system(size: 11, weight: .bold)) - .foregroundColor(.secondary) - .frame(minWidth: 10, alignment: .center) - } - .help("\(folder ?? "")\(changedFile.name)") - .onChange(of: changedFile.staged) { newStaged in - staged = newStaged ?? false - } - } -} diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorChangesCommitView.swift b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorChangesCommitView.swift index 40201fa29..acfa152ac 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorChangesCommitView.swift +++ b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorChangesCommitView.swift @@ -16,11 +16,11 @@ struct SourceControlNavigatorChangesCommitView: View { @State private var isCommiting: Bool = false var allFilesStaged: Bool { - sourceControlManager.changedFiles.allSatisfy { $0.staged ?? false } + sourceControlManager.changedFiles.allSatisfy { $0.isStaged } } var anyFilesStaged: Bool { - sourceControlManager.changedFiles.contains { $0.staged ?? false } + sourceControlManager.changedFiles.contains { $0.isStaged } } var body: some View { @@ -73,9 +73,9 @@ struct SourceControlNavigatorChangesCommitView: View { Button { Task { if allFilesStaged { - try await sourceControlManager.reset(sourceControlManager.changedFiles) + await resetAll() } else { - try await sourceControlManager.add(sourceControlManager.changedFiles) + await stageAll() } } } label: { @@ -133,4 +133,26 @@ struct SourceControlNavigatorChangesCommitView: View { } } } + + /// Stages all changed files. + private func stageAll() async { + do { + try await sourceControlManager.add(sourceControlManager.changedFiles.compactMap { + $0.stagedStatus == .none ? $0.fileURL : nil + }) + } catch { + sourceControlManager.logger.error("Failed to stage all files: \(error)") + } + } + + /// Resets all changed files. + private func resetAll() async { + do { + try await sourceControlManager.reset( + sourceControlManager.changedFiles.map { $0.fileURL } + ) + } catch { + sourceControlManager.logger.error("Failed to reset all files: \(error)") + } + } } diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorChangesList.swift b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorChangesList.swift index 3adf7b835..7872d0da2 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorChangesList.swift +++ b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Changes/Views/SourceControlNavigatorChangesList.swift @@ -5,30 +5,31 @@ // Created by Austin Condiff on 11/18/23. // +import AppKit import SwiftUI struct SourceControlNavigatorChangesList: View { @EnvironmentObject var workspace: WorkspaceDocument @EnvironmentObject var sourceControlManager: SourceControlManager - @State var selection = Set() + @State var selection = Set() var body: some View { - List($sourceControlManager.changedFiles, id: \.self, selection: $selection) { $file in - SourceControlNavigatorChangedFileView(changedFile: $file) + List($sourceControlManager.changedFiles, selection: $selection) { $file in + GitChangedFileListView(changedFile: $file) .listRowSeparator(.hidden) .padding(.vertical, -1) + .tag($file.wrappedValue) } .environment(\.defaultMinListRowHeight, 22) .contextMenu( - forSelectionType: CEWorkspaceFile.self, + forSelectionType: GitChangedFile.self, menu: { selectedFiles in - if !selectedFiles.isEmpty, - selectedFiles.count == 1, + if selectedFiles.count == 1, let file = selectedFiles.first { Group { Button("View in Finder") { - file.showInFinder() + NSWorkspace.shared.activateFileViewerSelecting([file.fileURL.absoluteURL]) } Button("Reveal in Project Navigator") {} .disabled(true) // TODO: Implementation Needed @@ -36,18 +37,16 @@ struct SourceControlNavigatorChangesList: View { } Group { Button("Open in New Tab") { - DispatchQueue.main.async { - workspace.editorManager?.openTab(item: file) - } + openGitFile(file) } Button("Open in New Window") {} .disabled(true) // TODO: Implementation Needed } - if file.gitStatus == .modified { + if file.anyStatus() != .none { Group { Divider() - Button("Discard Changes in \(file.name)...") { - sourceControlManager.discardChanges(for: file) + Button("Discard Changes in \(file.fileURL.lastPathComponent)...") { + sourceControlManager.discardChanges(for: file.fileURL) } Divider() } @@ -58,23 +57,26 @@ struct SourceControlNavigatorChangesList: View { }, // double-click action primaryAction: { selectedFiles in - if !selectedFiles.isEmpty, - selectedFiles.count == 1, - let file = selection.first { - DispatchQueue.main.async { - workspace.editorManager?.openTab(item: file) - } + if selectedFiles.count == 1, + let file = selectedFiles.first { + openGitFile(file) } } ) .onChange(of: selection) { newSelection in - if !newSelection.isEmpty, - newSelection.count == 1, + if newSelection.count == 1, let file = newSelection.first { - DispatchQueue.main.async { - workspace.editorManager?.openTab(item: file) - } + openGitFile(file) } } } + + private func openGitFile(_ file: GitChangedFile) { + guard let ceFile = workspace.workspaceFileManager?.getFile(file.ceFileKey, createIfNotFound: true) else { + return + } + DispatchQueue.main.async { + workspace.editorManager?.openTab(item: ceFile, asTemporary: true) + } + } } diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/CommitChangedFileListItemView.swift b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/CommitChangedFileListItemView.swift deleted file mode 100644 index d8f26c706..000000000 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/CommitChangedFileListItemView.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// CommitChangedFileListItemView.swift -// CodeEdit -// -// Created by Austin Condiff on 12/27/23. -// - -import SwiftUI - -struct CommitChangedFileListItemView: View { - @AppSettings(\.general.fileIconStyle) - var fileIconStyle - - @EnvironmentObject var sourceControlManager: SourceControlManager - - @Binding var changedFile: CEWorkspaceFile - - var folder: String? { - let rootPath = sourceControlManager.gitClient.directoryURL.relativePath - let filePath = changedFile.url.relativePath - - // Should not happen, but just in case - if !filePath.hasPrefix(rootPath) { - return nil - } - - let relativePath = filePath - .dropFirst(rootPath.count + 1) // Drop root folder - .dropLast(changedFile.name.count + 1) // Drop file name - return relativePath.isEmpty ? nil : "\(relativePath)/" - } - - var body: some View { - HStack(spacing: 6) { - Label(title: { - Text(changedFile.name) - .lineLimit(1) - .truncationMode(.middle) - }, icon: { - Image(nsImage: changedFile.nsIcon) - .foregroundStyle(fileIconStyle == .color ? changedFile.iconColor : Color("CoolGray")) - }) - - Spacer() - Text(changedFile.gitStatus?.description ?? "") - .font(.system(size: 11, weight: .bold)) - .foregroundColor(.secondary) - .frame(minWidth: 10, alignment: .center) - } - .help("\(folder ?? "")\(changedFile.name)") - } -} diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/CommitDetailsView.swift b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/CommitDetailsView.swift index b64b81003..ed63905c1 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/CommitDetailsView.swift +++ b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/CommitDetailsView.swift @@ -12,7 +12,7 @@ struct CommitDetailsView: View { @Binding var commit: GitCommit? - @State var commitChanges: [CEWorkspaceFile] = [] + @State var commitChanges: [GitChangedFile] = [] @State var selection: CEWorkspaceFile? @@ -46,12 +46,12 @@ struct CommitDetailsView: View { if !commitChanges.isEmpty { List(selection: $selection) { - ForEach($commitChanges, id: \.self) { $file in - CommitChangedFileListItemView(changedFile: $file) - .fixedSize(horizontal: false, vertical: true) - .listRowSeparator(.hidden) - .padding(.vertical, -1) - } + ForEach($commitChanges, id: \.self) { $file in + GitChangedFileListView(changedFile: $file, showStaged: false) + .fixedSize(horizontal: false, vertical: true) + .listRowSeparator(.hidden) + .padding(.vertical, -1) + } } .environment(\.defaultMinListRowHeight, 22) diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/SourceControlNavigatorHistoryView.swift b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/SourceControlNavigatorHistoryView.swift index c0fac857a..c3a429923 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/SourceControlNavigatorHistoryView.swift +++ b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/SourceControlNavigatorHistoryView.swift @@ -34,6 +34,7 @@ struct SourceControlNavigatorHistoryView: View { commitHistoryStatus = .ready } } catch { + sourceControlManager.logger.log("Failed to load commit history: \(error)") await MainActor.run { commitHistory = [] commitHistoryStatus = .error(error: error) diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/ChangedFile/GitChangedFileLabel.swift b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/ChangedFile/GitChangedFileLabel.swift new file mode 100644 index 000000000..02a8769b7 --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/ChangedFile/GitChangedFileLabel.swift @@ -0,0 +1,53 @@ +// +// GitChangedFileLabel.swift +// CodeEdit +// +// Created by Khan Winter on 8/23/24. +// + +import SwiftUI + +struct GitChangedFileLabel: View { + @EnvironmentObject private var workspace: WorkspaceDocument + @EnvironmentObject private var sourceControlManager: SourceControlManager + + let file: GitChangedFile + + var body: some View { + Label { + Text(file.fileURL.lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines)) + .lineLimit(1) + .truncationMode(.middle) + } icon: { + if let ceFile = workspace.workspaceFileManager?.getFile(file.ceFileKey, createIfNotFound: true) { + Image(nsImage: ceFile.nsIcon) + .renderingMode(.template) + } else { + Image(systemName: FileIcon.fileIcon(fileType: nil)) + .renderingMode(.template) + } + } + } +} + +#Preview { + Group { + GitChangedFileLabel(file: GitChangedFile( + status: .modified, + stagedStatus: .none, + fileURL: URL(filePath: "/Users/CodeEdit/app.jsx"), + originalFilename: nil + )) + .environmentObject(SourceControlManager(workspaceURL: URL(filePath: "/Users/CodeEdit"), editorManager: .init())) + .environmentObject(WorkspaceDocument()) + + GitChangedFileLabel(file: GitChangedFile( + status: .none, + stagedStatus: .renamed, + fileURL: URL(filePath: "/Users/CodeEdit/app.jsx"), + originalFilename: "app2.jsx" + )) + .environmentObject(SourceControlManager(workspaceURL: URL(filePath: "/Users/CodeEdit"), editorManager: .init())) + .environmentObject(WorkspaceDocument()) + }.padding() +} diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/ChangedFile/GitChangedFileListView.swift b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/ChangedFile/GitChangedFileListView.swift new file mode 100644 index 000000000..a17e606ba --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/ChangedFile/GitChangedFileListView.swift @@ -0,0 +1,79 @@ +// +// GitChangedFileListView.swift +// CodeEdit +// +// Created by Nanashi Li on 2022/05/20. +// + +import SwiftUI + +/// A view to display a changed file's information in a list view. Optionally displays the staged status. +struct GitChangedFileListView: View { + @AppSettings(\.general.fileIconStyle) + private var fileIconStyle + @EnvironmentObject private var workspace: WorkspaceDocument + @EnvironmentObject private var sourceControlManager: SourceControlManager + @Binding private var changedFile: GitChangedFile + + @State private var staged: Bool + private let showStaged: Bool + + init(changedFile: Binding, showStaged: Bool = true) { + self._changedFile = changedFile + self.showStaged = showStaged + self._staged = State(initialValue: changedFile.wrappedValue.isStaged) + } + + var body: some View { + HStack(spacing: 6) { + if showStaged { + Toggle("", isOn: $staged) + .labelsHidden() + .onChange(of: staged) { newStaged in + Task { + if changedFile.isStaged != newStaged { + if newStaged { + try await sourceControlManager.add([changedFile.fileURL]) + } else { + try await sourceControlManager.reset([changedFile.fileURL]) + } + } + } + } + } + + GitChangedFileLabel(file: changedFile) + Spacer() + Text(changedFile.anyStatus().description) + .font(.system(size: 11, weight: .bold)) + .foregroundColor(.secondary) + .frame(minWidth: 10, alignment: .center) + } + .listItemTint(listItemTint) + .help(changedFile.fileURL.relativePath) + .onChange(of: changedFile.isStaged) { newStaged in + staged = newStaged + } + } + + private var listItemTint: Color { + if let ceFile = workspace.workspaceFileManager?.getFile(changedFile.ceFileKey, createIfNotFound: true) { + iconForegroundColor(ceFile) + } else { + iconForegroundColor(nil) + } + } + + private func iconForegroundColor(_ file: CEWorkspaceFile?) -> Color { + switch fileIconStyle { + case .color: + if let file { + return file.iconColor + } else { + return FileIcon.iconColor(fileType: nil) + } + case .monochrome: + return Color("CoolGray") + } + } +} diff --git a/CodeEdit/Features/Search/Extensions/String+SafeOffset.swift b/CodeEdit/Features/Search/Extensions/String+SafeOffset.swift index a1c3ffada..ff120d91f 100644 --- a/CodeEdit/Features/Search/Extensions/String+SafeOffset.swift +++ b/CodeEdit/Features/Search/Extensions/String+SafeOffset.swift @@ -16,13 +16,13 @@ extension String { /// - offsetBy: The number (of characters) to offset from the first index. /// - limitedBy: An index to limit the offset by. /// - Returns: A `String.Index` - func safeOffset(_ idx: String.Index, offsetBy offset: Int, limitedBy: String.Index) -> String.Index { + func safeOffset(_ idx: String.Index, offsetBy offset: Int, limitedBy: String.Index) -> String.Index? { // This is the odd case this method solves. Swift's // ``String.index(_:offsetBy:limitedBy:)`` // will crash if the given index is equal to the offset, and // we try to go outside of the string's limits anyways. if idx == limitedBy { - return limitedBy + return nil } else if offset < 0 { // If the offset is going backwards, but the limit index // is ahead in the string we return the original index. @@ -32,7 +32,7 @@ extension String { // Return the index offset by the given offset. // If this index is nil we return the limit index. - return index(idx, offsetBy: offset, limitedBy: limitedBy) ?? limitedBy + return index(idx, offsetBy: offset, limitedBy: limitedBy) } else if offset > 0 { // If the offset is going forwards, but the limit index // is behind in the string we return the original index. @@ -42,7 +42,7 @@ extension String { // Return the index offset by the given offset. // If this index is nil we return the limit index. - return index(idx, offsetBy: offset, limitedBy: limitedBy) ?? limitedBy + return index(idx, offsetBy: offset, limitedBy: limitedBy) } else { // The offset is 0, so we return the limit index. return limitedBy @@ -56,7 +56,7 @@ extension String { /// - idx: The index to start at. /// - offsetBy: The number (of characters) to offset from the first index. /// - Returns: A `String.Index` - func safeOffset(_ idx: String.Index, offsetBy offset: Int) -> String.Index { + func safeOffset(_ idx: String.Index, offsetBy offset: Int) -> String.Index? { if offset < 0 { return safeOffset(idx, offsetBy: offset, limitedBy: self.startIndex) } else if offset > 0 { diff --git a/CodeEdit/Features/SourceControl/Client/GitClient+Branches.swift b/CodeEdit/Features/SourceControl/Client/GitClient+Branches.swift index 4f2cc598a..328ff8142 100644 --- a/CodeEdit/Features/SourceControl/Client/GitClient+Branches.swift +++ b/CodeEdit/Features/SourceControl/Client/GitClient+Branches.swift @@ -114,7 +114,7 @@ extension GitClient { error.description.contains("already exists") { try await checkoutBranch(branch, forceLocal: true) } else { - print(error) + logger.error("Failed to checkout branch: \(error)") } } } diff --git a/CodeEdit/Features/SourceControl/Client/GitClient+Commit.swift b/CodeEdit/Features/SourceControl/Client/GitClient+Commit.swift index a5edc4652..9a8dc0c9a 100644 --- a/CodeEdit/Features/SourceControl/Client/GitClient+Commit.swift +++ b/CodeEdit/Features/SourceControl/Client/GitClient+Commit.swift @@ -27,14 +27,15 @@ extension GitClient { /// Add file to git /// - Parameter file: File to add - func add(_ files: [CEWorkspaceFile]) async throws { - _ = try await run("add \(files.map { $0.url.relativePath }.joined(separator: " "))") + func add(_ files: [URL]) async throws { + let output = try await run("add \(files.map { "'\($0.path(percentEncoded: false))'" }.joined(separator: " "))") + print(output) } /// Add file to git /// - Parameter file: File to add - func reset(_ files: [CEWorkspaceFile]) async throws { - _ = try await run("reset \(files.map { $0.url.relativePath }.joined(separator: " "))") + func reset(_ files: [URL]) async throws { + _ = try await run("reset \(files.map { "'\($0.path(percentEncoded: false))'" }.joined(separator: " "))") } /// Returns tuple of unsynced commits both ahead and behind @@ -49,7 +50,7 @@ extension GitClient { let data = output .trimmingCharacters(in: .newlines) .components(separatedBy: "\n") - return try data.compactMap { line in + return try data.compactMap { line -> GitChangedFile? in let components = line.split(separator: "\t") guard components.count == 2 else { return nil } let changeType = String(components[0]) @@ -59,12 +60,14 @@ extension GitClient { throw GitClientError.failedToDecodeURL } - let gitType: GitType? = .init(rawValue: changeType) + let gitType: GitStatus? = .init(rawValue: changeType) let fullLink = self.directoryURL.appending(path: url.relativePath) return GitChangedFile( - changeType: gitType, - fileLink: fullLink + status: gitType ?? .none, + stagedStatus: .none, + fileURL: fullLink, + originalFilename: nil ) } } catch { diff --git a/CodeEdit/Features/SourceControl/Client/GitClient+CommitHistory.swift b/CodeEdit/Features/SourceControl/Client/GitClient+CommitHistory.swift index f2522ba1b..574e65034 100644 --- a/CodeEdit/Features/SourceControl/Client/GitClient+CommitHistory.swift +++ b/CodeEdit/Features/SourceControl/Client/GitClient+CommitHistory.swift @@ -19,11 +19,10 @@ extension GitClient { maxCount: Int? = nil, fileLocalPath: String? = nil ) async throws -> [GitCommit] { - var branchNameString = "" - var maxCountString = "" - let fileLocalPath = fileLocalPath?.escapedWhiteSpaces() ?? "" - if let branchName { branchNameString = "--first-parent \(branchName)" } - if let maxCount { maxCountString = "-n \(maxCount)" } + let branchString = branchName != nil ? "\"\(branchName ?? "")\"" : "" + let fileString = fileLocalPath != nil ? "\"\(fileLocalPath ?? "")\"" : "" + let countString = maxCount != nil ? "-n \(maxCount ?? 0)" : "" + let dateFormatter = DateFormatter() // Can't use `Locale.current`, since it'd give a nil date outside the US @@ -31,7 +30,7 @@ extension GitClient { dateFormatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" let output = try await run( - "log -z --pretty=%h¦%H¦%s¦%aN¦%ae¦%cn¦%ce¦%aD¦%b¦%D¦ \(maxCountString) \(branchNameString) \(fileLocalPath)" + "log -z --pretty=%h¦%H¦%s¦%aN¦%ae¦%cn¦%ce¦%aD¦%b¦%D¦ \(countString) \(branchString) -- \(fileString)" .trimmingCharacters(in: .whitespacesAndNewlines) ) let remoteURL = try await getRemoteURL() diff --git a/CodeEdit/Features/SourceControl/Client/GitClient+Status.swift b/CodeEdit/Features/SourceControl/Client/GitClient+Status.swift index 8380351a9..eaef31cfa 100644 --- a/CodeEdit/Features/SourceControl/Client/GitClient+Status.swift +++ b/CodeEdit/Features/SourceControl/Client/GitClient+Status.swift @@ -7,65 +7,209 @@ import Foundation +/// Methods for parsing git's porcelain v2 format and returning the info in a ``GitClient/Status`` struct. +/// +/// Git defines five types of changes to parse in the v2 format: +/// - Ordinary +/// - Renamed/Copied +/// - Unmerged +/// - Untracked +/// - Ignored +/// +/// These are documented here: https://git-scm.com/docs/git-status. +/// +/// There is one method for each change type that can be returned with the exception of ignored which is, well, ignored. +/// +/// # TODO: +/// In the future, this method should return information about push/pull status and stash status, as that +/// information can be included in the same call. + extension GitClient { - /// Get changed files - func getChangedFiles() async throws -> [GitChangedFile] { - let output = try await run("status -s --porcelain -u") + struct Status { + var changedFiles: [GitChangedFile] + var unmergedChanges: [GitChangedFile] + var untrackedFiles: [GitChangedFile] + } - return try output - .split(whereSeparator: \.isNewline) - .map { line -> GitChangedFile in - let paramData = line.trimmingCharacters(in: .whitespacesAndNewlines) - let parameters = paramData.components(separatedBy: " ") + /// Fetches and parses the git repository's status. + /// - Returns: A ``GitClient/Status`` struct with information about the changed files in the repository. + /// - Throws: Can throw ``GitClient/GitClientError`` errors if it finds unexpected output. + func getStatus() async throws -> Status { + let output = try await run("status -z --porcelain=2 -u") - let urlIndex = parameters.count > 2 ? 2 : 1 + var status = Status(changedFiles: [], unmergedChanges: [], untrackedFiles: []) - guard let url = URL(string: parameters[safe: urlIndex] ?? String(describing: URLError.badURL)) else { - throw GitClientError.failedToDecodeURL - } + var index = output.startIndex + while index < output.endIndex { + let typeIndex = index - let gitType: GitType? = .init(rawValue: parameters[safe: 0] ?? "") - let fullLink = self.directoryURL.appending(path: url.relativePath) + // Move ahead no matter what. + guard let nextIndex = output.safeOffset(index, offsetBy: 2) else { + throw GitClientError.statusParseEarlyEnd + } + index = nextIndex - return GitChangedFile( - changeType: gitType, - fileLink: fullLink - ) + switch output[typeIndex] { + case "1": // Ordinary changes + status.changedFiles.append(try parseOrdinary(index: &index, output: output)) + case "2": // Renamed or copied changes + status.changedFiles.append(try parseRenamed(index: &index, output: output)) + case "u": // Unmerged changes + status.unmergedChanges.append(try parseUnmerged(index: &index, output: output)) + case "?": // Untracked files + status.untrackedFiles.append(try parseUntracked(index: &index, output: output)) + case "!", "#": // Ignored files or Header + try substringToNextNull(from: &index, output: output) // move the index to the next line. + default: + throw GitClientError.statusInvalidChangeType(output[typeIndex]) } + } + + return status } - /// Get staged files - func getStagedFiles() async throws -> [GitChangedFile] { - let output = try await run("diff --name-status --cached") + /// Discard changes for file + func discardChanges(for file: URL) async throws { + _ = try await run("restore '\(file.path(percentEncoded: false))'") + } - return try output - .split(whereSeparator: \.isNewline) - .map { line -> GitChangedFile in - let paramData = line.trimmingCharacters(in: .whitespacesAndNewlines) - let parameters = paramData.components(separatedBy: "\t") - let urlIndex = parameters.count > 2 ? 2 : 1 + /// Discard unstaged changes + func discardAllChanges() async throws { + _ = try await run("restore .") + } - guard let url = URL(string: parameters[safe: urlIndex] ?? String(describing: URLError.badURL)) else { - throw GitClientError.failedToDecodeURL - } + // MARK: - Parsing Helpers - let gitType: GitType? = .init(rawValue: parameters[safe: 0] ?? "") - let fullLink = self.directoryURL.appending(path: url.relativePath) + // Note for the following methods we make extensive use of the `borrowing` parameter modifier to avoid + // ever copying the output. If changes are made to these methods, ensure this invariant is maintained for + // performance. - return GitChangedFile( - changeType: gitType, - fileLink: fullLink - ) + /// Finds the substring up until the next null character. Does not include the null char. + /// - Parameters: + /// - index: The current index. Modified to be after the null char. + /// - output: The string from the git command, borrowed. + /// - Returns: A substring with the contents of the string up until a null char. + /// - Throws: Throws a `GitClientError` if the end of the string is found early. + @discardableResult + fileprivate func substringToNextNull(from index: inout String.Index, output: borrowing String) throws -> Substring { + let startIndex = index + while output[index] != Character(UnicodeScalar(0)) { + let newIndex = output.index(after: index) + guard newIndex < output.endIndex else { + throw GitClientError.statusParseEarlyEnd } + index = newIndex + } + index = output.index(after: index) + return output[startIndex.. GitStatus { + guard let status = GitStatus(rawValue: String(output[index])) else { + throw GitClientError.invalidStatus(output[index]) + } + index = output.index(after: index) + return status + } + + // MARK: - Change Type Parsers + + /// Parses an ordinary change. + /// ``` + /// 1 + /// ``` + fileprivate func parseOrdinary(index: inout String.Index, output: borrowing String) throws -> GitChangedFile { + let stagedStatus = try parseStatus(index: &index, output: output) + let status = try parseStatus(index: &index, output: output) + // don't care about fields + for _ in 0..<6 { + try moveToNextSpace(from: &index, output: output) + } + try moveOneChar(from: &index, output: output) + let filename = String(try substringToNextNull(from: &index, output: output)) + return GitChangedFile( + status: status, + stagedStatus: stagedStatus, + fileURL: URL(filePath: filename, relativeTo: directoryURL), + originalFilename: nil + ) + } + + /// Parses a renamed or copied change. + /// ``` + /// 2 + /// ``` + fileprivate func parseRenamed(index: inout String.Index, output: borrowing String) throws -> GitChangedFile { + let stagedStatus = try parseStatus(index: &index, output: output) + let status = try parseStatus(index: &index, output: output) + // don't care about fields + for _ in 0..<7 { + try moveToNextSpace(from: &index, output: output) + } + try moveOneChar(from: &index, output: output) + let filename = String(try substringToNextNull(from: &index, output: output)) + let originalFilename = String(try substringToNextNull(from: &index, output: output)) + return GitChangedFile( + status: status, + stagedStatus: stagedStatus, + fileURL: URL(filePath: filename, relativeTo: directoryURL), + originalFilename: originalFilename + ) + } + + /// Parses an unmerged change. + /// ``` + /// u

+ /// ``` + fileprivate func parseUnmerged(index: inout String.Index, output: borrowing String) throws -> GitChangedFile { + let stagedStatus = try parseStatus(index: &index, output: output) + let status = try parseStatus(index: &index, output: output) + // don't care about fields + for _ in 0..<8 { + try moveToNextSpace(from: &index, output: output) + } + try moveOneChar(from: &index, output: output) + let filename = String(try substringToNextNull(from: &index, output: output)) + return GitChangedFile( + status: status, + stagedStatus: stagedStatus, + fileURL: URL(filePath: filename, relativeTo: directoryURL), + originalFilename: nil + ) + } + + /// Parses an untracked change. + /// ``` + /// ? + /// ``` + fileprivate func parseUntracked(index: inout String.Index, output: borrowing String) throws -> GitChangedFile { + let filename = String(try substringToNextNull(from: &index, output: output)) + return GitChangedFile( + status: .untracked, + stagedStatus: .none, + fileURL: URL(filePath: filename, relativeTo: directoryURL), + originalFilename: nil + ) } } diff --git a/CodeEdit/Features/SourceControl/Client/GitClient.swift b/CodeEdit/Features/SourceControl/Client/GitClient.swift index 730d83e0a..6dcf210c7 100644 --- a/CodeEdit/Features/SourceControl/Client/GitClient.swift +++ b/CodeEdit/Features/SourceControl/Client/GitClient.swift @@ -7,6 +7,7 @@ import Combine import Foundation +import OSLog class GitClient { enum GitClientError: Error { @@ -14,6 +15,10 @@ class GitClient { case notGitRepository case failedToDecodeURL case noRemoteConfigured + // Status parsing + case statusParseEarlyEnd + case invalidStatus(_ char: Character) + case statusInvalidChangeType(_ type: Character) var description: String { switch self { @@ -21,10 +26,15 @@ class GitClient { case .notGitRepository: "Not a git repository" case .failedToDecodeURL: "Failed to decode URL" case .noRemoteConfigured: "No remote configured" + case .statusParseEarlyEnd: "Invalid status, found end of string too early" + case let .invalidStatus(char): "Invalid status received: \(char)" + case let .statusInvalidChangeType(char): "Status invalid change type: \(char)" } } } + let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "GitClient") + internal let directoryURL: URL internal let shellClient: ShellClient diff --git a/CodeEdit/Features/SourceControl/Models/GitChangedFile.swift b/CodeEdit/Features/SourceControl/Models/GitChangedFile.swift index 9c86d5e41..641b8d337 100644 --- a/CodeEdit/Features/SourceControl/Models/GitChangedFile.swift +++ b/CodeEdit/Features/SourceControl/Models/GitChangedFile.swift @@ -8,10 +8,40 @@ import Foundation import SwiftUI -struct GitChangedFile { - /// Change type is to tell us whether the type is a new file, modified or deleted - let changeType: GitType? +/// Represents a single changed file in the working tree. +struct GitChangedFile: Identifiable, Hashable { + var id: String { fileURL.relativePath } - /// Link of the file - let fileLink: URL + /// The status of the file. + let status: GitStatus + /// The staged status of the file. A non-`none` value here and in ``status`` may indicate a file that was added + /// but has since been changed and needs to be re-added before committing. + let stagedStatus: GitStatus + + /// URL of the file + let fileURL: URL + + /// The original file name if ``status`` or ``stagedStatus`` is `renamed` or `copied` + let originalFilename: String? + + /// Returns the user-facing status, if ``status`` is `none`, returns ``stagedStatus``. + func anyStatus() -> GitStatus { + if case .none = status { + return stagedStatus + } + return status + } + + var isStaged: Bool { + stagedStatus != .none + } + + /// Use this string to find matching `CEWorkspaceFile`s in the workspace file manager. + var ceFileKey: String { + fileURL.absoluteURL.path(percentEncoded: false) + } + + func hash(into hasher: inout Hasher) { + hasher.combine(fileURL) + } } diff --git a/CodeEdit/Features/SourceControl/Models/GitType.swift b/CodeEdit/Features/SourceControl/Models/GitStatus.swift similarity index 76% rename from CodeEdit/Features/SourceControl/Models/GitType.swift rename to CodeEdit/Features/SourceControl/Models/GitStatus.swift index 85216fa99..a5904cae8 100644 --- a/CodeEdit/Features/SourceControl/Models/GitType.swift +++ b/CodeEdit/Features/SourceControl/Models/GitStatus.swift @@ -7,15 +7,16 @@ import Foundation -enum GitType: String, Codable { +enum GitStatus: String, Codable { + case none = "." case modified = "M" - case untracked = "??" + case untracked = "?" case fileTypeChange = "T" case added = "A" case deleted = "D" case renamed = "R" case copied = "C" - case updatedUnmerged = "U" + case unmerged = "U" var description: String { switch self { @@ -26,7 +27,8 @@ enum GitType: String, Codable { case .deleted: return "D" case .renamed: return "R" case .copied: return "C" - case .updatedUnmerged: return "U" + case .unmerged: return "U" + case .none: return "" } } } diff --git a/CodeEdit/Features/SourceControl/SourceControlManager+GitClient.swift b/CodeEdit/Features/SourceControl/SourceControlManager+GitClient.swift index 0c6b6d990..fe9af2d7c 100644 --- a/CodeEdit/Features/SourceControl/SourceControlManager+GitClient.swift +++ b/CodeEdit/Features/SourceControl/SourceControlManager+GitClient.swift @@ -95,13 +95,14 @@ extension SourceControlManager { } /// Discard changes for file - func discardChanges(for file: CEWorkspaceFile) { + func discardChanges(for file: URL) { Task { do { - try await gitClient.discardChanges(for: file.url) + try await gitClient.discardChanges(for: file) // TODO: Refresh content of active and unmodified document, // requires CodeEditSourceEditor changes } catch { + logger.error("Failed to discard changes for file (\(file.lastPathComponent): \(error)") await showAlertForError(title: "Failed to discard changes", error: error) } } @@ -115,6 +116,7 @@ extension SourceControlManager { // TODO: Refresh content of active and unmodified document, // requires CodeEditSourceEditor changes } catch { + logger.error("Failed to discard changes: \(error)") await showAlertForError(title: "Failed to discard changes", error: error) } } @@ -122,7 +124,7 @@ extension SourceControlManager { /// Set changed files on main actor @MainActor - private func setChangedFiles(_ files: [CEWorkspaceFile]) { + private func setChangedFiles(_ files: [GitChangedFile]) { self.changedFiles = files } @@ -136,14 +138,15 @@ extension SourceControlManager { var updatedStatusFor: Set = [] // Refresh status of file manager files for changedFile in changedFiles { - guard let file = fileManager.flattenedFileItems[changedFile.id] else { + guard let file = fileManager.getFile(changedFile.ceFileKey) else { continue } - if file.gitStatus != changedFile.gitStatus { - file.gitStatus = changedFile.gitStatus - updatedStatusFor.insert(file) + if file.gitStatus != changedFile.anyStatus() { + file.gitStatus = changedFile.anyStatus() } + updatedStatusFor.insert(file) } + for (_, file) in fileManager.flattenedFileItems where !updatedStatusFor.contains(file) && file.gitStatus != nil { file.gitStatus = nil @@ -160,50 +163,25 @@ extension SourceControlManager { /// Refresh all changed files and refresh status in file manager func refreshAllChangedFiles() async { do { - var fileDictionary = [URL: CEWorkspaceFile]() - - // Process changed files - for item in try await gitClient.getChangedFiles() { - fileDictionary[item.fileLink] = CEWorkspaceFile( - url: item.fileLink, - changeType: item.changeType, - staged: false - ) - } - - // Update staged status - for item in try await gitClient.getStagedFiles() { - fileDictionary[item.fileLink]?.staged = true - } + let status = try await gitClient.getStatus() - // TODO: Profile - let changedFiles = Array(fileDictionary.values.sorted()) + // TODO: Unmerged changes + // status.unmergedChanges - await setChangedFiles(changedFiles) + await setChangedFiles(status.changedFiles + status.untrackedFiles) await refreshStatusInFileManager() } catch { + logger.error("Error fetching git status: \(error)") await setChangedFiles([]) } } /// Get all changed files for a commit - func getCommitChangedFiles(commitSHA: String) async -> [CEWorkspaceFile] { + func getCommitChangedFiles(commitSHA: String) async -> [GitChangedFile] { do { - var fileDictionary = [URL: CEWorkspaceFile]() - - // Process changed files - for item in try await gitClient.getCommitChangedFiles(commitSHA: commitSHA) { - fileDictionary[item.fileLink] = CEWorkspaceFile( - url: item.fileLink, - changeType: item.changeType - ) - } - - // TODO: Profile - let changedFiles = Array(fileDictionary.values.sorted()) - - return changedFiles + return try await gitClient.getCommitChangedFiles(commitSHA: commitSHA) } catch { + logger.error("Error committing changed files: \(error)") return [] } } @@ -216,11 +194,15 @@ extension SourceControlManager { await self.refreshNumberOfUnsyncedCommits() } - func add(_ files: [CEWorkspaceFile]) async throws { + /// Adds the given URLs to the staged changes. + /// - Parameter files: The files to stage. + func add(_ files: [URL]) async throws { try await gitClient.add(files) } - func reset(_ files: [CEWorkspaceFile]) async throws { + /// Removes the given URLs from the staged changes. + /// - Parameter files: The URLs to un-stage. + func reset(_ files: [URL]) async throws { try await gitClient.reset(files) } diff --git a/CodeEdit/Features/SourceControl/SourceControlManager.swift b/CodeEdit/Features/SourceControl/SourceControlManager.swift index 1eedc0de8..9a42cf7f1 100644 --- a/CodeEdit/Features/SourceControl/SourceControlManager.swift +++ b/CodeEdit/Features/SourceControl/SourceControlManager.swift @@ -7,10 +7,13 @@ import Foundation import AppKit +import OSLog /// This class is used to perform git functions such as fetch, pull, add/remove of changes, commit, push, etc. /// It also stores remotes, branches, current changes, stashes, and commits final class SourceControlManager: ObservableObject { + let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "SourceControlManager") + let gitClient: GitClient /// The base URL of the workspace @@ -20,7 +23,7 @@ final class SourceControlManager: ObservableObject { weak var fileManager: CEWorkspaceFileManager? /// A list of changed files - @Published var changedFiles: [CEWorkspaceFile] = [] + @Published var changedFiles: [GitChangedFile] = [] /// Current branch @Published var currentBranch: GitBranch? diff --git a/CodeEdit/Features/WindowCommands/SourceControlCommands.swift b/CodeEdit/Features/WindowCommands/SourceControlCommands.swift index 11724b6da..6bf494f41 100644 --- a/CodeEdit/Features/WindowCommands/SourceControlCommands.swift +++ b/CodeEdit/Features/WindowCommands/SourceControlCommands.swift @@ -46,7 +46,7 @@ struct SourceControlCommands: Commands { } else { Task { do { - try await sourceControlManager.add(sourceControlManager.changedFiles) + try await sourceControlManager.add(sourceControlManager.changedFiles.map { $0.fileURL }) } catch { await sourceControlManager.showAlertForError( title: "Failed To Stage Changes", @@ -64,7 +64,9 @@ struct SourceControlCommands: Commands { } else { Task { do { - try await sourceControlManager.reset(sourceControlManager.changedFiles) + try await sourceControlManager.reset( + sourceControlManager.changedFiles.map { $0.fileURL } + ) } catch { await sourceControlManager.showAlertForError( title: "Failed To Unstage Changes",