From 25bfeb25fd54948c667d2e1e8a48f4ff54a1ffdc Mon Sep 17 00:00:00 2001 From: avinizhanov <42622715+avinizhanov@users.noreply.github.com> Date: Mon, 30 Oct 2023 20:00:16 -0500 Subject: [PATCH] Git Improvements (#1458) * git improvements * fix error on sync * clone progress labels, comments * fixes * fix branch selector for 1 branch repo * update refreshStatusInFileManager --- CodeEdit.xcodeproj/project.pbxproj | 84 +++++-- .../Models/CEWorkspaceFileManager.swift | 19 +- .../Views/ToolbarBranchPicker.swift | 86 ++++--- .../CodeEditWindowController.swift | 1 - .../Documents/WorkspaceDocument.swift | 10 +- .../Git/Client/GitClient+Branches.swift | 96 ++++++++ .../Features/Git/Client/GitClient+Clone.swift | 102 ++++++++ .../Git/Client/GitClient+Commit.swift | 47 ++++ .../Git/Client/GitClient+CommitHistory.swift | 41 ++++ .../Features/Git/Client/GitClient+Push.swift | 24 ++ .../Git/Client/GitClient+Status.swift | 41 ++++ CodeEdit/Features/Git/Client/GitClient.swift | 176 +++----------- .../Git/Client/Models/GitBranch.swift | 24 ++ .../Git/Client/Models/GitChangedFile.swift | 49 +--- .../Features/Git/Client/Models/GitType.swift | 4 +- ...GitCheckoutBranchView+CheckoutBranch.swift | 53 ----- .../Git/Clone/GitCheckoutBranchView.swift | 52 +++-- .../Features/Git/Clone/GitCloneView.swift | 200 ++++------------ .../GitCheckoutBranchViewModel.swift | 39 ++++ .../Clone/ViewModels/GitCloneViewModel.swift | 182 +++++++++++++++ .../Features/Git/SourceControlManager.swift | 219 ++++++++++++++++++ .../HistoryInspectorModel.swift | 37 +-- .../HistoryInspectorView.swift | 19 +- .../Model/SourceControlModel.swift | 35 --- .../SourceControlNavigatorView.swift | 17 +- ...ourceControlNavigatorChangedFileView.swift | 160 ++++++++----- ...rceControlNavigatorChangesCommitView.swift | 70 ++++++ .../SourceControlNavigatorChangesView.swift | 44 ++-- .../SourceControlNavigatorSyncView.swift | 68 ++++++ ...ourceControlNavigatorBranchGroupView.swift | 35 +++ .../SourceControlNavigatorBranchView.swift | 66 ++++++ .../SourceControlNavigatorNewBranchView.swift | 54 +++++ ...urceControlNavigatorRepositoriesView.swift | 24 +- .../Features/Welcome/Views/WelcomeView.swift | 28 ++- .../Welcome/Views/WelcomeWindow.swift | 2 +- .../Welcome/Views/WelcomeWindowView.swift | 4 - .../ShellClient/Models/ShellClient.swift | 36 +++ CodeEdit/WindowSplitView.swift | 1 - .../Features/CodeEditUI/CodeEditUITests.swift | 2 - .../CEWorkspaceFileManagerTests.swift | 24 +- 40 files changed, 1624 insertions(+), 651 deletions(-) create mode 100644 CodeEdit/Features/Git/Client/GitClient+Branches.swift create mode 100644 CodeEdit/Features/Git/Client/GitClient+Clone.swift create mode 100644 CodeEdit/Features/Git/Client/GitClient+Commit.swift create mode 100644 CodeEdit/Features/Git/Client/GitClient+CommitHistory.swift create mode 100644 CodeEdit/Features/Git/Client/GitClient+Push.swift create mode 100644 CodeEdit/Features/Git/Client/GitClient+Status.swift create mode 100644 CodeEdit/Features/Git/Client/Models/GitBranch.swift delete mode 100644 CodeEdit/Features/Git/Clone/GitCheckoutBranchView+CheckoutBranch.swift create mode 100644 CodeEdit/Features/Git/Clone/ViewModels/GitCheckoutBranchViewModel.swift create mode 100644 CodeEdit/Features/Git/Clone/ViewModels/GitCloneViewModel.swift create mode 100644 CodeEdit/Features/Git/SourceControlManager.swift delete mode 100644 CodeEdit/Features/NavigatorArea/SourceControlNavigator/Model/SourceControlModel.swift create mode 100644 CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Changes/SourceControlNavigatorChangesCommitView.swift create mode 100644 CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Changes/SourceControlNavigatorSyncView.swift create mode 100644 CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Repositories/SourceControlNavigatorBranchGroupView.swift create mode 100644 CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Repositories/SourceControlNavigatorBranchView.swift create mode 100644 CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Repositories/SourceControlNavigatorNewBranchView.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index a5efabbd9..abd23b0a9 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -7,12 +7,26 @@ objects = { /* Begin PBXBuildFile section */ + 041FC6A72AE429BB00C1F65A /* SourceControlNavigatorBranchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 041FC6A62AE429BB00C1F65A /* SourceControlNavigatorBranchView.swift */; }; + 041FC6AA2AE42C9100C1F65A /* SourceControlNavigatorBranchGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 041FC6A92AE42C9100C1F65A /* SourceControlNavigatorBranchGroupView.swift */; }; + 041FC6AD2AE437CE00C1F65A /* SourceControlNavigatorNewBranchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 041FC6AC2AE437CE00C1F65A /* SourceControlNavigatorNewBranchView.swift */; }; 043BCF03281DA18A000AC47C /* WorkspaceDocument+Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 043BCF02281DA18A000AC47C /* WorkspaceDocument+Search.swift */; }; 043C321427E31FF6006AE443 /* CodeEditDocumentController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 043C321327E31FF6006AE443 /* CodeEditDocumentController.swift */; }; 043C321627E3201F006AE443 /* WorkspaceDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 043C321527E3201F006AE443 /* WorkspaceDocument.swift */; }; 04540D5E27DD08C300E91B77 /* WorkspaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B658FB3127DA9E0F00EA4DBD /* WorkspaceView.swift */; }; 04660F6A27E51E5C00477777 /* CodeEditWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04660F6927E51E5C00477777 /* CodeEditWindowController.swift */; }; 0485EB1F27E7458B00138301 /* WorkspaceCodeFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0485EB1E27E7458B00138301 /* WorkspaceCodeFileView.swift */; }; + 04BA7C0B2AE2A2D100584E1C /* GitBranch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BA7C0A2AE2A2D100584E1C /* GitBranch.swift */; }; + 04BA7C0E2AE2A76E00584E1C /* SourceControlNavigatorChangesCommitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BA7C0D2AE2A76E00584E1C /* SourceControlNavigatorChangesCommitView.swift */; }; + 04BA7C132AE2AA7300584E1C /* GitCheckoutBranchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BA7C112AE2AA7300584E1C /* GitCheckoutBranchViewModel.swift */; }; + 04BA7C142AE2AA7300584E1C /* GitCloneViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BA7C122AE2AA7300584E1C /* GitCloneViewModel.swift */; }; + 04BA7C192AE2D7C600584E1C /* GitClient+Branches.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BA7C182AE2D7C600584E1C /* GitClient+Branches.swift */; }; + 04BA7C1C2AE2D84100584E1C /* GitClient+Commit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BA7C1B2AE2D84100584E1C /* GitClient+Commit.swift */; }; + 04BA7C1E2AE2D8A000584E1C /* GitClient+Clone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BA7C1D2AE2D8A000584E1C /* GitClient+Clone.swift */; }; + 04BA7C202AE2D92B00584E1C /* GitClient+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BA7C1F2AE2D92B00584E1C /* GitClient+Status.swift */; }; + 04BA7C222AE2D95E00584E1C /* GitClient+CommitHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BA7C212AE2D95E00584E1C /* GitClient+CommitHistory.swift */; }; + 04BA7C242AE2E7CD00584E1C /* SourceControlNavigatorSyncView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BA7C232AE2E7CD00584E1C /* SourceControlNavigatorSyncView.swift */; }; + 04BA7C272AE2E9F100584E1C /* GitClient+Push.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BA7C262AE2E9F100584E1C /* GitClient+Push.swift */; }; 04BC1CDE2AD9B4B000A83EA5 /* EditorFileTabCloseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BC1CDD2AD9B4B000A83EA5 /* EditorFileTabCloseButton.swift */; }; 04C3255B2801F86400C8DA2D /* ProjectNavigatorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 285FEC6D27FE4B4A00E57D53 /* ProjectNavigatorViewController.swift */; }; 04C3255C2801F86900C8DA2D /* ProjectNavigatorMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 285FEC7127FE4EEF00E57D53 /* ProjectNavigatorMenu.swift */; }; @@ -23,7 +37,7 @@ 201169DD2837B3AC00F92B46 /* SourceControlToolbarBottom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201169DC2837B3AC00F92B46 /* SourceControlToolbarBottom.swift */; }; 201169E22837B3D800F92B46 /* SourceControlNavigatorChangesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201169E12837B3D800F92B46 /* SourceControlNavigatorChangesView.swift */; }; 201169E52837B40300F92B46 /* SourceControlNavigatorRepositoriesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201169E42837B40300F92B46 /* SourceControlNavigatorRepositoriesView.swift */; }; - 201169E72837B5CA00F92B46 /* SourceControlModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201169E62837B5CA00F92B46 /* SourceControlModel.swift */; }; + 201169E72837B5CA00F92B46 /* SourceControlManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201169E62837B5CA00F92B46 /* SourceControlManager.swift */; }; 2072FA13280D74ED00C7F8D4 /* HistoryInspectorModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2072FA12280D74ED00C7F8D4 /* HistoryInspectorModel.swift */; }; 20D839AB280DEB2900B27357 /* NoSelectionInspectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D839AA280DEB2900B27357 /* NoSelectionInspectorView.swift */; }; 20D839AE280E0CA700B27357 /* HistoryPopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D839AD280E0CA700B27357 /* HistoryPopoverView.swift */; }; @@ -127,7 +141,6 @@ 587B9DA529300ABD00AC7927 /* PressActionsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B9D8E29300ABD00AC7927 /* PressActionsModifier.swift */; }; 587B9DA629300ABD00AC7927 /* ToolbarBranchPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B9D8F29300ABD00AC7927 /* ToolbarBranchPicker.swift */; }; 587B9DA729300ABD00AC7927 /* HelpButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B9D9029300ABD00AC7927 /* HelpButton.swift */; }; - 587B9E5929301D8F00AC7927 /* GitCheckoutBranchView+CheckoutBranch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B9E0629301D8F00AC7927 /* GitCheckoutBranchView+CheckoutBranch.swift */; }; 587B9E5A29301D8F00AC7927 /* GitCloneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B9E0729301D8F00AC7927 /* GitCloneView.swift */; }; 587B9E5B29301D8F00AC7927 /* GitCheckoutBranchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B9E0829301D8F00AC7927 /* GitCheckoutBranchView.swift */; }; 587B9E5C29301D8F00AC7927 /* Parameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B9E0A29301D8F00AC7927 /* Parameters.swift */; }; @@ -477,6 +490,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 041FC6A62AE429BB00C1F65A /* SourceControlNavigatorBranchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlNavigatorBranchView.swift; sourceTree = ""; }; + 041FC6A92AE42C9100C1F65A /* SourceControlNavigatorBranchGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlNavigatorBranchGroupView.swift; sourceTree = ""; }; + 041FC6AC2AE437CE00C1F65A /* SourceControlNavigatorNewBranchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlNavigatorNewBranchView.swift; sourceTree = ""; }; 043BCF02281DA18A000AC47C /* WorkspaceDocument+Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceDocument+Search.swift"; sourceTree = ""; }; 043C321327E31FF6006AE443 /* CodeEditDocumentController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeEditDocumentController.swift; sourceTree = ""; }; 043C321527E3201F006AE443 /* WorkspaceDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceDocument.swift; sourceTree = ""; }; @@ -484,6 +500,17 @@ 04660F6927E51E5C00477777 /* CodeEditWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeEditWindowController.swift; sourceTree = ""; }; 0468438427DC76E200F8E88E /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 0485EB1E27E7458B00138301 /* WorkspaceCodeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCodeFileView.swift; sourceTree = ""; }; + 04BA7C0A2AE2A2D100584E1C /* GitBranch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitBranch.swift; sourceTree = ""; }; + 04BA7C0D2AE2A76E00584E1C /* SourceControlNavigatorChangesCommitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlNavigatorChangesCommitView.swift; sourceTree = ""; }; + 04BA7C112AE2AA7300584E1C /* GitCheckoutBranchViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitCheckoutBranchViewModel.swift; sourceTree = ""; }; + 04BA7C122AE2AA7300584E1C /* GitCloneViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitCloneViewModel.swift; sourceTree = ""; }; + 04BA7C182AE2D7C600584E1C /* GitClient+Branches.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GitClient+Branches.swift"; sourceTree = ""; }; + 04BA7C1B2AE2D84100584E1C /* GitClient+Commit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GitClient+Commit.swift"; sourceTree = ""; }; + 04BA7C1D2AE2D8A000584E1C /* GitClient+Clone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GitClient+Clone.swift"; sourceTree = ""; }; + 04BA7C1F2AE2D92B00584E1C /* GitClient+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GitClient+Status.swift"; sourceTree = ""; }; + 04BA7C212AE2D95E00584E1C /* GitClient+CommitHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GitClient+CommitHistory.swift"; sourceTree = ""; }; + 04BA7C232AE2E7CD00584E1C /* SourceControlNavigatorSyncView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlNavigatorSyncView.swift; sourceTree = ""; }; + 04BA7C262AE2E9F100584E1C /* GitClient+Push.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GitClient+Push.swift"; sourceTree = ""; }; 04BC1CDD2AD9B4B000A83EA5 /* EditorFileTabCloseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorFileTabCloseButton.swift; sourceTree = ""; }; 200412EE280F3EAC00BCAF5C /* HistoryInspectorNoHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryInspectorNoHistoryView.swift; sourceTree = ""; }; 201169D62837B2E300F92B46 /* SourceControlNavigatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlNavigatorView.swift; sourceTree = ""; }; @@ -492,7 +519,7 @@ 201169DC2837B3AC00F92B46 /* SourceControlToolbarBottom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlToolbarBottom.swift; sourceTree = ""; }; 201169E12837B3D800F92B46 /* SourceControlNavigatorChangesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlNavigatorChangesView.swift; sourceTree = ""; }; 201169E42837B40300F92B46 /* SourceControlNavigatorRepositoriesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlNavigatorRepositoriesView.swift; sourceTree = ""; }; - 201169E62837B5CA00F92B46 /* SourceControlModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlModel.swift; sourceTree = ""; }; + 201169E62837B5CA00F92B46 /* SourceControlManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlManager.swift; sourceTree = ""; }; 2072FA12280D74ED00C7F8D4 /* HistoryInspectorModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryInspectorModel.swift; sourceTree = ""; }; 20D839AA280DEB2900B27357 /* NoSelectionInspectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoSelectionInspectorView.swift; sourceTree = ""; }; 20D839AD280E0CA700B27357 /* HistoryPopoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryPopoverView.swift; sourceTree = ""; }; @@ -600,7 +627,6 @@ 587B9D8E29300ABD00AC7927 /* PressActionsModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PressActionsModifier.swift; sourceTree = ""; }; 587B9D8F29300ABD00AC7927 /* ToolbarBranchPicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ToolbarBranchPicker.swift; sourceTree = ""; }; 587B9D9029300ABD00AC7927 /* HelpButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HelpButton.swift; sourceTree = ""; }; - 587B9E0629301D8F00AC7927 /* GitCheckoutBranchView+CheckoutBranch.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "GitCheckoutBranchView+CheckoutBranch.swift"; sourceTree = ""; }; 587B9E0729301D8F00AC7927 /* GitCloneView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitCloneView.swift; sourceTree = ""; }; 587B9E0829301D8F00AC7927 /* GitCheckoutBranchView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitCheckoutBranchView.swift; sourceTree = ""; }; 587B9E0A29301D8F00AC7927 /* Parameters.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Parameters.swift; sourceTree = ""; }; @@ -939,10 +965,18 @@ path = Documents; sourceTree = ""; }; + 04BA7C102AE2AA7300584E1C /* ViewModels */ = { + isa = PBXGroup; + children = ( + 04BA7C112AE2AA7300584E1C /* GitCheckoutBranchViewModel.swift */, + 04BA7C122AE2AA7300584E1C /* GitCloneViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; 201169D52837B29600F92B46 /* SourceControlNavigator */ = { isa = PBXGroup; children = ( - 201169DF2837B3CB00F92B46 /* Model */, 201169DE2837B3C700F92B46 /* Views */, 201169D62837B2E300F92B46 /* SourceControlNavigatorView.swift */, 201169D82837B31200F92B46 /* SourceControlSearchToolbar.swift */, @@ -960,19 +994,13 @@ path = Views; sourceTree = ""; }; - 201169DF2837B3CB00F92B46 /* Model */ = { - isa = PBXGroup; - children = ( - 201169E62837B5CA00F92B46 /* SourceControlModel.swift */, - ); - path = Model; - sourceTree = ""; - }; 201169E02837B3D100F92B46 /* Changes */ = { isa = PBXGroup; children = ( 201169E12837B3D800F92B46 /* SourceControlNavigatorChangesView.swift */, 201169DA2837B34000F92B46 /* SourceControlNavigatorChangedFileView.swift */, + 04BA7C0D2AE2A76E00584E1C /* SourceControlNavigatorChangesCommitView.swift */, + 04BA7C232AE2E7CD00584E1C /* SourceControlNavigatorSyncView.swift */, ); path = Changes; sourceTree = ""; @@ -981,6 +1009,9 @@ isa = PBXGroup; children = ( 201169E42837B40300F92B46 /* SourceControlNavigatorRepositoriesView.swift */, + 041FC6A62AE429BB00C1F65A /* SourceControlNavigatorBranchView.swift */, + 041FC6A92AE42C9100C1F65A /* SourceControlNavigatorBranchGroupView.swift */, + 041FC6AC2AE437CE00C1F65A /* SourceControlNavigatorNewBranchView.swift */, ); path = Repositories; sourceTree = ""; @@ -1620,6 +1651,7 @@ 587B9E0529301D8F00AC7927 /* Clone */, 587B9E0929301D8F00AC7927 /* Accounts */, 587B9E5129301D8F00AC7927 /* Client */, + 201169E62837B5CA00F92B46 /* SourceControlManager.swift */, ); path = Git; sourceTree = ""; @@ -1627,9 +1659,9 @@ 587B9E0529301D8F00AC7927 /* Clone */ = { isa = PBXGroup; children = ( + 04BA7C102AE2AA7300584E1C /* ViewModels */, 587B9E0729301D8F00AC7927 /* GitCloneView.swift */, 587B9E0829301D8F00AC7927 /* GitCheckoutBranchView.swift */, - 587B9E0629301D8F00AC7927 /* GitCheckoutBranchView+CheckoutBranch.swift */, ); path = Clone; sourceTree = ""; @@ -1794,6 +1826,12 @@ children = ( 587B9E5229301D8F00AC7927 /* Models */, 58A5DF7F29325B5A00D1BD5D /* GitClient.swift */, + 04BA7C182AE2D7C600584E1C /* GitClient+Branches.swift */, + 04BA7C1B2AE2D84100584E1C /* GitClient+Commit.swift */, + 04BA7C1D2AE2D8A000584E1C /* GitClient+Clone.swift */, + 04BA7C1F2AE2D92B00584E1C /* GitClient+Status.swift */, + 04BA7C212AE2D95E00584E1C /* GitClient+CommitHistory.swift */, + 04BA7C262AE2E9F100584E1C /* GitClient+Push.swift */, ); path = Client; sourceTree = ""; @@ -1804,6 +1842,7 @@ 587B9E5329301D8F00AC7927 /* GitCommit.swift */, 587B9E5429301D8F00AC7927 /* GitChangedFile.swift */, 587B9E5529301D8F00AC7927 /* GitType.swift */, + 04BA7C0A2AE2A2D100584E1C /* GitBranch.swift */, ); path = Models; sourceTree = ""; @@ -2905,6 +2944,7 @@ 5879824F292E78D80085B254 /* CodeFileView.swift in Sources */, 6C0D0C6829E861B000AE4D3F /* SettingsSidebarFix.swift in Sources */, 587B9E8429301D8F00AC7927 /* GitHubUser.swift in Sources */, + 04BA7C1C2AE2D84100584E1C /* GitClient+Commit.swift in Sources */, 2072FA13280D74ED00C7F8D4 /* HistoryInspectorModel.swift in Sources */, 852E62012A5C17E500447138 /* PageAndSettings.swift in Sources */, 587B9DA029300ABD00AC7927 /* PanelDivider.swift in Sources */, @@ -2924,11 +2964,13 @@ 6C14CEB028777D3C001468FE /* FindNavigatorListViewController.swift in Sources */, 587B9E7F29301D8F00AC7927 /* GitHubUserRouter.swift in Sources */, B62AEDBC2A210DBB009A9F52 /* UtilityAreaTerminalTab.swift in Sources */, + 04BA7C142AE2AA7300584E1C /* GitCloneViewModel.swift in Sources */, B61A606129F188AB009B43F9 /* ExternalLink.swift in Sources */, 587B9E9729301D8F00AC7927 /* BitBucketAccount+Token.swift in Sources */, 587B9E7729301D8F00AC7927 /* String+PercentEncoding.swift in Sources */, 587B9E5B29301D8F00AC7927 /* GitCheckoutBranchView.swift in Sources */, 2813F93827ECC4AA00E305E4 /* FindNavigatorResultList.swift in Sources */, + 04BA7C192AE2D7C600584E1C /* GitClient+Branches.swift in Sources */, 587B9E8829301D8F00AC7927 /* GitHubFiles.swift in Sources */, 201169D92837B31200F92B46 /* SourceControlSearchToolbar.swift in Sources */, 587B9DA729300ABD00AC7927 /* HelpButton.swift in Sources */, @@ -2953,11 +2995,13 @@ 587B9E8F29301D8F00AC7927 /* BitBucketUserRouter.swift in Sources */, B66A4E5129C917D5004573B4 /* AboutWindow.swift in Sources */, 58F2EB03292FB2B0004A9BDE /* Documentation.docc in Sources */, + 04BA7C272AE2E9F100584E1C /* GitClient+Push.swift in Sources */, 2B7A583527E4BA0100D25D4E /* AppDelegate.swift in Sources */, D7012EE827E757850001E1EF /* FindNavigatorView.swift in Sources */, 58A5DF8029325B5A00D1BD5D /* GitClient.swift in Sources */, D7E201AE27E8B3C000CB86D0 /* String+Ranges.swift in Sources */, 6CE6226E2A2A1CDE0013085C /* NavigatorTab.swift in Sources */, + 041FC6AD2AE437CE00C1F65A /* SourceControlNavigatorNewBranchView.swift in Sources */, 6C48D8F72972E5F300D6D205 /* WindowObserver.swift in Sources */, 6CED16E42A3E660D000EC962 /* String+Lines.swift in Sources */, 587B9E6B29301D8F00AC7927 /* GitLabAvatarURL.swift in Sources */, @@ -2985,6 +3029,7 @@ 587B9E9829301D8F00AC7927 /* GitCommit.swift in Sources */, 6C5228B529A868BD00AC48F6 /* Environment+ContentInsets.swift in Sources */, 587B9E9429301D8F00AC7927 /* BitBucketTokenConfiguration.swift in Sources */, + 04BA7C222AE2D95E00584E1C /* GitClient+CommitHistory.swift in Sources */, 581BFB672926431000D251EC /* WelcomeWindowView.swift in Sources */, 58A5DFA329339F6400D1BD5D /* CommandManager.swift in Sources */, 58798284292ED0FB0085B254 /* TerminalEmulatorView.swift in Sources */, @@ -2992,7 +3037,7 @@ B66A4E4C29C9179B004573B4 /* CodeEditApp.swift in Sources */, 4E7F066629602E7B00BB3C12 /* CodeEditSplitViewController.swift in Sources */, 587B9E8D29301D8F00AC7927 /* GitHubAccount.swift in Sources */, - 201169E72837B5CA00F92B46 /* SourceControlModel.swift in Sources */, + 201169E72837B5CA00F92B46 /* SourceControlManager.swift in Sources */, 58822528292C280D00E83CDE /* StatusBarEncodingSelector.swift in Sources */, 6C7F37FE2A3EA6FA00217B83 /* View+focusedValue.swift in Sources */, B6C6A43029771F7100A3D28F /* EditorTabBackground.swift in Sources */, @@ -3003,6 +3048,7 @@ 587B9E8229301D8F00AC7927 /* GitHubPreviewHeader.swift in Sources */, 58F2EB02292FB2B0004A9BDE /* Loopable.swift in Sources */, 6C578D8929CD36E400DC73B2 /* Commands+ForEach.swift in Sources */, + 041FC6AA2AE42C9100C1F65A /* SourceControlNavigatorBranchGroupView.swift in Sources */, 28B8F884280FFE4600596236 /* NSTableView+Background.swift in Sources */, 6CBA0D512A1BF524002C6FAA /* SegmentedControlImproved.swift in Sources */, 58F2EB06292FB2B0004A9BDE /* KeybindingsSettings.swift in Sources */, @@ -3061,11 +3107,13 @@ 6C4104E6297C884F00F472BA /* AboutDetailView.swift in Sources */, 6C6BD6F129CD13FA00235D17 /* ExtensionDiscovery.swift in Sources */, 587B9E7A29301D8F00AC7927 /* GitHubReviewsRouter.swift in Sources */, + 04BA7C132AE2AA7300584E1C /* GitCheckoutBranchViewModel.swift in Sources */, 04540D5E27DD08C300E91B77 /* WorkspaceView.swift in Sources */, DE6F77872813625500D00A76 /* EditorTabBarDivider.swift in Sources */, 6CABB1A129C5593800340467 /* OverlayView.swift in Sources */, D7211D4327E066CE008F2ED7 /* Localized+Ex.swift in Sources */, 581BFB692926431000D251EC /* WelcomeActionView.swift in Sources */, + 041FC6A72AE429BB00C1F65A /* SourceControlNavigatorBranchView.swift in Sources */, 20D839AE280E0CA700B27357 /* HistoryPopoverView.swift in Sources */, B6E41C7029DD157F0088F9F4 /* AccountsSettingsView.swift in Sources */, 6CFF967A29BEBD2400182D6F /* ViewCommands.swift in Sources */, @@ -3119,6 +3167,7 @@ 581550D429FBD37D00684881 /* ProjectNavigatorToolbarBottom.swift in Sources */, 587B9E7E29301D8F00AC7927 /* GitHubGistRouter.swift in Sources */, B6AB09A52AAAC00F0003A3A6 /* EditorTabBarTrailingAccessories.swift in Sources */, + 04BA7C0B2AE2A2D100584E1C /* GitBranch.swift in Sources */, 6CAAF69229BCC71C00A1F48A /* (null) in Sources */, 581BFB682926431000D251EC /* WelcomeView.swift in Sources */, 6CFF967829BEBCF600182D6F /* MainCommands.swift in Sources */, @@ -3138,6 +3187,7 @@ B6E41C7C29DE2B110088F9F4 /* AccountsSettingsProviderRow.swift in Sources */, B62AEDB52A1FE295009A9F52 /* UtilityAreaDebugView.swift in Sources */, 6C049A372A49E2DB00D42923 /* DirectoryEventStream.swift in Sources */, + 04BA7C0E2AE2A76E00584E1C /* SourceControlNavigatorChangesCommitView.swift in Sources */, 6CAAF68A29BC9C2300A1F48A /* (null) in Sources */, 6C6BD6EF29CD12E900235D17 /* ExtensionManagerWindow.swift in Sources */, 6CFF967629BEBCD900182D6F /* FileCommands.swift in Sources */, @@ -3145,6 +3195,7 @@ B685DE7929CC9CCD002860C8 /* StatusBarIcon.swift in Sources */, 587B9DA629300ABD00AC7927 /* ToolbarBranchPicker.swift in Sources */, 6C6BD6F629CD145F00235D17 /* ExtensionInfo.swift in Sources */, + 04BA7C202AE2D92B00584E1C /* GitClient+Status.swift in Sources */, 58F2EB05292FB2B0004A9BDE /* Settings.swift in Sources */, 6CBD1BC62978DE53006639D5 /* Font+Caption3.swift in Sources */, 30E6D0012A6E505200A58B20 /* NavigatorSidebarViewModel.swift in Sources */, @@ -3164,7 +3215,7 @@ 587B9E6229301D8F00AC7927 /* GitLabConfiguration.swift in Sources */, 6CABB19E29C5591D00340467 /* NSTableViewWrapper.swift in Sources */, 5879821B292D92370085B254 /* SearchResultMatchModel.swift in Sources */, - 587B9E5929301D8F00AC7927 /* GitCheckoutBranchView+CheckoutBranch.swift in Sources */, + 04BA7C1E2AE2D8A000584E1C /* GitClient+Clone.swift in Sources */, 58F2EB09292FB2B0004A9BDE /* TerminalSettings.swift in Sources */, 6C578D8429CD343800DC73B2 /* ExtensionDetailView.swift in Sources */, B6A43C5D29FC4AF00027E0E0 /* CreateSSHKeyView.swift in Sources */, @@ -3229,6 +3280,7 @@ 6CFF967C29BEBD5200182D6F /* WindowCommands.swift in Sources */, 587B9E7229301D8F00AC7927 /* GitJSONPostRouter.swift in Sources */, 5878DAB0291D627C00DD95A3 /* EditorPathBarMenu.swift in Sources */, + 04BA7C242AE2E7CD00584E1C /* SourceControlNavigatorSyncView.swift in Sources */, 587B9DA529300ABD00AC7927 /* PressActionsModifier.swift in Sources */, 6C147C4029A328BC0089B630 /* SplitViewData.swift in Sources */, 587B9E9029301D8F00AC7927 /* BitBucketTokenRouter.swift in Sources */, diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift index 28e6ab95e..dfbeae192 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift @@ -48,18 +48,24 @@ final class CEWorkspaceFileManager { let folderUrl: URL let workspaceItem: CEWorkspaceFile + weak var sourceControlManager: SourceControlManager? /// Create a file manager object with a root and a set of files to ignore. /// - Parameters: /// - folderUrl: The folder to use as the root of the file manager. /// - ignoredFilesAndFolders: A set of files to ignore. These should not be paths, but rather file names /// like `.DS_Store` - init(folderUrl: URL, ignoredFilesAndFolders: Set) { + init( + folderUrl: URL, + ignoredFilesAndFolders: Set, + sourceControlManager: SourceControlManager? + ) { self.folderUrl = folderUrl self.ignoredFilesAndFolders = ignoredFilesAndFolders self.workspaceItem = CEWorkspaceFile(url: folderUrl) self.flattenedFileItems = [workspaceItem.id: workspaceItem] + self.sourceControlManager = sourceControlManager self.loadChildrenForFile(self.workspaceItem) @@ -142,6 +148,9 @@ final class CEWorkspaceFileManager { flattenedFileItems[newFileItem.id] = newFileItem } childrenMap[file.id] = children.map { $0.relativePath } + Task { + await sourceControlManager?.refresAllChangesFiles() + } } /// Creates an ordered array of all files and directories at the given file object. @@ -210,6 +219,14 @@ final class CEWorkspaceFileManager { if !files.isEmpty { self.notifyObservers(updatedItems: files) } + + // Ignore changes to .git folder + let notGitChanges = events.filter({ !$0.path.contains(".git/") }) + if !notGitChanges.isEmpty { + Task { + await self.sourceControlManager?.refresAllChangesFiles() + } + } } } diff --git a/CodeEdit/Features/CodeEditUI/Views/ToolbarBranchPicker.swift b/CodeEdit/Features/CodeEditUI/Views/ToolbarBranchPicker.swift index 3b15ccee3..fde39952c 100644 --- a/CodeEdit/Features/CodeEditUI/Views/ToolbarBranchPicker.swift +++ b/CodeEdit/Features/CodeEditUI/Views/ToolbarBranchPicker.swift @@ -7,33 +7,27 @@ import SwiftUI import CodeEditSymbols +import Combine /// A view that pops up a branch picker. struct ToolbarBranchPicker: View { private var workspaceFileManager: CEWorkspaceFileManager? - private var gitClient: GitClient? + private var sourceControlManager: SourceControlManager? @Environment(\.controlActiveState) private var controlActive @State private var isHovering: Bool = false - @State private var displayPopover: Bool = false - - @State private var currentBranch: String? + @State private var currentBranch: GitBranch? /// Initializes the ``ToolbarBranchPicker`` with an instance of a `WorkspaceClient` - /// - Parameter shellClient: An instance of the current `ShellClient` /// - Parameter workspace: An instance of the current `WorkspaceClient` init( - shellClient: ShellClient, workspaceFileManager: CEWorkspaceFileManager? ) { self.workspaceFileManager = workspaceFileManager - if let folderURL = workspaceFileManager?.folderUrl { - self.gitClient = GitClient(directoryURL: folderURL, shellClient: shellClient) - } - self._currentBranch = State(initialValue: try? gitClient?.getCurrentBranchName()) + self.sourceControlManager = workspaceFileManager?.sourceControlManager } var body: some View { @@ -57,7 +51,7 @@ struct ToolbarBranchPicker: View { .help(title) if let currentBranch { ZStack(alignment: .trailing) { - Text(currentBranch) + Text(currentBranch.name) .padding(.trailing) if isHovering { Image(systemName: "chevron.down") @@ -79,10 +73,23 @@ struct ToolbarBranchPicker: View { isHovering = active } .popover(isPresented: $displayPopover, arrowEdge: .bottom) { - PopoverView(gitClient: gitClient, currentBranch: $currentBranch) + if let sourceControlManager = workspaceFileManager?.sourceControlManager { + PopoverView(sourceControlManager: sourceControlManager) + } } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { (_) in - currentBranch = try? gitClient?.getCurrentBranchName() + Task { + await sourceControlManager?.refreshCurrentBranch() + } + } + .onReceive( + self.sourceControlManager?.$currentBranch.eraseToAnyPublisher() ?? + Empty().eraseToAnyPublisher() + ) { branch in + self.currentBranch = branch + } + .task { + await self.sourceControlManager?.refreshCurrentBranch() } } @@ -100,28 +107,24 @@ struct ToolbarBranchPicker: View { /// /// It displays the currently checked-out branch and all other local branches. private struct PopoverView: View { - var gitClient: GitClient? - - @Binding var currentBranch: String? + @ObservedObject var sourceControlManager: SourceControlManager var body: some View { VStack(alignment: .leading) { - if let currentBranch { + if let currentBranch = sourceControlManager.currentBranch { VStack(alignment: .leading, spacing: 0) { headerLabel("Current Branch") - BranchCell(name: currentBranch, active: true) {} + BranchCell(sourceControlManager: sourceControlManager, branch: currentBranch, active: true) } } - if !branchNames.isEmpty { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - headerLabel("Branches") - ForEach(branchNames, id: \.self) { branch in - BranchCell(name: branch) { - try? gitClient?.checkoutBranch(branch) - currentBranch = try? gitClient?.getCurrentBranchName() - } - } + + let branches = sourceControlManager.branches + .filter({ $0.isLocal && $0 != sourceControlManager.currentBranch }) + if !branches.isEmpty { + VStack(alignment: .leading, spacing: 0) { + headerLabel("Branches") + ForEach(branches, id: \.self) { branch in + BranchCell(sourceControlManager: sourceControlManager, branch: branch) } } } @@ -129,6 +132,9 @@ struct ToolbarBranchPicker: View { .padding(.top, 10) .padding(5) .frame(width: 340) + .task { + await sourceControlManager.refreshBranches() + } } func headerLabel(_ title: String) -> some View { @@ -143,9 +149,9 @@ struct ToolbarBranchPicker: View { /// A Button Cell that represents a branch in the branch picker struct BranchCell: View { - var name: String + let sourceControlManager: SourceControlManager + var branch: GitBranch var active: Bool = false - var action: () -> Void @Environment(\.dismiss) private var dismiss @@ -154,12 +160,11 @@ struct ToolbarBranchPicker: View { var body: some View { Button { - action() - dismiss() + switchBranch() } label: { HStack { Label { - Text(name) + Text(branch.name) .frame(maxWidth: .infinity, alignment: .leading) } icon: { Image.checkout @@ -184,10 +189,19 @@ struct ToolbarBranchPicker: View { isHovering = active } } - } - var branchNames: [String] { - ((try? gitClient?.getBranches(false)) ?? []).filter { $0 != currentBranch } + func switchBranch() { + Task { + do { + try await sourceControlManager.checkoutBranch(branch: branch) + await MainActor.run { + dismiss() + } + } catch { + await sourceControlManager.showAlertForError(title: "Failed to checkout", error: error) + } + } + } } } } diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift index 56c9f7dab..e0de7947e 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift @@ -207,7 +207,6 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, Obs let toolbarItem = NSToolbarItem(itemIdentifier: .branchPicker) let view = NSHostingView( rootView: ToolbarBranchPicker( - shellClient: currentWorld.shellClient, workspaceFileManager: workspace?.workspaceFileManager ) ) diff --git a/CodeEdit/Features/Documents/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument.swift index 68aa6ff68..ab419b93e 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument.swift @@ -36,6 +36,7 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { var quickOpenViewModel: QuickOpenViewModel? var commandsPaletteState: CommandPaletteViewModel? var listenerModel: WorkspaceNotificationModel = .init() + var sourceControlManager: SourceControlManager? private var cancellables = Set() @@ -110,10 +111,17 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { private func initWorkspaceState(_ url: URL) throws { self.fileURL = url + let sourceControlManager = SourceControlManager( + workspaceURL: url, + editorManager: editorManager + ) self.workspaceFileManager = .init( folderUrl: url, - ignoredFilesAndFolders: Set(ignoredFilesAndDirectory) + ignoredFilesAndFolders: Set(ignoredFilesAndDirectory), + sourceControlManager: sourceControlManager ) + self.sourceControlManager = sourceControlManager + sourceControlManager.fileManager = workspaceFileManager self.searchState = .init(self) self.quickOpenViewModel = .init(fileURL: url) self.commandsPaletteState = .init() diff --git a/CodeEdit/Features/Git/Client/GitClient+Branches.swift b/CodeEdit/Features/Git/Client/GitClient+Branches.swift new file mode 100644 index 000000000..b9bb91f07 --- /dev/null +++ b/CodeEdit/Features/Git/Client/GitClient+Branches.swift @@ -0,0 +1,96 @@ +// +// GitClient+Branches.swift +// CodeEdit +// +// Created by Albert Vinizhanau on 10/20/23. +// + +import Foundation + +extension GitClient { + /// Get branches + /// - Returns: Array of branches + func getBranches() async throws -> [GitBranch] { + let command = "branch --format \"%(refname:short)|%(refname)|%(upstream:short)\" -a" + + return try await run(command) + .components(separatedBy: "\n") + .filter { $0 != "" && !$0.contains("HEAD") } + .compactMap { line in + let components = line.components(separatedBy: "|") + let name = components[0] + let upstream = components[safe: 2] + + return .init( + name: name, + longName: components[safe: 1] ?? name, + upstream: upstream?.isEmpty == true ? nil : upstream + ) + } + } + + /// Get current branch + func getCurrentBranch() async throws -> GitBranch? { + let branchName = try await run("rev-parse --abbrev-ref HEAD").trimmingCharacters(in: .whitespacesAndNewlines) + let components = try await run( + "for-each-ref --format=\"%(refname)|%(upstream:short)\" refs/heads/\(branchName)" + ) + .trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: "|") + + let upstream = components[safe: 1] + + return .init( + name: branchName, + longName: components[0], + upstream: upstream?.isEmpty == true ? nil : upstream + ) + } + + /// Delete branch + func deleteBranch(_ branch: GitBranch) async throws { + if !branch.isLocal { + return + } + + _ = try await run("branch -d \(branch.name)") + } + + /// Create new branch + func newBranch(name: String, from: GitBranch) async throws { + if !from.isLocal { + return + } + + _ = try await run("checkout -b \(name) \(from.name)") + } + + /// Checkout branch + /// - Parameter branch: Branch to checkout + func checkoutBranch(_ branch: GitBranch, forceLocal: Bool = false) async throws { + var command = "checkout " + + // If branch is remote, try to create local branch + if branch.isRemote { + let localName = branch.name.replacingOccurrences(of: "origin/", with: "") + command += forceLocal ? localName : "-b " + localName + " " + branch.name + } else { + command += branch.name + } + + do { + let output = try await run(command) + if !output.contains("Switched to branch") && !output.contains("Switched to a new branch") { + throw GitClientError.outputError(output) + } + } catch { + // If branch is remote and command failed because branch already exists + // try to switch to local branch + if let error = error as? GitClientError, + branch.isRemote, + error.localizedDescription.contains("already exists") { + try await checkoutBranch(branch, forceLocal: true) + } + } + } +} diff --git a/CodeEdit/Features/Git/Client/GitClient+Clone.swift b/CodeEdit/Features/Git/Client/GitClient+Clone.swift new file mode 100644 index 000000000..09f3f3ad8 --- /dev/null +++ b/CodeEdit/Features/Git/Client/GitClient+Clone.swift @@ -0,0 +1,102 @@ +// +// GitClient+Clone.swift +// CodeEdit +// +// Created by Albert Vinizhanau on 10/20/23. +// + +import Foundation +import Combine + +extension GitClient { + struct CloneProgress { + let progress: Double + let state: GitCloneProgressState + } + + enum GitCloneProgressState { + case initialState + case counting + case compressing + case receiving + case resolving + + var label: String { + switch self { + case .initialState: "Cloning" + case .counting: "Counting" + case .compressing: "Compressing" + case .receiving: "Receiving" + case .resolving: "Resolving" + } + } + } + + /// Clone repository + /// - Parameters: + /// - remoteUrl: URL of remote repository + /// - localPath: Local path to clone + /// - Returns: Stream of progress + func cloneRepository( + remoteUrl: URL, + localPath: URL + ) -> AsyncThrowingMapSequence { + let command = "clone \(remoteUrl.absoluteString) \(localPath.relativePath.escapedWhiteSpaces()) --progress" + + return self.runLive(command) + .map { line in + // Inspired by VS Code https://github.com/microsoft/vscode/blob/main/extensions/git/src/git.ts + // Parsing git clone output (for patterns look at cloneMatchTypes) and calculating total progress + // Each step has own base progress and multiplier + // Total progress is baseProgress + (progress from output * multiplier) + // For example current outout is Counting objects: 10%, baseProgress is 0, multiplier is 0.1 + // So total progress is 0 + (10 * 0.1) = 1% + for cloneMatchType in self.cloneMatchTypes { + if let progress = self.matchAndCalculateProgress( + line, + cloneMatchType.pattern, + baseProgress: cloneMatchType.baseProgress, + multiplier: cloneMatchType.multiplier + ) { + return .init(progress: progress, state: cloneMatchType.state) + } + } + + return .init(progress: 0, state: .initialState) + } + } + + fileprivate struct CloneMatchType { + let pattern: String + let baseProgress: Double + let multiplier: Double + let state: GitCloneProgressState + } + + fileprivate var cloneMatchTypes: [CloneMatchType] { + [ + .init(pattern: "Counting objects:\\s*(\\d+)%", baseProgress: 0, multiplier: 0.1, state: .counting), + .init(pattern: "Compressing objects:\\s*(\\d+)%", baseProgress: 10, multiplier: 0.1, state: .compressing), + .init(pattern: "Receiving objects:\\s*(\\d+)%", baseProgress: 20, multiplier: 0.4, state: .receiving), + .init(pattern: "Resolving deltas:\\s*(\\d+)%", baseProgress: 60, multiplier: 0.4, state: .resolving), + ] + } + + /// Match pattern in output line and calculate progress + fileprivate func matchAndCalculateProgress( + _ line: String, + _ pattern: String, + baseProgress: Double, + multiplier: Double + ) -> Double? { + let match = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) + .firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) + + if let match, + let range = Range(match.range(at: 1), in: line), + let progress = Int(line[range]) { + return baseProgress + Double(progress) * multiplier + } + return nil + } +} diff --git a/CodeEdit/Features/Git/Client/GitClient+Commit.swift b/CodeEdit/Features/Git/Client/GitClient+Commit.swift new file mode 100644 index 000000000..c9c05d8a9 --- /dev/null +++ b/CodeEdit/Features/Git/Client/GitClient+Commit.swift @@ -0,0 +1,47 @@ +// +// GitClient+Commit.swift +// CodeEdit +// +// Created by Albert Vinizhanau on 10/20/23. +// + +import Foundation + +extension GitClient { + /// Commit files, if file is untracked, it will be added + /// - Parameters: + /// - files: Files to commit + /// - message: Commit message + func commit(_ files: [CEWorkspaceFile], message: String) async throws { + // Add untracked files + for file in files where file.gitStatus == .untracked { + try await add(file) + } + + let message = message.replacingOccurrences(of: #"""#, with: #"\""#) + let command = "commit \(files.map { $0.url.relativePath }.joined(separator: " ")) --message=\"\(message)\"" + + _ = try await run(command) + } + + /// Add file to git + /// - Parameter file: File to add + func add(_ file: CEWorkspaceFile) async throws { + if file.gitStatus != .untracked { + return + } + + _ = try await run("add \(file.url.relativePath)") + } + + func numberOfUnsyncedCommits() async throws -> Int { + let output = try await run("log --oneline origin/$(git rev-parse --abbrev-ref HEAD)..HEAD | wc -l") + .trimmingCharacters(in: .whitespacesAndNewlines) + + if let number = Int(output) { + return number + } + + return 0 + } +} diff --git a/CodeEdit/Features/Git/Client/GitClient+CommitHistory.swift b/CodeEdit/Features/Git/Client/GitClient+CommitHistory.swift new file mode 100644 index 000000000..cbdeb2252 --- /dev/null +++ b/CodeEdit/Features/Git/Client/GitClient+CommitHistory.swift @@ -0,0 +1,41 @@ +// +// GitClient+CommitHistory.swift +// CodeEdit +// +// Created by Albert Vinizhanau on 10/20/23. +// + +import Foundation + +extension GitClient { + // Gets the commit history log of the current file opened + // in the workspace. + func getCommitHistory(entries: Int?, fileLocalPath: String?) async throws -> [GitCommit] { + var entriesString = "" + let fileLocalPath = fileLocalPath?.escapedWhiteSpaces() ?? "" + if let entries { entriesString = "-n \(entries)" } + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale.current + dateFormatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" + let output = try await run("log --pretty=%h¦%H¦%s¦%aN¦%ae¦%cn¦%ce¦%aD¦ \(entriesString) \(fileLocalPath)") + let remote = try await run("ls-remote --get-url") + let remoteURL = URL(string: remote.trimmingCharacters(in: .whitespacesAndNewlines)) + + return output + .split(separator: "\n") + .map { line -> GitCommit in + let parameters = line.components(separatedBy: "¦") + return GitCommit( + hash: parameters[safe: 0] ?? "", + commitHash: parameters[safe: 1] ?? "", + message: parameters[safe: 2] ?? "", + author: parameters[safe: 3] ?? "", + authorEmail: parameters[safe: 4] ?? "", + committer: parameters[safe: 5] ?? "", + committerEmail: parameters[safe: 6] ?? "", + remoteURL: remoteURL, + date: dateFormatter.date(from: parameters[safe: 7] ?? "") ?? Date() + ) + } + } +} diff --git a/CodeEdit/Features/Git/Client/GitClient+Push.swift b/CodeEdit/Features/Git/Client/GitClient+Push.swift new file mode 100644 index 000000000..6da980e44 --- /dev/null +++ b/CodeEdit/Features/Git/Client/GitClient+Push.swift @@ -0,0 +1,24 @@ +// +// GitClient+Push.swift +// CodeEdit +// +// Created by Albert Vinizhanau on 10/20/23. +// + +import Foundation + +extension GitClient { + /// Push changes to remote + func pushToRemote(upstream: String? = nil) async throws { + var command = "push" + if let upstream { + command += " --set-upstream origin \(upstream)" + } + + let output = try await self.run(command) + + if output.contains("rejected") { + throw GitClientError.outputError(output) + } + } +} diff --git a/CodeEdit/Features/Git/Client/GitClient+Status.swift b/CodeEdit/Features/Git/Client/GitClient+Status.swift new file mode 100644 index 000000000..5afac5de8 --- /dev/null +++ b/CodeEdit/Features/Git/Client/GitClient+Status.swift @@ -0,0 +1,41 @@ +// +// GitClient+Status.swift +// CodeEdit +// +// Created by Albert Vinizhanau on 10/20/23. +// + +import Foundation + +extension GitClient { + /// Get changed files + func getChangedFiles() async throws -> [GitChangedFile] { + let output = try await run("status -s --porcelain -u") + + return try output + .split(whereSeparator: \.isNewline) + .map { line -> GitChangedFile in + let paramData = line.trimmingCharacters(in: .whitespacesAndNewlines) + let parameters = paramData.components(separatedBy: " ") + + let urlIndex = parameters.count > 2 ? 2 : 1 + + guard let url = URL(string: parameters[safe: urlIndex] ?? String(describing: URLError.badURL)) else { + throw GitClientError.failedToDecodeURL + } + + let gitType: GitType? = .init(rawValue: parameters[safe: 0] ?? "") + let fullLink = self.directoryURL.appending(path: url.relativePath) + + return GitChangedFile( + changeType: gitType, + fileLink: fullLink + ) + } + } + + /// Discard changes for file + func discardChanges(for file: URL) async throws { + _ = try await run("restore \(file.relativePath)") + } +} diff --git a/CodeEdit/Features/Git/Client/GitClient.swift b/CodeEdit/Features/Git/Client/GitClient.swift index 308f29d24..a8f68dec9 100644 --- a/CodeEdit/Features/Git/Client/GitClient.swift +++ b/CodeEdit/Features/Git/Client/GitClient.swift @@ -8,185 +8,77 @@ import Combine import Foundation -struct GitClient { +class GitClient { enum GitClientError: Error { case outputError(String) case notGitRepository case failedToDecodeURL - } - enum CloneProgressResult { - case receivingProgress(Int) - case resolvingProgress(Int) - case other(String) + var description: String { + switch self { + case .outputError(let string): string + case .notGitRepository: "Not a git repository" + case .failedToDecodeURL: "Failed to decode URL" + } + } } - private let directoryURL: URL - private let shellClient: ShellClient + internal let directoryURL: URL + internal let shellClient: ShellClient init(directoryURL: URL, shellClient: ShellClient) { self.directoryURL = directoryURL self.shellClient = shellClient } - func getBranches(_ allBranches: Bool = false) throws -> [String] { - if allBranches == true { - return try shellClient.run( - "cd \(directoryURL.relativePath.escapedWhiteSpaces());git branch -a --format \"%(refname:short)\"" - ) - .components(separatedBy: "\n") - .filter { $0 != "" } - } - return try shellClient.run( - "cd \(directoryURL.relativePath.escapedWhiteSpaces());git branch --format \"%(refname:short)\"" - ) - .components(separatedBy: "\n") - .filter { $0 != "" } + /// Runs a git command, it will prepend the command with `cd ;git`, + /// If you need to run "git checkout", pass "checkout" as the command parameter + internal func run(_ command: String) async throws -> String { + let output = try shellClient.run(generateCommand(command)) + return try processCommonErrors(output) } - func getCurrentBranchName() throws -> String { - let output = try shellClient.run( - "cd \(directoryURL.relativePath.escapedWhiteSpaces());git rev-parse --abbrev-ref HEAD" - ) - .replacingOccurrences(of: "\n", with: "") - if output.contains("fatal: not a git repository") { - throw GitClientError.notGitRepository - } - return output - } + internal typealias LiveCommandStream = AsyncThrowingMapSequence, String> - func checkoutBranch(_ name: String) throws { - guard try getCurrentBranchName() != name else { return } - let output = try shellClient.run( - "cd \(directoryURL.relativePath.escapedWhiteSpaces());git checkout \(name)" - ) - if output.contains("fatal: not a git repository") { - throw GitClientError.notGitRepository - } else if !output.contains("Switched to branch") && !output.contains("Switched to a new branch") { - throw GitClientError.outputError(output) - } + /// Runs a git command in same way as `run`, but returns a async stream of the output + internal func runLive(_ command: String) -> LiveCommandStream { + return runLive(customCommand: generateCommand(command)) } - func cloneRepository(url: String) -> AnyPublisher { - shellClient - .runLive("git clone \(url) \(directoryURL.relativePath.escapedWhiteSpaces()) --progress") - .tryMap { output -> String in - if output.contains("fatal: not a git repository") { - throw GitClientError.notGitRepository - } - return output + /// Here you can run a custom command, this is needed for git clone + internal func runLive(customCommand: String) -> LiveCommandStream { + return shellClient + .runAsync(customCommand) + .map { output in + return try self.processCommonErrors(output) } - .map { value -> CloneProgressResult in - // TODO: Make a more solid parsing system. - if value.contains("Receiving objects: ") { - return .receivingProgress( - Int( - value - .replacingOccurrences(of: "Receiving objects: ", with: "") - .replacingOccurrences(of: " ", with: "") - .split(separator: "%") - .first ?? "0" - ) ?? 0 - ) - } else if value.contains("Resolving deltas: ") { - return .resolvingProgress( - Int( - value - .replacingOccurrences(of: "Resolving deltas: ", with: "") - .replacingOccurrences(of: " ", with: "") - .split(separator: "%") - .first ?? "0" - ) ?? 0 - ) - } else { - return .other(value) - } - } - .mapError { - if let error = $0 as? GitClientError { - return error - } else { - return GitClientError.outputError($0.localizedDescription) - } - } - .eraseToAnyPublisher() + } + private func generateCommand(_ command: String) -> String { + "cd \(directoryURL.relativePath.escapedWhiteSpaces());git \(command)" } - func getChangedFiles() throws -> [GitChangedFile] { - let output = try shellClient.run( - "cd \(directoryURL.relativePath.escapedWhiteSpaces());git status -s --porcelain -u" - ) + private func processCommonErrors(_ output: String) throws -> String { if output.contains("fatal: not a git repository") { throw GitClientError.notGitRepository } - return try output - .split(whereSeparator: \.isNewline) - .map { line -> GitChangedFile in - let paramData = line.trimmingCharacters(in: .whitespacesAndNewlines) - let parameters = paramData.components(separatedBy: " ") - guard let url = URL(string: parameters[safe: 1] ?? String(describing: URLError.badURL)) else { - throw GitClientError.failedToDecodeURL - } - - var gitType: GitType { - .init(rawValue: parameters[safe: 0] ?? "") ?? GitType.unknown - } - - return GitChangedFile( - changeType: gitType, - fileLink: url - ) - } - } - // Gets the commit history log of the current file opened - // in the workspace. - func getCommitHistory(entries: Int?, fileLocalPath: String?) throws -> [GitCommit] { - var entriesString = "" - let fileLocalPath = fileLocalPath?.escapedWhiteSpaces() ?? "" - if let entries { entriesString = "-n \(entries)" } - let dateFormatter = DateFormatter() - dateFormatter.locale = Locale.current - dateFormatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" - let output = try shellClient.run( - // swiftlint:disable:next line_length - "cd \(directoryURL.relativePath.escapedWhiteSpaces());git log --pretty=%h¦%H¦%s¦%aN¦%ae¦%cn¦%ce¦%aD¦ \(entriesString) \(fileLocalPath)" - ) - let remote = try shellClient.run( - "cd \(directoryURL.relativePath.escapedWhiteSpaces());git ls-remote --get-url" - ) - let remoteURL = URL(string: remote.trimmingCharacters(in: .whitespacesAndNewlines)) - if output.contains("fatal: not a git repository") { - throw GitClientError.notGitRepository + if output.hasPrefix("fatal:") { + throw GitClientError.outputError(output) } + return output - .split(separator: "\n") - .map { line -> GitCommit in - let parameters = line.components(separatedBy: "¦") - return GitCommit( - hash: parameters[safe: 0] ?? "", - commitHash: parameters[safe: 1] ?? "", - message: parameters[safe: 2] ?? "", - author: parameters[safe: 3] ?? "", - authorEmail: parameters[safe: 4] ?? "", - committer: parameters[safe: 5] ?? "", - committerEmail: parameters[safe: 6] ?? "", - remoteURL: remoteURL, - date: dateFormatter.date(from: parameters[safe: 7] ?? "") ?? Date() - ) - } } } -private extension Collection { +internal extension Collection { /// Returns the element at the specified index if it is within bounds, otherwise nil. subscript (safe index: Index) -> Element? { indices.contains(index) ? self[index] : nil } } -private extension String { +internal extension String { func escapedWhiteSpaces() -> String { self.replacingOccurrences(of: " ", with: "\\ ") } diff --git a/CodeEdit/Features/Git/Client/Models/GitBranch.swift b/CodeEdit/Features/Git/Client/Models/GitBranch.swift new file mode 100644 index 000000000..e9ea44840 --- /dev/null +++ b/CodeEdit/Features/Git/Client/Models/GitBranch.swift @@ -0,0 +1,24 @@ +// +// GitBranch.swift +// CodeEdit +// +// Created by Albert Vinizhanau on 10/20/23. +// + +import Foundation + +struct GitBranch: Hashable { + let name: String + let longName: String + let upstream: String? + + /// Is local branch + var isLocal: Bool { + return longName.hasPrefix("refs/heads/") + } + + /// Is remote branch + var isRemote: Bool { + return longName.hasPrefix("refs/remotes/") + } +} diff --git a/CodeEdit/Features/Git/Client/Models/GitChangedFile.swift b/CodeEdit/Features/Git/Client/Models/GitChangedFile.swift index 3396f2ea8..9c86d5e41 100644 --- a/CodeEdit/Features/Git/Client/Models/GitChangedFile.swift +++ b/CodeEdit/Features/Git/Client/Models/GitChangedFile.swift @@ -8,57 +8,10 @@ import Foundation import SwiftUI -struct GitChangedFile: Codable, Hashable, Identifiable { - /// ID of the changed file - var id = UUID() - +struct GitChangedFile { /// Change type is to tell us whether the type is a new file, modified or deleted let changeType: GitType? /// Link of the file let fileLink: URL - - /// Use it like this - /// ```swift - /// Image(systemName: item.systemImage) - /// ``` - var systemImage: String { - if fileLink.hasDirectoryPath { - return "folder.fill" - } else { - return FileIcon.fileIcon(fileType: fileType) - } - } - - /// Returns the file name (e.g.: `Package.swift`) - var fileName: String { - fileLink.deletingPathExtension().lastPathComponent - } - - var changeTypeValue: String { - changeType?.description ?? "" - } - - /// Returns the extension of the file or an empty string if no extension is present. - private var fileType: FileIcon.FileType { - .init(rawValue: fileLink.pathExtension) ?? .txt - } - - /// Returns a `Color` for a specific `fileType` - /// - /// If not specified otherwise this will return `Color.accentColor` - var iconColor: Color { - FileIcon.iconColor(fileType: fileType) - } - - // MARK: Intents - /// Allows the user to view the file or folder in the finder application - func showInFinder(workspaceURL: URL) { - let workspace = workspaceURL.absoluteString - let file = fileLink.absoluteString - guard let url = URL(string: workspace + file) else { - return print("Failed to decode URL") - } - NSWorkspace.shared.activateFileViewerSelecting([url]) - } } diff --git a/CodeEdit/Features/Git/Client/Models/GitType.swift b/CodeEdit/Features/Git/Client/Models/GitType.swift index bdedf1f4a..85216fa99 100644 --- a/CodeEdit/Features/Git/Client/Models/GitType.swift +++ b/CodeEdit/Features/Git/Client/Models/GitType.swift @@ -9,7 +9,7 @@ import Foundation enum GitType: String, Codable { case modified = "M" - case unknown = "??" + case untracked = "??" case fileTypeChange = "T" case added = "A" case deleted = "D" @@ -20,7 +20,7 @@ enum GitType: String, Codable { var description: String { switch self { case .modified: return "M" - case .unknown: return "?" + case .untracked: return "U" case .fileTypeChange: return "T" case .added: return "A" case .deleted: return "D" diff --git a/CodeEdit/Features/Git/Clone/GitCheckoutBranchView+CheckoutBranch.swift b/CodeEdit/Features/Git/Clone/GitCheckoutBranchView+CheckoutBranch.swift deleted file mode 100644 index 14bccf8a2..000000000 --- a/CodeEdit/Features/Git/Clone/GitCheckoutBranchView+CheckoutBranch.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// GitCheckoutBranchView+CheckoutBranch.swift -// CodeEditModules/Git -// -// Created by Aleksi Puttonen on 18.4.2022. -// - -import Foundation -import SwiftUI - -// TODO: DOCS (Aleksi Puttonen) -extension GitCheckoutBranchView { - func getBranches() -> [String] { - guard let url = URL(string: repoPath) else { - return [""] - } - do { - let branches = try GitClient( - directoryURL: url, - shellClient: shellClient - ).getBranches(true) - return branches - } catch { - return [""] - } - } - func checkoutBranch() { - var parsedBranch = selectedBranch - if selectedBranch.contains("origin/") || selectedBranch.contains("upstream/") { - parsedBranch = selectedBranch.components(separatedBy: "/")[1] - } - do { - if let url = URL(string: repoPath) { - try GitClient(directoryURL: url, shellClient: shellClient).checkoutBranch(parsedBranch) - isPresented = false - } - } catch { - guard let error = error as? GitClient.GitClientError else { return } - let alert = NSAlert() - alert.alertStyle = .critical - alert.addButton(withTitle: "Ok") - switch error { - case .notGitRepository: - alert.messageText = "Not a git repository" - case let .outputError(message): - alert.messageText = message - case .failedToDecodeURL: - alert.messageText = "Failed to decode URL" - } - alert.runModal() - } - } -} diff --git a/CodeEdit/Features/Git/Clone/GitCheckoutBranchView.swift b/CodeEdit/Features/Git/Clone/GitCheckoutBranchView.swift index 802265d4c..c3a4c3b00 100644 --- a/CodeEdit/Features/Git/Clone/GitCheckoutBranchView.swift +++ b/CodeEdit/Features/Git/Clone/GitCheckoutBranchView.swift @@ -9,19 +9,18 @@ import Foundation import SwiftUI struct GitCheckoutBranchView: View { - let shellClient: ShellClient - @Binding var isPresented: Bool - @Binding var repoPath: String - // TODO: This has to be derived from git - @State var selectedBranch = "main" + @Environment(\.dismiss) + private var dismiss + + @StateObject private var viewModel: GitCheckoutBranchViewModel + private var openDocument: (URL) -> Void + init( - isPresented: Binding, - repoPath: Binding, - shellClient: ShellClient + repoLocalPath: URL, + openDocument: @escaping (URL) -> Void ) { - self.shellClient = shellClient - self._isPresented = isPresented - self._repoPath = repoPath + _viewModel = .init(wrappedValue: GitCheckoutBranchViewModel(repoPath: repoLocalPath)) + self.openDocument = openDocument } var body: some View { VStack(spacing: 8) { @@ -40,24 +39,26 @@ struct GitCheckoutBranchView: View { .alignmentGuide(.trailing) { context in context[.trailing] } - Menu { - ForEach(getBranches().filter { !$0.contains("HEAD") }, id: \.self) { branch in - Button { - guard selectedBranch != branch else { return } - selectedBranch = branch - } label: { - Text(branch) - }.disabled(selectedBranch == branch) + Picker("", selection: $viewModel.selectedBranch, content: { + ForEach(viewModel.branches, id: \.self) { branch in + Text(branch.name.replacingOccurrences(of: "origin/", with: "")) + .tag(branch as GitBranch?) } - } label: { - Text(selectedBranch) - } + }) + .labelsHidden() + HStack { Button("Cancel") { - isPresented = false + dismiss() } Button("Checkout") { - checkoutBranch() + Task { + await viewModel.checkoutBranch() + await MainActor.run { + dismiss() + openDocument(viewModel.repoPath) + } + } } .keyboardShortcut(.defaultAction) } @@ -71,6 +72,9 @@ struct GitCheckoutBranchView: View { .padding(.horizontal, 20) .padding(.bottom, 16) .frame(width: 400) + .task { + await viewModel.loadBranches() + } } } } diff --git a/CodeEdit/Features/Git/Clone/GitCloneView.swift b/CodeEdit/Features/Git/Clone/GitCloneView.swift index 27efd1d85..d7a0307f3 100644 --- a/CodeEdit/Features/Git/Clone/GitCloneView.swift +++ b/CodeEdit/Features/Git/Clone/GitCloneView.swift @@ -10,24 +10,20 @@ import Foundation import Combine struct GitCloneView: View { - private let shellClient: ShellClient - @Binding private var isPresented: Bool - @Binding private var showCheckout: Bool - @Binding private var repoPath: String - @State private var repoUrlStr = "" - @State private var gitClient: GitClient? - @State private var cloneCancellable: AnyCancellable? + @Environment(\.dismiss) + private var dismiss + + @StateObject private var viewModel: GitCloneViewModel = .init() + + private let openBranchView: (URL) -> Void + private let openDocument: (URL) -> Void init( - shellClient: ShellClient, - isPresented: Binding, - showCheckout: Binding, - repoPath: Binding + openBranchView: @escaping (URL) -> Void, + openDocument: @escaping (URL) -> Void ) { - self.shellClient = shellClient - self._isPresented = isPresented - self._showCheckout = showCheckout - self._repoPath = repoPath + self.openBranchView = openBranchView + self.openDocument = openDocument } var body: some View { @@ -47,19 +43,20 @@ struct GitCloneView: View { .alignmentGuide(.trailing) { context in context[.trailing] } - TextField("Git Repository URL", text: $repoUrlStr) + TextField("Git Repository URL", text: $viewModel.repoUrlStr) .lineLimit(1) .padding(.bottom, 15) .frame(width: 300) + HStack { Button("Cancel") { - isPresented = false + dismiss() } Button("Clone") { cloneRepository() } .keyboardShortcut(.defaultAction) - .disabled(!isValid(url: repoUrlStr)) + .disabled(!viewModel.isValidUrl(url: viewModel.repoUrlStr)) } .offset(x: 185) .alignmentGuide(.leading) { context in @@ -71,152 +68,49 @@ struct GitCloneView: View { .padding(.horizontal, 20) .padding(.bottom, 16) .onAppear { - self.checkClipboard(textFieldText: &repoUrlStr) + viewModel.checkClipboard() } - } - } -} - -extension GitCloneView { - func getPath(modifiable: inout String, saveName: String) -> String? { - let dialog = NSSavePanel() - dialog.showsResizeIndicator = true - dialog.showsHiddenFiles = false - dialog.showsTagField = false - dialog.prompt = "Clone" - dialog.nameFieldStringValue = saveName - dialog.nameFieldLabel = "Clone as" - dialog.title = "Clone" - - if dialog.runModal() == NSApplication.ModalResponse.OK { - let result = dialog.url - - if result != nil { - let path: String = result!.path - // path contains the directory path e.g - // /Users/ourcodeworld/Desktop/folder - modifiable = path - return path + .sheet(isPresented: $viewModel.isCloning) { + NavigationStack { + VStack { + ProgressView( + viewModel.cloningProgress.state.label, + value: viewModel.cloningProgress.progress, + total: 100 + ) + } + } + .toolbar { + ToolbarItem { + Button("Cancel Cloning") { + viewModel.cloningTask?.cancel() + viewModel.cloningTask = nil + viewModel.isCloning = false + } + } + } + .padding() + .frame(width: 350) } - } else { - // User clicked on "Cancel" - return nil } - return nil - } - - func showAlert(alertMsg: String, infoText: String) { - let alert = NSAlert() - alert.messageText = alertMsg - alert.informativeText = infoText - alert.addButton(withTitle: "OK") - alert.alertStyle = .warning - alert.runModal() } - func isValid(url: String) -> Bool { - // Doing the same kind of check that Xcode does when cloning - let url = url.lowercased() - if url.starts(with: "http://") && url.count > 7 { - return true - } else if url.starts(with: "https://") && url.count > 8 { - return true - } else if url.starts(with: "git@") && url.count > 4 { - return true - } - return false - } + func cloneRepository() { + viewModel.cloneRepository { localPath in + dismiss() - func checkClipboard(textFieldText: inout String) { - if let url = NSPasteboard.general.pasteboardItems?.first?.string(forType: .string) { - if isValid(url: url) { - textFieldText = url - } - } - } + guard let gitClient = viewModel.gitClient else { return } - // swiftlint:disable:next function_body_length cyclomatic_complexity - private func cloneRepository() { - do { - if repoUrlStr == "" { - showAlert( - alertMsg: "Url cannot be empty", - infoText: "You must specify a repository to clone" - ) - return - } - // Parsing repo name - let repoURL = URL(string: repoUrlStr) - if var repoName = repoURL?.lastPathComponent { - // Strip .git from name if it has it. - // Cloning repository without .git also works - if repoName.contains(".git") { - repoName.removeLast(4) - } - guard getPath(modifiable: &repoPath, saveName: repoName) != nil else { + Task { + let branches = ((try? await gitClient.getBranches()) ?? []) + .filter({ $0.isRemote }) + if branches.count > 1 { + openBranchView(localPath) return } - } else { - return - } - guard let dirUrl = URL(string: repoPath) else { - return - } - var isDir: ObjCBool = true - if FileManager.default.fileExists(atPath: repoPath, isDirectory: &isDir) { - showAlert(alertMsg: "Error", infoText: "Directory already exists") - return - } - try FileManager.default.createDirectory( - atPath: repoPath, - withIntermediateDirectories: true, - attributes: nil - ) - gitClient = GitClient(directoryURL: dirUrl, shellClient: shellClient) - cloneCancellable = gitClient? - .cloneRepository(url: repoUrlStr) - .sink(receiveCompletion: { result in - switch result { - case let .failure(error): - switch error { - case .notGitRepository: - showAlert(alertMsg: "Error", infoText: "Not a git repository") - case let .outputError(error): - showAlert(alertMsg: "Error", infoText: error) - case .failedToDecodeURL: - showAlert(alertMsg: "Error", infoText: "Failed to decode URL") - } - case .finished: break - } - }, receiveValue: { result in - switch result { - case let .receivingProgress(progress): - print("Receiving Progress: ", progress) - case let .resolvingProgress(progress): - print("Resolving Progress: ", progress) - if progress >= 100 { - cloneCancellable?.cancel() - isPresented = false - } - case .other: break - } - }) - checkBranches(dirUrl: dirUrl) - } catch { - showAlert(alertMsg: "Error", infoText: error.localizedDescription) - } - } - private func checkBranches(dirUrl: URL) { - // Check if repo has only one branch, and if so, don't show the checkout page - do { - let branches = try GitClient(directoryURL: dirUrl, shellClient: shellClient).getBranches(true) - let filtered = branches.filter { !$0.contains("HEAD") } - if filtered.count > 1 { - showCheckout = true + openDocument(localPath) } - } catch { - return } } } diff --git a/CodeEdit/Features/Git/Clone/ViewModels/GitCheckoutBranchViewModel.swift b/CodeEdit/Features/Git/Clone/ViewModels/GitCheckoutBranchViewModel.swift new file mode 100644 index 000000000..df72ef224 --- /dev/null +++ b/CodeEdit/Features/Git/Clone/ViewModels/GitCheckoutBranchViewModel.swift @@ -0,0 +1,39 @@ +// +// GitCheckoutBranchView.swift +// CodeEdit +// +// Created by Albert Vinizhanau on 10/17/23. +// + +import Foundation + +class GitCheckoutBranchViewModel: ObservableObject { + @Published var selectedBranch: GitBranch? + @Published var branches: [GitBranch] = [] + + let repoPath: URL + private let gitClient: GitClient + + init(repoPath: URL) { + self.repoPath = repoPath + gitClient = .init(directoryURL: repoPath, shellClient: .live()) + } + + func loadBranches() async { + let branches = ((try? await gitClient.getBranches()) ?? []) + .filter({ $0.isRemote }) + + await MainActor.run { + self.branches = branches + if selectedBranch == nil { + selectedBranch = branches.first + } + } + } + + func checkoutBranch() async { + guard let selectedBranch else { return } + + try? await gitClient.checkoutBranch(selectedBranch) + } +} diff --git a/CodeEdit/Features/Git/Clone/ViewModels/GitCloneViewModel.swift b/CodeEdit/Features/Git/Clone/ViewModels/GitCloneViewModel.swift new file mode 100644 index 000000000..4c1dd789d --- /dev/null +++ b/CodeEdit/Features/Git/Clone/ViewModels/GitCloneViewModel.swift @@ -0,0 +1,182 @@ +// +// GitCloneViewModel.swift +// CodeEdit +// +// Created by Albert Vinizhanau on 10/17/23. +// + +import Foundation +import AppKit + +class GitCloneViewModel: ObservableObject { + @Published var repoUrlStr = "" + @Published var isCloning: Bool = false + @Published var cloningProgress: GitClient.CloneProgress = .init(progress: 0, state: .initialState) + + var gitClient: GitClient? + var cloningTask: Task? + + /// Check if url is valid + /// - Parameter url: Url to check + /// - Returns: True if url is valid + func isValidUrl(url: String) -> Bool { + // Doing the same kind of check that Xcode does when cloning + let url = url.lowercased() + if url.starts(with: "http://") && url.count > 7 { + return true + } else if url.starts(with: "https://") && url.count > 8 { + return true + } else if url.starts(with: "git@") && url.count > 4 { + return true + } + return false + } + + /// Check if clipboard contains git url + func checkClipboard() { + if let url = NSPasteboard.general.pasteboardItems?.first?.string(forType: .string) { + if isValidUrl(url: url) { + self.repoUrlStr = url + } + } + } + + /// Clone repository + func cloneRepository(completionHandler: @escaping (URL) -> Void) { + if repoUrlStr == "" { + showAlert( + alertMsg: "Url cannot be empty", + infoText: "You must specify a repository to clone" + ) + return + } + + // Parsing repo name + guard let remoteUrl = URL(string: repoUrlStr) else { + return + } + + var repoName = remoteUrl.lastPathComponent + + // Strip .git from name if it has it. + // Cloning repository without .git also works + if repoName.contains(".git") { + repoName.removeLast(4) + } + + guard let localPath = getPath(saveName: repoName) else { + return + } + + var isDir: ObjCBool = true + if FileManager.default.fileExists(atPath: localPath.relativePath, isDirectory: &isDir) { + showAlert(alertMsg: "Error", infoText: "Directory already exists") + return + } + + do { + try FileManager.default.createDirectory( + atPath: localPath.relativePath, + withIntermediateDirectories: true, + attributes: nil + ) + } catch { + showAlert(alertMsg: "Failed to create folder", infoText: "\(error)") + return + } + + gitClient = GitClient(directoryURL: localPath, shellClient: .live()) + + self.cloningTask = Task(priority: .background) { + await processCloning( + remoteUrl: remoteUrl, + localPath: localPath, + completionHandler: completionHandler + ) + } + } + + /// Process cloning + /// - Parameters: + /// - remoteUrl: Path to remote repository + /// - localPath: Path to local folder + /// - completionHandler: Completion handler if cloning is successful + private func processCloning( + remoteUrl: URL, + localPath: URL, + completionHandler: @escaping (URL) -> Void + ) async { + guard let gitClient else { return } + + await setIsCloning(true) + + do { + for try await progress in gitClient.cloneRepository(remoteUrl: remoteUrl, localPath: localPath) { + await MainActor.run { + self.cloningProgress = progress + } + } + + if Task.isCancelled { + await MainActor.run { + deleteTemporaryFolder(localPath: localPath) + } + return + } + + completionHandler(localPath) + } catch { + await MainActor.run { + if let error = error as? GitClient.GitClientError { + showAlert(alertMsg: "Failed to clone", infoText: error.description) + } else { + showAlert(alertMsg: "Failed to clone", infoText: error.localizedDescription) + } + deleteTemporaryFolder(localPath: localPath) + } + } + + await setIsCloning(false) + } + + private func deleteTemporaryFolder(localPath: URL) { + do { + try FileManager.default.removeItem(atPath: localPath.relativePath) + } catch { + showAlert(alertMsg: "Failed to delete folder", infoText: "\(error)") + return + } + } + + @MainActor + private func setIsCloning(_ newValue: Bool) { + self.isCloning = newValue + } + + private func getPath(saveName: String) -> URL? { + let dialog = NSSavePanel() + dialog.showsResizeIndicator = true + dialog.showsHiddenFiles = false + dialog.showsTagField = false + dialog.prompt = "Clone" + dialog.nameFieldStringValue = saveName + dialog.nameFieldLabel = "Clone as" + dialog.title = "Clone" + + guard dialog.runModal() == NSApplication.ModalResponse.OK, + let result = dialog.url else { + return nil + } + + return result + } + + private func showAlert(alertMsg: String, infoText: String) { + let alert = NSAlert() + alert.messageText = alertMsg + alert.informativeText = infoText + alert.addButton(withTitle: "OK") + alert.alertStyle = .warning + alert.runModal() + } +} diff --git a/CodeEdit/Features/Git/SourceControlManager.swift b/CodeEdit/Features/Git/SourceControlManager.swift new file mode 100644 index 000000000..76d1b1a5d --- /dev/null +++ b/CodeEdit/Features/Git/SourceControlManager.swift @@ -0,0 +1,219 @@ +// +// SourceControlModel.swift +// CodeEdit +// +// Created by Nanashi Li on 2022/05/20. +// + +import Foundation +import AppKit + +/// This model handle the fetching and adding of changes etc... +final class SourceControlManager: ObservableObject { + let gitClient: GitClient + + /// The base URL of the workspace + let workspaceURL: URL + + let editorManager: EditorManager + weak var fileManager: CEWorkspaceFileManager? + + /// A list of changed files + @Published var changedFiles: [CEWorkspaceFile] = [] + + /// Current branch + @Published var currentBranch: GitBranch? + + /// All branches, local and remote + @Published var branches: [GitBranch] = [] + + /// Files user selected to commit + @Published var filesToCommit: [CEWorkspaceFile.ID] = [] + + /// Number of unsynced commits with remote in current branch + @Published var numberOfUnsyncedCommits: Int = 0 + + init( + workspaceURL: URL, + editorManager: EditorManager + ) { + self.workspaceURL = workspaceURL + self.editorManager = editorManager + gitClient = GitClient(directoryURL: workspaceURL, shellClient: currentWorld.shellClient) + } + + /// Refresh all changed files and refresh status in file manager + func refresAllChangesFiles() async { + do { + var changedFiles: [CEWorkspaceFile] = [] + + for item in try await gitClient.getChangedFiles() { + changedFiles.append(.init(url: item.fileLink, changeType: item.changeType)) + } + + await setChangedFiles(changedFiles) + await refreshStatusInFileManager() + } catch { + await setChangedFiles([]) + } + } + + /// Set changed files on main actor + @MainActor + private func setChangedFiles(_ files: [CEWorkspaceFile]) { + self.changedFiles = files + } + + /// Refresh git status for files in project navigator + @MainActor + private func refreshStatusInFileManager() { + guard let fileManager = fileManager else { + return + } + + var updatedStatusFor: Set = [] + // Refresh status of file manager files + for changedFile in changedFiles { + guard let file = fileManager.flattenedFileItems[changedFile.id] else { + continue + } + if file.gitStatus != changedFile.gitStatus { + file.gitStatus = changedFile.gitStatus + updatedStatusFor.insert(file) + } + } + for (_, file) in fileManager.flattenedFileItems + where !updatedStatusFor.contains(file) && file.gitStatus != nil { + file.gitStatus = nil + updatedStatusFor.insert(file) + } + + if updatedStatusFor.isEmpty { + return + } + + fileManager.notifyObservers(updatedItems: updatedStatusFor) + } + + /// Refresh current branch + func refreshCurrentBranch() async { + let currentBranch = try? await gitClient.getCurrentBranch() + await MainActor.run { + self.currentBranch = currentBranch + } + } + + /// Refresh branches + func refreshBranches() async { + let branches = (try? await gitClient.getBranches()) ?? [] + await MainActor.run { + self.branches = branches + } + } + + /// Checkout branch + func checkoutBranch(branch: GitBranch) async throws { + try await gitClient.checkoutBranch(branch) + await refreshBranches() + await refreshCurrentBranch() + } + + /// Create new branch, can be created only from local branch + func newBranch(name: String, from: GitBranch) async throws { + if !from.isLocal { + return + } + + try await gitClient.newBranch(name: name, from: from) + await refreshBranches() + await refreshCurrentBranch() + } + + /// Delete branch if it's local and not current + func deleteBranch(branch: GitBranch) async throws { + if !branch.isLocal || branch == currentBranch { + return + } + + try await gitClient.deleteBranch(branch) + await refreshBranches() + } + + /// Discard changes for file + func discardChanges(for file: CEWorkspaceFile) { + Task { + do { + try await gitClient.discardChanges(for: file.url) + // TODO: Refresh content of active and unmodified document, + // requires CodeEditTextView changes + } catch { + await showAlertForError(title: "Failed to discard changes", error: error) + } + } + } + + /// Commit files selected by user + func commit(message: String) async throws { + var filesToCommit: [CEWorkspaceFile] = [] + for file in changedFiles where self.filesToCommit.contains(file.id) { + filesToCommit.append(file) + } + + if filesToCommit.isEmpty { + return + } + + try await gitClient.commit(filesToCommit, message: message) + + await MainActor.run { + self.filesToCommit = [] + } + + await self.refresAllChangesFiles() + await self.refreshNumberOfUnsyncedCommits() + } + + /// Refresh number of unsynced commits + func refreshNumberOfUnsyncedCommits() async { + let numberOfUnsyncedCommits = (try? await gitClient.numberOfUnsyncedCommits()) ?? 0 + + await MainActor.run { + self.numberOfUnsyncedCommits = numberOfUnsyncedCommits + } + } + + /// Push changes to remote + func push() async throws { + guard let currentBranch else { return } + + if currentBranch.upstream == nil { + try await gitClient.pushToRemote(upstream: currentBranch.name) + await refreshCurrentBranch() + } else { + try await gitClient.pushToRemote() + } + + await self.refreshNumberOfUnsyncedCommits() + } + + /// Show alert for error + func showAlertForError(title: String, error: Error) async { + if let error = error as? GitClient.GitClientError { + await showAlert(title: title, message: error.description) + return + } + + await showAlert(title: title, message: error.localizedDescription) + } + + private func showAlert(title: String, message: String) async { + await MainActor.run { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "OK") + alert.alertStyle = .warning + alert.runModal() + } + } +} diff --git a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorModel.swift b/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorModel.swift index ffbc19c0b..85ebc4e71 100644 --- a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorModel.swift +++ b/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorModel.swift @@ -8,9 +8,7 @@ import Foundation final class HistoryInspectorModel: ObservableObject { - - /// A GitClient instance - private(set) var gitClient: GitClient? + private(set) var sourceControlManager: SourceControlManager? /// The base URL of the workspace private(set) var workspaceURL: URL? @@ -21,31 +19,36 @@ final class HistoryInspectorModel: ObservableObject { /// The selected branch from the GitClient @Published var commitHistory: [GitCommit] = [] - func setWorkspace(url: URL?) { - if workspaceURL != url { - workspaceURL = url - updateCommitHistory() - } + func setWorkspace(sourceControlManager: SourceControlManager?) async { + self.sourceControlManager = sourceControlManager + await updateCommitHistory() } - func setFile(url: String?) { + func setFile(url: String?) async { if fileURL != url { fileURL = url - updateCommitHistory() + await updateCommitHistory() } } - func updateCommitHistory() { - guard let workspaceURL, let fileURL else { - commitHistory = [] + func updateCommitHistory() async { + guard let sourceControlManager, let fileURL else { + await setCommitHistory([]) return } - gitClient = GitClient(directoryURL: workspaceURL, shellClient: currentWorld.shellClient) + do { - let commitHistory = try gitClient?.getCommitHistory(entries: 40, fileLocalPath: fileURL) - self.commitHistory = commitHistory ?? [] + let commitHistory = try await sourceControlManager + .gitClient + .getCommitHistory(entries: 40, fileLocalPath: fileURL) + await setCommitHistory(commitHistory) } catch { - commitHistory = [] + await setCommitHistory([]) } } + + @MainActor + private func setCommitHistory(_ history: [GitCommit]) { + self.commitHistory = history + } } diff --git a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorView.swift b/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorView.swift index 81be86f95..c14e286d6 100644 --- a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorView.swift +++ b/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorView.swift @@ -23,7 +23,7 @@ struct HistoryInspectorView: View { var body: some View { Group { - if model.gitClient != nil { + if model.sourceControlManager != nil { VStack { if model.commitHistory.isEmpty { HistoryInspectorNoHistoryView() @@ -42,12 +42,19 @@ struct HistoryInspectorView: View { NoSelectionInspectorView() } } - .onReceive(editorManager.activeEditor.objectWillChange) { _ in - model.setFile(url: editorManager.activeEditor.selectedTab?.url.path) + .onChange(of: editorManager.activeEditor) { _ in + Task { + await model.setFile(url: editorManager.activeEditor.selectedTab?.url.path) + } + } + .onChange(of: editorManager.activeEditor.selectedTab) { _ in + Task { + await model.setFile(url: editorManager.activeEditor.selectedTab?.url.path) + } } - .onAppear { - model.setWorkspace(url: workspace.fileURL) - model.setFile(url: editorManager.activeEditor.selectedTab?.url.path) + .task { + await model.setWorkspace(sourceControlManager: workspace.sourceControlManager) + await model.setFile(url: editorManager.activeEditor.selectedTab?.url.path) } } } diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Model/SourceControlModel.swift b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Model/SourceControlModel.swift deleted file mode 100644 index 5dc1de304..000000000 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Model/SourceControlModel.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// SourceControlModel.swift -// CodeEdit -// -// Created by Nanashi Li on 2022/05/20. -// - -import Foundation - -/// This model handle the fetching and adding of changes etc... for the -/// Source Control Navigator -final class SourceControlModel: ObservableObject { - - /// A GitClient instance - let gitClient: GitClient - - /// The base URL of the workspace - let workspaceURL: URL - - /// A list of changed files - @Published var changed: [GitChangedFile] - - /// Initialize with a GitClient - /// - Parameter workspaceURL: the current workspace URL we also need this to open files in finder - /// - init(workspaceURL: URL) { - self.workspaceURL = workspaceURL - gitClient = GitClient(directoryURL: workspaceURL, shellClient: currentWorld.shellClient) - do { - changed = try gitClient.getChangedFiles() - } catch { - changed = [] - } - } -} diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/SourceControlNavigatorView.swift b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/SourceControlNavigatorView.swift index eb3a0660a..76123c8d8 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/SourceControlNavigatorView.swift +++ b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/SourceControlNavigatorView.swift @@ -8,7 +8,6 @@ import SwiftUI struct SourceControlNavigatorView: View { - @EnvironmentObject private var workspace: WorkspaceDocument @State private var selectedSection: Int = 0 @@ -28,14 +27,18 @@ struct SourceControlNavigatorView: View { Divider() } - if selectedSection == 0 { - if let urlString = workspace.fileURL { - SourceControlNavigatorChangesView(workspaceURL: urlString) + if let sourceControlManager = workspace.workspaceFileManager?.sourceControlManager { + if selectedSection == 0 { + SourceControlNavigatorChangesView( + sourceControlManager: sourceControlManager + ) } - } - if selectedSection == 1 { - SourceControlNavigatorRepositoriesView() + if selectedSection == 1 { + SourceControlNavigatorRepositoriesView( + sourceControlManager: sourceControlManager + ) + } } } .safeAreaInset(edge: .bottom, spacing: 0) { diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Changes/SourceControlNavigatorChangedFileView.swift b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Changes/SourceControlNavigatorChangedFileView.swift index 8d05886b8..d9c4ecdc6 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Changes/SourceControlNavigatorChangedFileView.swift +++ b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Changes/SourceControlNavigatorChangedFileView.swift @@ -7,62 +7,104 @@ import SwiftUI - struct SourceControlNavigatorChangedFileView: View { - - @State var changedFile: GitChangedFile - - @Binding var selection: GitChangedFile.ID? - - @State var workspaceURL: URL - - var body: some View { - HStack { - Image(systemName: changedFile.systemImage) - .frame(width: 11, height: 11) - .foregroundColor(selection == changedFile.id ? .white : changedFile.iconColor) - - Text(changedFile.fileName) - .font(.system(size: 11)) - .foregroundColor(selection == changedFile.id ? .white : .secondary) - - Text(changedFile.changeTypeValue) - .font(.system(size: 11)) - .foregroundColor(selection == changedFile.id ? .white : .secondary) - .frame(maxWidth: .infinity, alignment: .trailing) - } - .contextMenu { - Group { - Button("View in Finder") { - changedFile.showInFinder(workspaceURL: workspaceURL) - } - Button("Reveal in Project Navigator") {} - .disabled(true) // TODO: Implementation Needed - Divider() - } - Group { - Button("Open in New Tab") {} - .disabled(true) // TODO: Implementation Needed - Button("Open in New Window") {} - .disabled(true) // TODO: Implementation Needed - Button("Open with External Editor") {} - .disabled(true) // TODO: Implementation Needed - } - Group { - Divider() - Button("Commit \(changedFile.fileName)...") {} - .disabled(true) // TODO: Implementation Needed - Divider() - Button("Discard Changes in \(changedFile.fileName)...") {} - .disabled(true) // TODO: Implementation Needed - Divider() - } - Group { - Button("Add \(changedFile.fileName)") {} - .disabled(true) // TODO: Implementation Needed - Button("Mark \(changedFile.fileName) as Resolved") {} - .disabled(true) // TODO: Implementation Needed - } - } - .padding(.leading, 15) - } - } +struct SourceControlNavigatorChangedFileView: View { + @ObservedObject var sourceControlManager: SourceControlManager + 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 : String(relativePath) + } + + var body: some View { + HStack(spacing: 5) { + Toggle("", isOn: .init(get: getSelectedFileState, set: setSelectedFile)) + .labelsHidden() + + HStack(spacing: 5) { + Image(systemName: changedFile.systemImage) + .frame(width: 22) + .foregroundColor(changedFile.iconColor) + + Text(changedFile.name) + .font(.system(size: 13)) + + if let folder { + Text(folder) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(changedFile.gitStatus?.description ?? "") + .font(.system(size: 13)) + .foregroundColor(.secondary) + } + .clipShape(Rectangle()) + .onTapGesture { + toggleSelectedFileState() + } + } + .frame(height: 25) + .contextMenu { + Group { + Button("View in Finder") { + changedFile.showInFinder() + } + Button("Reveal in Project Navigator") {} + .disabled(true) // TODO: Implementation Needed + Divider() + } + Group { + Button("Open in New Tab") {} + .disabled(true) // TODO: Implementation Needed + Button("Open in New Window") {} + .disabled(true) // TODO: Implementation Needed + Button("Open with External Editor") {} + .disabled(true) // TODO: Implementation Needed + } + if changedFile.gitStatus == .modified { + Group { + Divider() + Button("Discard Changes in \(changedFile.name)...") { + sourceControlManager.discardChanges(for: changedFile) + } + Divider() + } + } + } + .padding(.horizontal) + } + + func toggleSelectedFileState() { + setSelectedFile(!getSelectedFileState()) + } + + func getSelectedFileState() -> Bool { + return sourceControlManager.filesToCommit.contains(changedFile.id) + } + + func setSelectedFile(_ newValue: Bool) { + if newValue { + sourceControlManager.filesToCommit.append(changedFile.id) + return + } + + guard let index = sourceControlManager.filesToCommit.firstIndex(of: changedFile.id) else { + return + } + + sourceControlManager.filesToCommit.remove(at: index) + } +} diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Changes/SourceControlNavigatorChangesCommitView.swift b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Changes/SourceControlNavigatorChangesCommitView.swift new file mode 100644 index 000000000..7562c473a --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Changes/SourceControlNavigatorChangesCommitView.swift @@ -0,0 +1,70 @@ +// +// SourceControlNavigatorChangesCommitView.swift +// CodeEdit +// +// Created by Albert Vinizhanau on 10/19/23. +// + +import SwiftUI + +struct SourceControlNavigatorChangesCommitView: View { + @ObservedObject var sourceControlManager: SourceControlManager + @State private var message: String = "" + @State private var isCommiting: Bool = false + + var body: some View { + VStack { + VStack { + Text("Commit") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + VStack { + TextEditor(text: $message) + .scrollContentBackground(.hidden) + } + .padding(.vertical, 10) + .padding(.horizontal, 5) + .frame(height: 60) + .background() + .clipShape(.rect(cornerRadius: 5)) + .overlay( + RoundedRectangle(cornerRadius: 5) + .stroke(Color(NSColor.separatorColor), lineWidth: 0.5) + ) + .font(.body) + + Button { + Task { + self.isCommiting = true + do { + try await sourceControlManager.commit(message: message) + self.message = "" + } catch { + await sourceControlManager.showAlertForError(title: "Failed to commit", error: error) + } + self.isCommiting = false + } + } label: { + HStack { + Spacer() + Text( + isCommiting + ? "Committing..." + : "Commit" + ) + Spacer() + } + } + .disabled( + message.isEmpty || + sourceControlManager.filesToCommit.isEmpty || + isCommiting + ) + } + .padding(.horizontal) + .padding(.vertical, 5) + Divider() + } + } +} diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Changes/SourceControlNavigatorChangesView.swift b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Changes/SourceControlNavigatorChangesView.swift index 68d2c782b..052c762c4 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Changes/SourceControlNavigatorChangesView.swift +++ b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Changes/SourceControlNavigatorChangesView.swift @@ -8,39 +8,43 @@ import SwiftUI struct SourceControlNavigatorChangesView: View { + @ObservedObject var sourceControlManager: SourceControlManager - @ObservedObject var model: SourceControlModel - - @State var selectedFile: GitChangedFile.ID? - - /// Initialize with GitClient - /// - Parameter gitClient: a GitClient - init(workspaceURL: URL) { - self.model = .init(workspaceURL: workspaceURL) + var showSyncView: Bool { + sourceControlManager.numberOfUnsyncedCommits > 0 || + (sourceControlManager.currentBranch != nil && sourceControlManager.currentBranch?.upstream == nil) } var body: some View { VStack(alignment: .center) { - if model.changed.isEmpty { - Text("No Changes") - .font(.system(size: 16)) - .foregroundColor(.secondary) + if sourceControlManager.changedFiles.isEmpty { + if showSyncView { + SourceControlNavigatorSyncView(sourceControlManager: sourceControlManager) + } else { + Text("No Changes") + .font(.system(size: 16)) + .foregroundColor(.secondary) + } } else { - List(selection: $selectedFile) { - Section("Local Changes") { - ForEach(model.changed) { file in + SourceControlNavigatorChangesCommitView( + sourceControlManager: sourceControlManager + ) + ScrollView { + LazyVStack(spacing: 0) { + ForEach(sourceControlManager.changedFiles) { file in SourceControlNavigatorChangedFileView( - changedFile: file, - selection: $selectedFile, - workspaceURL: model.workspaceURL + sourceControlManager: sourceControlManager, + changedFile: file ) } } - .foregroundColor(.secondary) } - .listStyle(.sidebar) } } .frame(maxHeight: .infinity) + .task { + await sourceControlManager.refresAllChangesFiles() + await sourceControlManager.refreshNumberOfUnsyncedCommits() + } } } diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Changes/SourceControlNavigatorSyncView.swift b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Changes/SourceControlNavigatorSyncView.swift new file mode 100644 index 000000000..3491ceb4b --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Changes/SourceControlNavigatorSyncView.swift @@ -0,0 +1,68 @@ +// +// SourceControlNavigatorSyncView.swift +// CodeEdit +// +// Created by Albert Vinizhanau on 10/20/23. +// + +import SwiftUI + +struct SourceControlNavigatorSyncView: View { + @ObservedObject var sourceControlManager: SourceControlManager + @State private var isSyncing: Bool = false + + var body: some View { + VStack { + Button { + self.sync() + } label: { + HStack { + Spacer() + if isSyncing { + Text("Syncing...") + } else { + Label( + title, + systemImage: icon + ) + } + Spacer() + } + } + .buttonStyle(.borderedProminent) + .disabled(isSyncing) + + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 5) + } + + var title: String { + if sourceControlManager.numberOfUnsyncedCommits > 0 { + return "Sync Changes \(sourceControlManager.numberOfUnsyncedCommits)" + } + + return "Publish Branch" + } + + var icon: String { + if sourceControlManager.numberOfUnsyncedCommits > 0 { + return "arrow.triangle.2.circlepath" + } + + return "arrowshape.up.circle" + } + + func sync() { + Task(priority: .background) { + self.isSyncing = true + do { + try await sourceControlManager.push() + } catch { + await sourceControlManager.showAlertForError(title: "Failed to sync", error: error) + } + self.isSyncing = false + } + } +} diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Repositories/SourceControlNavigatorBranchGroupView.swift b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Repositories/SourceControlNavigatorBranchGroupView.swift new file mode 100644 index 000000000..efa3acb93 --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Repositories/SourceControlNavigatorBranchGroupView.swift @@ -0,0 +1,35 @@ +// +// SourceControlNavigatorBranchGroupView.swift +// CodeEdit +// +// Created by Albert Vinizhanau on 10/21/23. +// + +import SwiftUI + +struct SourceControlNavigatorBranchGroupView: View { + let sourceControlManager: SourceControlManager + let branches: [GitBranch] + let name: String + var icon: String = "opticaldiscdrive.fill" + @State var isExpanded: Bool = false + + var body: some View { + DisclosureGroup(isExpanded: $isExpanded) { + VStack(spacing: 0) { + ForEach(branches, id: \.self) { branch in + SourceControlNavigatorBranchView( + sourceControlManager: sourceControlManager, + branch: branch + ) + } + } + } label: { + HStack { + Image(systemName: icon) + .foregroundStyle(.secondary) + Text(name) + } + } + } +} diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Repositories/SourceControlNavigatorBranchView.swift b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Repositories/SourceControlNavigatorBranchView.swift new file mode 100644 index 000000000..99c119d9c --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Repositories/SourceControlNavigatorBranchView.swift @@ -0,0 +1,66 @@ +// +// SourceControlNavigatorBranchView.swift +// CodeEdit +// +// Created by Albert Vinizhanau on 10/21/23. +// + +import SwiftUI + +struct SourceControlNavigatorBranchView: View { + @ObservedObject var sourceControlManager: SourceControlManager + @State var showNewBranch: Bool = false + let branch: GitBranch + + var body: some View { + HStack { + Image(systemName: "arrow.branch") + .foregroundStyle(.secondary) + Text(branch.name) + + if sourceControlManager.currentBranch == branch { + Text("(current)") + .foregroundStyle(.secondary) + } + + Spacer() + } + .padding(.leading, 20) + .frame(height: 25) + .sheet(isPresented: $showNewBranch, content: { + SourceControlNavigatorNewBranchView( + sourceControlManager: sourceControlManager, + fromBranch: branch + ) + }) + .contextMenu { + Button("Checkout") { + Task { + do { + try await sourceControlManager.checkoutBranch(branch: branch) + } catch { + await sourceControlManager.showAlertForError(title: "Failed to checkout", error: error) + } + } + } + if branch.isLocal { + Divider() + Button("New Branch from \"\(branch.name)\"") { + showNewBranch = true + } + } + if branch.isLocal && sourceControlManager.currentBranch != branch { + Divider() + Button("Delete") { + Task { + do { + try await sourceControlManager.deleteBranch(branch: branch) + } catch { + await sourceControlManager.showAlertForError(title: "Failed to delete", error: error) + } + } + } + } + } + } +} diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Repositories/SourceControlNavigatorNewBranchView.swift b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Repositories/SourceControlNavigatorNewBranchView.swift new file mode 100644 index 000000000..6957f9ce0 --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Repositories/SourceControlNavigatorNewBranchView.swift @@ -0,0 +1,54 @@ +// +// SourceControlNavigatorNewBranchView.swift +// CodeEdit +// +// Created by Albert Vinizhanau on 10/21/23. +// + +import SwiftUI + +struct SourceControlNavigatorNewBranchView: View { + @Environment(\.dismiss) + var dismiss + + @State var name: String = "" + let sourceControlManager: SourceControlManager + let fromBranch: GitBranch + + var body: some View { + NavigationStack { + TextField("New Branch Name", text: $name) + .textFieldStyle(.roundedBorder) + .padding() + } + .navigationTitle("Create Branch from \(fromBranch.name)") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Create") { + createBranch() + } + .buttonStyle(.borderedProminent) + .disabled(name.isEmpty) + } + } + .frame(width: 300) + } + + func createBranch() { + Task { + do { + try await sourceControlManager.newBranch(name: name, from: fromBranch) + await MainActor.run { + dismiss() + } + } catch { + await sourceControlManager.showAlertForError(title: "Failed to create branch", error: error) + } + } + } +} diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Repositories/SourceControlNavigatorRepositoriesView.swift b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Repositories/SourceControlNavigatorRepositoriesView.swift index 8b48914bb..b18dfcdf4 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Repositories/SourceControlNavigatorRepositoriesView.swift +++ b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/Repositories/SourceControlNavigatorRepositoriesView.swift @@ -8,10 +8,30 @@ import SwiftUI struct SourceControlNavigatorRepositoriesView: View { + @ObservedObject var sourceControlManager: SourceControlManager + var body: some View { - VStack(alignment: .center) { - Text("Needs Implementation") + ScrollView { + VStack(spacing: 0) { + SourceControlNavigatorBranchGroupView( + sourceControlManager: sourceControlManager, + branches: sourceControlManager.branches.filter({ $0.isLocal }), + name: "Branches", + isExpanded: true + ) + + SourceControlNavigatorBranchGroupView( + sourceControlManager: sourceControlManager, + branches: sourceControlManager.branches.filter({ $0.isRemote }), + name: "Remotes", + icon: "globe" + ) + } } .frame(maxHeight: .infinity) + .padding(.horizontal) + .task { + await sourceControlManager.refreshBranches() + } } } diff --git a/CodeEdit/Features/Welcome/Views/WelcomeView.swift b/CodeEdit/Features/Welcome/Views/WelcomeView.swift index c943bf813..ca5357284 100644 --- a/CodeEdit/Features/Welcome/Views/WelcomeView.swift +++ b/CodeEdit/Features/Welcome/Views/WelcomeView.swift @@ -19,11 +19,9 @@ struct WelcomeView: View { @AppSettings(\.general.reopenBehavior) var reopenBehavior - @State private var repoPath = "~/" - @State var showGitClone = false - @State var showCheckoutBranch = false + @State var showCheckoutBranchItem: URL? @State var isHovering: Bool = false @@ -32,15 +30,12 @@ struct WelcomeView: View { private let openDocument: (URL?, @escaping () -> Void) -> Void private let newDocument: () -> Void private let dismissWindow: () -> Void - private let shellClient: ShellClient init( - shellClient: ShellClient, openDocument: @escaping (URL?, @escaping () -> Void) -> Void, newDocument: @escaping () -> Void, dismissWindow: @escaping () -> Void ) { - self.shellClient = shellClient self.openDocument = openDocument self.newDocument = newDocument self.dismissWindow = dismissWindow @@ -120,19 +115,22 @@ struct WelcomeView: View { } .sheet(isPresented: $showGitClone) { GitCloneView( - shellClient: shellClient, - isPresented: $showGitClone, - showCheckout: $showCheckoutBranch, - repoPath: $repoPath + openBranchView: { url in + showCheckoutBranchItem = url + }, + openDocument: { url in + openDocument(url, dismissWindow) + } ) } - .sheet(isPresented: $showCheckoutBranch) { + .sheet(item: $showCheckoutBranchItem, content: { repoPath in GitCheckoutBranchView( - isPresented: $showCheckoutBranch, - repoPath: $repoPath, - shellClient: shellClient + repoLocalPath: repoPath, + openDocument: { url in + openDocument(url, dismissWindow) + } ) - } + }) } private var mainContent: some View { diff --git a/CodeEdit/Features/Welcome/Views/WelcomeWindow.swift b/CodeEdit/Features/Welcome/Views/WelcomeWindow.swift index 3ee52a100..399816919 100644 --- a/CodeEdit/Features/Welcome/Views/WelcomeWindow.swift +++ b/CodeEdit/Features/Welcome/Views/WelcomeWindow.swift @@ -35,7 +35,7 @@ struct WelcomeWindow: Scene { var openWindow var body: some View { - WelcomeWindowView(shellClient: currentWorld.shellClient) { url, opened in + WelcomeWindowView { url, opened in if let url { CodeEditDocumentController.shared.openDocument(withContentsOf: url, display: true) { doc, _, _ in if doc != nil { diff --git a/CodeEdit/Features/Welcome/Views/WelcomeWindowView.swift b/CodeEdit/Features/Welcome/Views/WelcomeWindowView.swift index 06a13710c..378f93084 100644 --- a/CodeEdit/Features/Welcome/Views/WelcomeWindowView.swift +++ b/CodeEdit/Features/Welcome/Views/WelcomeWindowView.swift @@ -11,15 +11,12 @@ struct WelcomeWindowView: View { private let openDocument: (URL?, @escaping () -> Void) -> Void private let newDocument: () -> Void private let dismissWindow: () -> Void - private let shellClient: ShellClient init( - shellClient: ShellClient, openDocument: @escaping (URL?, @escaping () -> Void) -> Void, newDocument: @escaping () -> Void, dismissWindow: @escaping () -> Void ) { - self.shellClient = shellClient self.openDocument = openDocument self.newDocument = newDocument self.dismissWindow = dismissWindow @@ -28,7 +25,6 @@ struct WelcomeWindowView: View { var body: some View { HStack(spacing: 0) { WelcomeView( - shellClient: shellClient, openDocument: openDocument, newDocument: newDocument, dismissWindow: dismissWindow diff --git a/CodeEdit/Utils/ShellClient/Models/ShellClient.swift b/CodeEdit/Utils/ShellClient/Models/ShellClient.swift index 107d37694..70c1eaad6 100644 --- a/CodeEdit/Utils/ShellClient/Models/ShellClient.swift +++ b/CodeEdit/Utils/ShellClient/Models/ShellClient.swift @@ -77,6 +77,42 @@ class ShellClient { return subject.eraseToAnyPublisher() } + /// Run a command with AsyncStream + /// - Parameter args: command to run + /// - Returns: async stream of command output + func runAsync(_ args: String...) -> AsyncThrowingStream { + let (task, pipe) = generateProcessAndPipe(args) + + return AsyncThrowingStream { continuation in + pipe.fileHandleForReading.readabilityHandler = { [unowned pipe] fileHandle in + let data = fileHandle.availableData + if !data.isEmpty { + if let line = String(data: data, encoding: .utf8)?.split(whereSeparator: \.isNewline) { + line.map(String.init).forEach({ continuation.yield($0) }) + } + } else { + if !task.isRunning && task.terminationStatus != 0 { + continuation.finish( + throwing: NSError(domain: "ShellClient", code: Int(task.terminationStatus)) + ) + } else { + continuation.finish() + } + + // Clean up the handler to prevent repeated calls and continuation finishes for the same + // process. + pipe.fileHandleForReading.readabilityHandler = nil + } + } + + do { + try task.run() + } catch { + continuation.finish(throwing: error) + } + } + } + /// Shell client /// - Returns: description static func live() -> ShellClient { diff --git a/CodeEdit/WindowSplitView.swift b/CodeEdit/WindowSplitView.swift index f10a3491a..36dae71bd 100644 --- a/CodeEdit/WindowSplitView.swift +++ b/CodeEdit/WindowSplitView.swift @@ -44,7 +44,6 @@ struct WindowSplitView: View { .toolbar { ToolbarItem(id: "com.apple.SwiftUI.navigationSplitView.toggleSidebar") { ToolbarBranchPicker( - shellClient: currentWorld.shellClient, workspaceFileManager: workspace.workspaceFileManager ) } diff --git a/CodeEditTests/Features/CodeEditUI/CodeEditUITests.swift b/CodeEditTests/Features/CodeEditUI/CodeEditUITests.swift index 73fccef97..335eef00d 100644 --- a/CodeEditTests/Features/CodeEditUI/CodeEditUITests.swift +++ b/CodeEditTests/Features/CodeEditUI/CodeEditUITests.swift @@ -87,7 +87,6 @@ final class CodeEditUIUnitTests: XCTestCase { func testBranchPickerLight() throws { let view = ToolbarBranchPicker( - shellClient: ShellClient(), workspaceFileManager: nil ) let hosting = NSHostingView(rootView: view) @@ -98,7 +97,6 @@ final class CodeEditUIUnitTests: XCTestCase { func testBranchPickerDark() throws { let view = ToolbarBranchPicker( - shellClient: ShellClient(), workspaceFileManager: nil ) let hosting = NSHostingView(rootView: view) diff --git a/CodeEditTests/Utils/CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift b/CodeEditTests/Utils/CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift index 3b21c5805..678d42385 100644 --- a/CodeEditTests/Utils/CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift +++ b/CodeEditTests/Utils/CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift @@ -53,7 +53,8 @@ final class CEWorkspaceFileManagerUnitTests: XCTestCase { } let client = CEWorkspaceFileManager( folderUrl: directory, - ignoredFilesAndFolders: [] + ignoredFilesAndFolders: [], + sourceControlManager: nil ) // Compare to flattened files - 1 cause root is in there @@ -64,7 +65,8 @@ final class CEWorkspaceFileManagerUnitTests: XCTestCase { func testDirectoryChanges() throws { let client = CEWorkspaceFileManager( folderUrl: directory, - ignoredFilesAndFolders: [] + ignoredFilesAndFolders: [], + sourceControlManager: nil ) let newFile = generateRandomFiles(amount: 1)[0] @@ -113,7 +115,11 @@ final class CEWorkspaceFileManagerUnitTests: XCTestCase { try FileManager.default.createDirectory(at: testDirectoryURL, withIntermediateDirectories: true) try "".write(to: testFileURL, atomically: true, encoding: .utf8) - let fileManger = CEWorkspaceFileManager(folderUrl: directory, ignoredFilesAndFolders: []) + let fileManger = CEWorkspaceFileManager( + folderUrl: directory, + ignoredFilesAndFolders: [], + sourceControlManager: nil + ) XCTAssert(fileManger.getFile(testFileURL.path()) == nil) XCTAssert(fileManger.childrenOfFile(CEWorkspaceFile(url: testFileURL)) == nil) @@ -125,7 +131,11 @@ final class CEWorkspaceFileManagerUnitTests: XCTestCase { let testFileURL = directory.appending(path: "file.txt") try "".write(to: testFileURL, atomically: true, encoding: .utf8) - let fileManger = CEWorkspaceFileManager(folderUrl: directory, ignoredFilesAndFolders: []) + let fileManger = CEWorkspaceFileManager( + folderUrl: directory, + ignoredFilesAndFolders: [], + sourceControlManager: nil + ) XCTAssert(fileManger.getFile(testFileURL.path()) != nil) XCTAssert(FileManager.default.fileExists(atPath: testFileURL.path()) == true) fileManger.delete(file: CEWorkspaceFile(url: testFileURL), confirmDelete: false) @@ -137,7 +147,11 @@ final class CEWorkspaceFileManagerUnitTests: XCTestCase { let testDuplicatedFileURL = directory.appendingPathComponent("file copy.txt") try "😄".write(to: testFileURL, atomically: true, encoding: .utf8) - let fileManger = CEWorkspaceFileManager(folderUrl: directory, ignoredFilesAndFolders: []) + let fileManger = CEWorkspaceFileManager( + folderUrl: directory, + ignoredFilesAndFolders: [], + sourceControlManager: nil + ) XCTAssert(fileManger.getFile(testFileURL.path()) != nil) XCTAssert(FileManager.default.fileExists(atPath: testFileURL.path()) == true) fileManger.duplicate(file: CEWorkspaceFile(url: testFileURL))