diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index fda81b378..95d9fca79 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -13,8 +13,8 @@ 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 */; }; - 04C3255B2801F86400C8DA2D /* OutlineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 285FEC6D27FE4B4A00E57D53 /* OutlineViewController.swift */; }; - 04C3255C2801F86900C8DA2D /* OutlineMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 285FEC7127FE4EEF00E57D53 /* OutlineMenu.swift */; }; + 04C3255B2801F86400C8DA2D /* ProjectNavigatorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 285FEC6D27FE4B4A00E57D53 /* ProjectNavigatorViewController.swift */; }; + 04C3255C2801F86900C8DA2D /* ProjectNavigatorMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 285FEC7127FE4EEF00E57D53 /* ProjectNavigatorMenu.swift */; }; 200412EF280F3EAC00BCAF5C /* HistoryInspectorNoHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200412EE280F3EAC00BCAF5C /* HistoryInspectorNoHistoryView.swift */; }; 201169D72837B2E300F92B46 /* SourceControlNavigatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201169D62837B2E300F92B46 /* SourceControlNavigatorView.swift */; }; 201169D92837B31200F92B46 /* SourceControlSearchToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201169D82837B31200F92B46 /* SourceControlSearchToolbar.swift */; }; @@ -44,23 +44,21 @@ 2816F594280CF50500DD548B /* CodeEditSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = 2816F593280CF50500DD548B /* CodeEditSymbols */; }; 283BDCBD2972EEBD002AFF81 /* Package.resolved in Resources */ = {isa = PBXBuildFile; fileRef = 283BDCBC2972EEBD002AFF81 /* Package.resolved */; }; 283BDCC52972F236002AFF81 /* AcknowledgementsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283BDCC42972F236002AFF81 /* AcknowledgementsTests.swift */; }; - 2847019E27FDDF7600F87B6B /* OutlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2847019D27FDDF7600F87B6B /* OutlineView.swift */; }; + 2847019E27FDDF7600F87B6B /* ProjectNavigatorOutlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2847019D27FDDF7600F87B6B /* ProjectNavigatorOutlineView.swift */; }; 284DC84F2978B7B400BF2770 /* ContributorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 284DC84E2978B7B400BF2770 /* ContributorsView.swift */; }; 284DC8512978BA2600BF2770 /* .all-contributorsrc in Resources */ = {isa = PBXBuildFile; fileRef = 284DC8502978BA2600BF2770 /* .all-contributorsrc */; }; - 285FEC7027FE4B9800E57D53 /* OutlineTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 285FEC6F27FE4B9800E57D53 /* OutlineTableViewCell.swift */; }; + 285FEC7027FE4B9800E57D53 /* ProjectNavigatorTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 285FEC6F27FE4B9800E57D53 /* ProjectNavigatorTableViewCell.swift */; }; 286471AB27ED51FD0039369D /* ProjectNavigatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 286471AA27ED51FD0039369D /* ProjectNavigatorView.swift */; }; 287776E927E34BC700D46668 /* TabBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 287776E827E34BC700D46668 /* TabBarView.swift */; }; - 287776EF27E3515300D46668 /* TabBarItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 287776EE27E3515300D46668 /* TabBarItemView.swift */; }; 2897E1C72979A29200741E32 /* OffsettableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2897E1C62979A29200741E32 /* OffsettableScrollView.swift */; }; 28A51001281673530087B0CC /* codeedit-xcode-dark.json in Resources */ = {isa = PBXBuildFile; fileRef = 28A50FFF281673530087B0CC /* codeedit-xcode-dark.json */; }; 28A51002281673530087B0CC /* codeedit-xcode-light.json in Resources */ = {isa = PBXBuildFile; fileRef = 28A51000281673530087B0CC /* codeedit-xcode-light.json */; }; 28A51005281701B40087B0CC /* codeedit-github-light.json in Resources */ = {isa = PBXBuildFile; fileRef = 28A51003281701B40087B0CC /* codeedit-github-light.json */; }; 28A51006281701B40087B0CC /* codeedit-github-dark.json in Resources */ = {isa = PBXBuildFile; fileRef = 28A51004281701B40087B0CC /* codeedit-github-dark.json */; }; - 28B0A19827E385C300B73177 /* NavigatorSidebarToolbarTop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28B0A19727E385C300B73177 /* NavigatorSidebarToolbarTop.swift */; }; + 28B0A19827E385C300B73177 /* NavigatorSidebarToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28B0A19727E385C300B73177 /* NavigatorSidebarToolbar.swift */; }; 28B8F884280FFE4600596236 /* NSTableView+Background.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28B8F883280FFE4600596236 /* NSTableView+Background.swift */; }; 28F43DE029738792008BBA45 /* codeedit-solarized-dark.json in Resources */ = {isa = PBXBuildFile; fileRef = 28F43DDF29738792008BBA45 /* codeedit-solarized-dark.json */; }; 28F43DE2297388C5008BBA45 /* codeedit-solarized-light.json in Resources */ = {isa = PBXBuildFile; fileRef = 28F43DE1297388C5008BBA45 /* codeedit-solarized-light.json */; }; - 28FFE1BF27E3A441001939DB /* NavigatorSidebarToolbarBottom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FFE1BE27E3A441001939DB /* NavigatorSidebarToolbarBottom.swift */; }; 2B7A583527E4BA0100D25D4E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0468438427DC76E200F8E88E /* AppDelegate.swift */; }; 2B7AC06B282452FB0082A5B8 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2B7AC06A282452FB0082A5B8 /* Media.xcassets */; }; 2BE487EF28245162003F3F64 /* FinderSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BE487EE28245162003F3F64 /* FinderSync.swift */; }; @@ -69,6 +67,10 @@ 4E7F066629602E7B00BB3C12 /* CodeEditSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E7F066529602E7B00BB3C12 /* CodeEditSplitViewController.swift */; }; 4EE96ECB2960565E00FFBEA8 /* DocumentsUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE96ECA2960565E00FFBEA8 /* DocumentsUnitTests.swift */; }; 4EE96ECE296059E000FFBEA8 /* NSHapticFeedbackPerformerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE96ECD296059E000FFBEA8 /* NSHapticFeedbackPerformerMock.swift */; }; + 581550CF29FBD30400684881 /* StandardTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581550CC29FBD30400684881 /* StandardTableViewCell.swift */; }; + 581550D029FBD30400684881 /* FileSystemTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581550CD29FBD30400684881 /* FileSystemTableViewCell.swift */; }; + 581550D129FBD30400684881 /* TextTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581550CE29FBD30400684881 /* TextTableViewCell.swift */; }; + 581550D429FBD37D00684881 /* ProjectNavigatorToolbarBottom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581550D329FBD37D00684881 /* ProjectNavigatorToolbarBottom.swift */; }; 581BFB672926431000D251EC /* WelcomeWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581BFB5A2926431000D251EC /* WelcomeWindowView.swift */; }; 581BFB682926431000D251EC /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581BFB5B2926431000D251EC /* WelcomeView.swift */; }; 581BFB692926431000D251EC /* WelcomeActionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581BFB5C2926431000D251EC /* WelcomeActionView.swift */; }; @@ -88,6 +90,7 @@ 583E529729361B39001AB554 /* testEffectViewDark.1.png in Resources */ = {isa = PBXBuildFile; fileRef = 583E528329361B39001AB554 /* testEffectViewDark.1.png */; }; 583E529829361B39001AB554 /* testBranchPickerLight.1.png in Resources */ = {isa = PBXBuildFile; fileRef = 583E528429361B39001AB554 /* testBranchPickerLight.1.png */; }; 583E529C29361BAB001AB554 /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 583E529B29361BAB001AB554 /* SnapshotTesting */; }; + 58710159298EB80000951BA4 /* CEWorkspaceFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58710158298EB80000951BA4 /* CEWorkspaceFileManager.swift */; }; 5878DA82291863F900DD95A3 /* AcknowledgementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878DA81291863F900DD95A3 /* AcknowledgementsView.swift */; }; 5878DA842918642000DD95A3 /* ParsePackagesResolved.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878DA832918642000DD95A3 /* ParsePackagesResolved.swift */; }; 5878DA872918642F00DD95A3 /* AcknowledgementsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878DA862918642F00DD95A3 /* AcknowledgementsViewModel.swift */; }; @@ -193,12 +196,7 @@ 587B9E9829301D8F00AC7927 /* GitCommit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B9E5329301D8F00AC7927 /* GitCommit.swift */; }; 587B9E9929301D8F00AC7927 /* GitChangedFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B9E5429301D8F00AC7927 /* GitChangedFile.swift */; }; 587B9E9A29301D8F00AC7927 /* GitType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B9E5529301D8F00AC7927 /* GitType.swift */; }; - 587D9B732933BF5700BF7490 /* FileIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587D9B6D2933BF5700BF7490 /* FileIcon.swift */; }; - 587D9B742933BF5700BF7490 /* FileItem+Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587D9B6E2933BF5700BF7490 /* FileItem+Array.swift */; }; - 587D9B752933BF5700BF7490 /* FileItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587D9B6F2933BF5700BF7490 /* FileItem.swift */; }; - 587D9B762933BF5700BF7490 /* Mocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587D9B702933BF5700BF7490 /* Mocks.swift */; }; - 587D9B772933BF5700BF7490 /* Live.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587D9B712933BF5700BF7490 /* Live.swift */; }; - 587D9B782933BF5700BF7490 /* Interface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587D9B722933BF5700BF7490 /* Interface.swift */; }; + 587FB99029C1246400B519DD /* TabBarItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587FB98F29C1246400B519DD /* TabBarItemView.swift */; }; 58822524292C280D00E83CDE /* StatusBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58822509292C280D00E83CDE /* StatusBarView.swift */; }; 58822525292C280D00E83CDE /* StatusBarMenuStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5882250B292C280D00E83CDE /* StatusBarMenuStyle.swift */; }; 58822526292C280D00E83CDE /* StatusBarBreakpointButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5882250C292C280D00E83CDE /* StatusBarBreakpointButton.swift */; }; @@ -216,6 +214,10 @@ 58822532292C280D00E83CDE /* StatusBarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5882251C292C280D00E83CDE /* StatusBarViewModel.swift */; }; 58822533292C280D00E83CDE /* StatusBarTabType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5882251D292C280D00E83CDE /* StatusBarTabType.swift */; }; 58822534292C280D00E83CDE /* CursorLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5882251E292C280D00E83CDE /* CursorLocation.swift */; }; + 588847632992A2A200996D95 /* CEWorkspaceFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588847622992A2A200996D95 /* CEWorkspaceFile.swift */; }; + 588847692992ABCA00996D95 /* Array+CEWorkspaceFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588847682992ABCA00996D95 /* Array+CEWorkspaceFile.swift */; }; + 5894E59729FEF7740077E59C /* CEWorkspaceFile+Recursion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5894E59629FEF7740077E59C /* CEWorkspaceFile+Recursion.swift */; }; + 58A2E40C29C3975D005CB615 /* CEWorkspaceFileIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A2E40629C3975D005CB615 /* CEWorkspaceFileIcon.swift */; }; 58A5DF7D2931787A00D1BD5D /* ShellClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A5DF7C2931787A00D1BD5D /* ShellClient.swift */; }; 58A5DF8029325B5A00D1BD5D /* GitClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A5DF7F29325B5A00D1BD5D /* GitClient.swift */; }; 58A5DFA229339F6400D1BD5D /* KeybindingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A5DF9E29339F6400D1BD5D /* KeybindingManager.swift */; }; @@ -353,7 +355,7 @@ D7012EE827E757850001E1EF /* FindNavigatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7012EE727E757850001E1EF /* FindNavigatorView.swift */; }; D7211D4327E066CE008F2ED7 /* Localized+Ex.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7211D4227E066CE008F2ED7 /* Localized+Ex.swift */; }; D7211D4727E06BFE008F2ED7 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D7211D4927E06BFE008F2ED7 /* Localizable.strings */; }; - D7DC4B76298FFBE900D6C83D /* OutlintViewController+OutlineTableViewCellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DC4B75298FFBE900D6C83D /* OutlintViewController+OutlineTableViewCellDelegate.swift */; }; + D7DC4B76298FFBE900D6C83D /* ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DC4B75298FFBE900D6C83D /* ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift */; }; D7E201AE27E8B3C000CB86D0 /* String+Ranges.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E201AD27E8B3C000CB86D0 /* String+Ranges.swift */; }; D7E201B027E8C07300CB86D0 /* FindNavigatorSearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E201AF27E8C07300CB86D0 /* FindNavigatorSearchBar.swift */; }; D7E201B227E8D50000CB86D0 /* FindNavigatorModeSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E201B127E8D50000CB86D0 /* FindNavigatorModeSelector.swift */; }; @@ -449,26 +451,24 @@ 2806E903297958B9000040F4 /* ContributorRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContributorRowView.swift; sourceTree = ""; }; 283BDCBC2972EEBD002AFF81 /* Package.resolved */ = {isa = PBXFileReference; lastKnownFileType = text; name = Package.resolved; path = CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved; sourceTree = ""; }; 283BDCC42972F236002AFF81 /* AcknowledgementsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcknowledgementsTests.swift; sourceTree = ""; }; - 2847019D27FDDF7600F87B6B /* OutlineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutlineView.swift; sourceTree = ""; }; + 2847019D27FDDF7600F87B6B /* ProjectNavigatorOutlineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectNavigatorOutlineView.swift; sourceTree = ""; }; 284DC84E2978B7B400BF2770 /* ContributorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContributorsView.swift; sourceTree = ""; }; 284DC8502978BA2600BF2770 /* .all-contributorsrc */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = ".all-contributorsrc"; sourceTree = ""; }; - 285FEC6D27FE4B4A00E57D53 /* OutlineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutlineViewController.swift; sourceTree = ""; }; - 285FEC6F27FE4B9800E57D53 /* OutlineTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutlineTableViewCell.swift; sourceTree = ""; }; - 285FEC7127FE4EEF00E57D53 /* OutlineMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutlineMenu.swift; sourceTree = ""; }; + 285FEC6D27FE4B4A00E57D53 /* ProjectNavigatorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectNavigatorViewController.swift; sourceTree = ""; }; + 285FEC6F27FE4B9800E57D53 /* ProjectNavigatorTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectNavigatorTableViewCell.swift; sourceTree = ""; }; + 285FEC7127FE4EEF00E57D53 /* ProjectNavigatorMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectNavigatorMenu.swift; sourceTree = ""; }; 286471AA27ED51FD0039369D /* ProjectNavigatorView.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = ProjectNavigatorView.swift; sourceTree = ""; tabWidth = 4; }; 287776E627E3413200D46668 /* NavigatorSidebarView.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = NavigatorSidebarView.swift; sourceTree = ""; tabWidth = 4; }; 287776E827E34BC700D46668 /* TabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarView.swift; sourceTree = ""; }; - 287776EE27E3515300D46668 /* TabBarItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarItemView.swift; sourceTree = ""; }; 2897E1C62979A29200741E32 /* OffsettableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffsettableScrollView.swift; sourceTree = ""; }; 28A50FFF281673530087B0CC /* codeedit-xcode-dark.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "codeedit-xcode-dark.json"; sourceTree = ""; }; 28A51000281673530087B0CC /* codeedit-xcode-light.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "codeedit-xcode-light.json"; sourceTree = ""; }; 28A51003281701B40087B0CC /* codeedit-github-light.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "codeedit-github-light.json"; sourceTree = ""; }; 28A51004281701B40087B0CC /* codeedit-github-dark.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "codeedit-github-dark.json"; sourceTree = ""; }; - 28B0A19727E385C300B73177 /* NavigatorSidebarToolbarTop.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = NavigatorSidebarToolbarTop.swift; sourceTree = ""; tabWidth = 4; }; + 28B0A19727E385C300B73177 /* NavigatorSidebarToolbar.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = NavigatorSidebarToolbar.swift; sourceTree = ""; tabWidth = 4; }; 28B8F883280FFE4600596236 /* NSTableView+Background.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSTableView+Background.swift"; sourceTree = ""; }; 28F43DDF29738792008BBA45 /* codeedit-solarized-dark.json */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 2; lastKnownFileType = text.json; path = "codeedit-solarized-dark.json"; sourceTree = ""; tabWidth = 2; }; 28F43DE1297388C5008BBA45 /* codeedit-solarized-light.json */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 2; lastKnownFileType = text.json; path = "codeedit-solarized-light.json"; sourceTree = ""; tabWidth = 2; }; - 28FFE1BE27E3A441001939DB /* NavigatorSidebarToolbarBottom.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = NavigatorSidebarToolbarBottom.swift; sourceTree = ""; tabWidth = 4; }; 2B15CA0028254139004E8F22 /* OpenWithCodeEdit.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = OpenWithCodeEdit.entitlements; sourceTree = ""; }; 2B7AC06A282452FB0082A5B8 /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = ""; }; 2BE487EC28245162003F3F64 /* OpenWithCodeEdit.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenWithCodeEdit.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -478,6 +478,10 @@ 4E7F066529602E7B00BB3C12 /* CodeEditSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeEditSplitViewController.swift; sourceTree = ""; }; 4EE96ECA2960565E00FFBEA8 /* DocumentsUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentsUnitTests.swift; sourceTree = ""; }; 4EE96ECD296059E000FFBEA8 /* NSHapticFeedbackPerformerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSHapticFeedbackPerformerMock.swift; sourceTree = ""; }; + 581550CC29FBD30400684881 /* StandardTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StandardTableViewCell.swift; sourceTree = ""; }; + 581550CD29FBD30400684881 /* FileSystemTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileSystemTableViewCell.swift; sourceTree = ""; }; + 581550CE29FBD30400684881 /* TextTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextTableViewCell.swift; sourceTree = ""; }; + 581550D329FBD37D00684881 /* ProjectNavigatorToolbarBottom.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProjectNavigatorToolbarBottom.swift; sourceTree = ""; }; 581BFB5A2926431000D251EC /* WelcomeWindowView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WelcomeWindowView.swift; sourceTree = ""; }; 581BFB5B2926431000D251EC /* WelcomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; 581BFB5C2926431000D251EC /* WelcomeActionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WelcomeActionView.swift; sourceTree = ""; }; @@ -497,6 +501,7 @@ 583E528329361B39001AB554 /* testEffectViewDark.1.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = testEffectViewDark.1.png; sourceTree = ""; }; 583E528429361B39001AB554 /* testBranchPickerLight.1.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = testBranchPickerLight.1.png; sourceTree = ""; }; 583E52A129361BFD001AB554 /* CodeEditUITests-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "CodeEditUITests-Bridging-Header.h"; sourceTree = ""; }; + 58710158298EB80000951BA4 /* CEWorkspaceFileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CEWorkspaceFileManager.swift; sourceTree = ""; }; 5878DA81291863F900DD95A3 /* AcknowledgementsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AcknowledgementsView.swift; sourceTree = ""; }; 5878DA832918642000DD95A3 /* ParsePackagesResolved.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParsePackagesResolved.swift; sourceTree = ""; }; 5878DA862918642F00DD95A3 /* AcknowledgementsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AcknowledgementsViewModel.swift; sourceTree = ""; }; @@ -601,12 +606,7 @@ 587B9E5329301D8F00AC7927 /* GitCommit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitCommit.swift; sourceTree = ""; }; 587B9E5429301D8F00AC7927 /* GitChangedFile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitChangedFile.swift; sourceTree = ""; }; 587B9E5529301D8F00AC7927 /* GitType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitType.swift; sourceTree = ""; }; - 587D9B6D2933BF5700BF7490 /* FileIcon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileIcon.swift; sourceTree = ""; }; - 587D9B6E2933BF5700BF7490 /* FileItem+Array.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "FileItem+Array.swift"; sourceTree = ""; }; - 587D9B6F2933BF5700BF7490 /* FileItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileItem.swift; sourceTree = ""; }; - 587D9B702933BF5700BF7490 /* Mocks.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Mocks.swift; sourceTree = ""; }; - 587D9B712933BF5700BF7490 /* Live.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Live.swift; sourceTree = ""; }; - 587D9B722933BF5700BF7490 /* Interface.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Interface.swift; sourceTree = ""; }; + 587FB98F29C1246400B519DD /* TabBarItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabBarItemView.swift; sourceTree = ""; }; 58822509292C280D00E83CDE /* StatusBarView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusBarView.swift; sourceTree = ""; }; 5882250B292C280D00E83CDE /* StatusBarMenuStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusBarMenuStyle.swift; sourceTree = ""; }; 5882250C292C280D00E83CDE /* StatusBarBreakpointButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusBarBreakpointButton.swift; sourceTree = ""; }; @@ -624,7 +624,11 @@ 5882251C292C280D00E83CDE /* StatusBarViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusBarViewModel.swift; sourceTree = ""; }; 5882251D292C280D00E83CDE /* StatusBarTabType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusBarTabType.swift; sourceTree = ""; }; 5882251E292C280D00E83CDE /* CursorLocation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CursorLocation.swift; sourceTree = ""; }; + 588847622992A2A200996D95 /* CEWorkspaceFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CEWorkspaceFile.swift; sourceTree = ""; }; + 588847682992ABCA00996D95 /* Array+CEWorkspaceFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+CEWorkspaceFile.swift"; sourceTree = ""; }; + 5894E59629FEF7740077E59C /* CEWorkspaceFile+Recursion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CEWorkspaceFile+Recursion.swift"; sourceTree = ""; }; 589F3E342936185400E1A4DA /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; + 58A2E40629C3975D005CB615 /* CEWorkspaceFileIcon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CEWorkspaceFileIcon.swift; sourceTree = ""; }; 58A5DF7C2931787A00D1BD5D /* ShellClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellClient.swift; sourceTree = ""; }; 58A5DF7F29325B5A00D1BD5D /* GitClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitClient.swift; sourceTree = ""; }; 58A5DF9E29339F6400D1BD5D /* KeybindingManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeybindingManager.swift; sourceTree = ""; }; @@ -758,7 +762,7 @@ D7012EE727E757850001E1EF /* FindNavigatorView.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = FindNavigatorView.swift; sourceTree = ""; tabWidth = 4; }; D7211D4227E066CE008F2ED7 /* Localized+Ex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Localized+Ex.swift"; sourceTree = ""; }; D7211D4827E06BFE008F2ED7 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; - D7DC4B75298FFBE900D6C83D /* OutlintViewController+OutlineTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OutlintViewController+OutlineTableViewCellDelegate.swift"; sourceTree = ""; }; + D7DC4B75298FFBE900D6C83D /* ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift"; sourceTree = ""; }; D7E201AD27E8B3C000CB86D0 /* String+Ranges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Ranges.swift"; sourceTree = ""; }; D7E201AF27E8C07300CB86D0 /* FindNavigatorSearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindNavigatorSearchBar.swift; sourceTree = ""; }; D7E201B127E8D50000CB86D0 /* FindNavigatorModeSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindNavigatorModeSelector.swift; sourceTree = ""; }; @@ -966,11 +970,11 @@ 285FEC6C27FE4AC700E57D53 /* OutlineView */ = { isa = PBXGroup; children = ( - 2847019D27FDDF7600F87B6B /* OutlineView.swift */, - 285FEC6D27FE4B4A00E57D53 /* OutlineViewController.swift */, - 285FEC6F27FE4B9800E57D53 /* OutlineTableViewCell.swift */, - 285FEC7127FE4EEF00E57D53 /* OutlineMenu.swift */, - D7DC4B75298FFBE900D6C83D /* OutlintViewController+OutlineTableViewCellDelegate.swift */, + 2847019D27FDDF7600F87B6B /* ProjectNavigatorOutlineView.swift */, + 285FEC6D27FE4B4A00E57D53 /* ProjectNavigatorViewController.swift */, + 285FEC6F27FE4B9800E57D53 /* ProjectNavigatorTableViewCell.swift */, + 285FEC7127FE4EEF00E57D53 /* ProjectNavigatorMenu.swift */, + D7DC4B75298FFBE900D6C83D /* ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift */, ); path = OutlineView; sourceTree = ""; @@ -978,8 +982,9 @@ 286471AC27ED52950039369D /* ProjectNavigator */ = { isa = PBXGroup; children = ( - 286471AA27ED51FD0039369D /* ProjectNavigatorView.swift */, 285FEC6C27FE4AC700E57D53 /* OutlineView */, + 581550D329FBD37D00684881 /* ProjectNavigatorToolbarBottom.swift */, + 286471AA27ED51FD0039369D /* ProjectNavigatorView.swift */, ); path = ProjectNavigator; sourceTree = ""; @@ -987,12 +992,12 @@ 287776EA27E350A100D46668 /* NavigatorSidebar */ = { isa = PBXGroup; children = ( - 201169D52837B29600F92B46 /* SourceControlNavigator */, - 286471AC27ED52950039369D /* ProjectNavigator */, D7012EE627E757660001E1EF /* FindNavigator */, + 581550CB29FBD30400684881 /* OutlineView */, + 286471AC27ED52950039369D /* ProjectNavigator */, + 201169D52837B29600F92B46 /* SourceControlNavigator */, 287776E627E3413200D46668 /* NavigatorSidebarView.swift */, - 28B0A19727E385C300B73177 /* NavigatorSidebarToolbarTop.swift */, - 28FFE1BE27E3A441001939DB /* NavigatorSidebarToolbarBottom.swift */, + 28B0A19727E385C300B73177 /* NavigatorSidebarToolbar.swift */, ); path = NavigatorSidebar; sourceTree = ""; @@ -1035,6 +1040,16 @@ path = Mocks; sourceTree = ""; }; + 581550CB29FBD30400684881 /* OutlineView */ = { + isa = PBXGroup; + children = ( + 581550CC29FBD30400684881 /* StandardTableViewCell.swift */, + 581550CD29FBD30400684881 /* FileSystemTableViewCell.swift */, + 581550CE29FBD30400684881 /* TextTableViewCell.swift */, + ); + path = OutlineView; + sourceTree = ""; + }; 581BFB4B2926431000D251EC /* Welcome */ = { isa = PBXGroup; children = ( @@ -1081,6 +1096,7 @@ children = ( 582213EE2918345500EFE361 /* About */, 5878DA7D291862BC00DD95A3 /* Acknowledgements */, + 588847642992A30900996D95 /* CEWorkspace */, 587B9D50292FC27A00AC7927 /* CodeEditExtension */, 587B9D7529300ABD00AC7927 /* CodeEditUI */, 58798244292E78D80085B254 /* CodeFile */, @@ -1679,27 +1695,6 @@ path = Models; sourceTree = ""; }; - 587D9B6B2933BF5700BF7490 /* WorkspaceClient */ = { - isa = PBXGroup; - children = ( - 587D9B6C2933BF5700BF7490 /* Model */, - 587D9B702933BF5700BF7490 /* Mocks.swift */, - 587D9B712933BF5700BF7490 /* Live.swift */, - 587D9B722933BF5700BF7490 /* Interface.swift */, - ); - path = WorkspaceClient; - sourceTree = ""; - }; - 587D9B6C2933BF5700BF7490 /* Model */ = { - isa = PBXGroup; - children = ( - 587D9B6D2933BF5700BF7490 /* FileIcon.swift */, - 587D9B6E2933BF5700BF7490 /* FileItem+Array.swift */, - 587D9B6F2933BF5700BF7490 /* FileItem.swift */, - ); - path = Model; - sourceTree = ""; - }; 588224FF292C280D00E83CDE /* StatusBar */ = { isa = PBXGroup; children = ( @@ -1772,6 +1767,41 @@ path = ViewModels; sourceTree = ""; }; + 588847642992A30900996D95 /* CEWorkspace */ = { + isa = PBXGroup; + children = ( + 588847652992A35800996D95 /* Models */, + 588847662992A36100996D95 /* Views */, + ); + path = CEWorkspace; + sourceTree = ""; + }; + 588847652992A35800996D95 /* Models */ = { + isa = PBXGroup; + children = ( + 588847622992A2A200996D95 /* CEWorkspaceFile.swift */, + 5894E59629FEF7740077E59C /* CEWorkspaceFile+Recursion.swift */, + 58A2E40629C3975D005CB615 /* CEWorkspaceFileIcon.swift */, + 58710158298EB80000951BA4 /* CEWorkspaceFileManager.swift */, + ); + path = Models; + sourceTree = ""; + }; + 588847662992A36100996D95 /* Views */ = { + isa = PBXGroup; + children = ( + ); + path = Views; + sourceTree = ""; + }; + 588847672992AAB800996D95 /* Array */ = { + isa = PBXGroup; + children = ( + 588847682992ABCA00996D95 /* Array+CEWorkspaceFile.swift */, + ); + path = Array; + sourceTree = ""; + }; 58A5DF7B2931784D00D1BD5D /* Models */ = { isa = PBXGroup; children = ( @@ -1795,11 +1825,11 @@ isa = PBXGroup; children = ( 287776E827E34BC700D46668 /* TabBarView.swift */, - 287776EE27E3515300D46668 /* TabBarItemView.swift */, B6C6A429297716A500A3D28F /* TabBarItemCloseButton.swift */, 6CDA84AC284C1BA000C1CC3A /* TabBarContextMenu.swift */, B6C6A42D29771A8D00A3D28F /* TabBarItemButtonStyle.swift */, DE6F77862813625500D00A76 /* TabBarDivider.swift */, + 587FB98F29C1246400B519DD /* TabBarItemView.swift */, DE6405A52817734700881FDF /* TabBarNative.swift */, DE513F51281B672D002260B9 /* TabBarAccessory.swift */, DE513F53281DE5D0002260B9 /* TabBarXcode.swift */, @@ -1826,7 +1856,6 @@ 58D01C8F293167DC00C5B6B4 /* KeyChain */, 5831E3C92933E83400D5A6D2 /* Protocols */, 5875680E29316BDC00C965A3 /* ShellClient */, - 587D9B6B2933BF5700BF7490 /* WorkspaceClient */, ); path = Utils; sourceTree = ""; @@ -1835,6 +1864,7 @@ isa = PBXGroup; children = ( 6C82D6C429C0129E00495C54 /* NSApplication */, + 588847672992AAB800996D95 /* Array */, 6CBD1BC42978DE3E006639D5 /* Text */, 5831E3D02934036D00D5A6D2 /* NSTableView */, 5831E3CA2933E86F00D5A6D2 /* View */, @@ -2551,7 +2581,7 @@ 201169E52837B40300F92B46 /* SourceControlNavigatorRepositoriesView.swift in Sources */, 587B9E6A29301D8F00AC7927 /* GitLabPermissions.swift in Sources */, B6EA1FF529DA380E001BF195 /* TextEditingSettingsView.swift in Sources */, - D7DC4B76298FFBE900D6C83D /* OutlintViewController+OutlineTableViewCellDelegate.swift in Sources */, + D7DC4B76298FFBE900D6C83D /* ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift in Sources */, 587B9E9229301D8F00AC7927 /* BitBucketAccount.swift in Sources */, DE513F52281B672D002260B9 /* TabBarAccessory.swift in Sources */, 2813F93927ECC4C300E305E4 /* NavigatorSidebarView.swift in Sources */, @@ -2584,6 +2614,7 @@ B6EA1FE529DA33DB001BF195 /* ThemeModel.swift in Sources */, B6EA200029DB7966001BF195 /* SettingsColorPicker.swift in Sources */, 58FD7609291EA1CB0051D6E4 /* CommandPaletteView.swift in Sources */, + 58A2E40C29C3975D005CB615 /* CEWorkspaceFileIcon.swift in Sources */, 587B9E8F29301D8F00AC7927 /* BitBucketUserRouter.swift in Sources */, B66A4E5129C917D5004573B4 /* AboutWindow.swift in Sources */, 58F2EB03292FB2B0004A9BDE /* Documentation.docc in Sources */, @@ -2631,6 +2662,7 @@ B6C6A43029771F7100A3D28F /* TabBarItemBackground.swift in Sources */, B6F0517D29D9E4B100D72287 /* TerminalSettingsView.swift in Sources */, 587B9E8C29301D8F00AC7927 /* GitHubOpenness.swift in Sources */, + 5894E59729FEF7740077E59C /* CEWorkspaceFile+Recursion.swift in Sources */, 587B9E8229301D8F00AC7927 /* GitHubPreviewHeader.swift in Sources */, 58F2EB02292FB2B0004A9BDE /* Loopable.swift in Sources */, 28B8F884280FFE4600596236 /* NSTableView+Background.swift in Sources */, @@ -2641,6 +2673,7 @@ B61DA9DF29D929E100BF4A43 /* GeneralSettingsView.swift in Sources */, B6D7EA5C297107DD00301FAC /* InspectorField.swift in Sources */, 043C321427E31FF6006AE443 /* CodeEditDocumentController.swift in Sources */, + 581550D129FBD30400684881 /* TextTableViewCell.swift in Sources */, 587B9E6629301D8F00AC7927 /* GitLabProjectHook.swift in Sources */, 587B9E9329301D8F00AC7927 /* BitBucketOAuthConfiguration.swift in Sources */, 6C18620A298BF5A800C663EA /* RecentProjectsListView.swift in Sources */, @@ -2671,9 +2704,10 @@ 20EBB507280C32D300F3A5DA /* QuickHelpInspectorView.swift in Sources */, 850C631029D6B01D00E1444C /* SettingsView.swift in Sources */, DE6405A62817734700881FDF /* TabBarNative.swift in Sources */, + 581550CF29FBD30400684881 /* StandardTableViewCell.swift in Sources */, 587B9E5C29301D8F00AC7927 /* Parameters.swift in Sources */, 58798235292E30B90085B254 /* FeedbackModel.swift in Sources */, - 04C3255C2801F86900C8DA2D /* OutlineMenu.swift in Sources */, + 04C3255C2801F86900C8DA2D /* ProjectNavigatorMenu.swift in Sources */, 587B9E6429301D8F00AC7927 /* GitLabCommit.swift in Sources */, 58A5DFA229339F6400D1BD5D /* KeybindingManager.swift in Sources */, 58AFAA2E2933C69E00482B53 /* TabBarItemRepresentable.swift in Sources */, @@ -2686,7 +2720,6 @@ 581BFB692926431000D251EC /* WelcomeActionView.swift in Sources */, 20D839AE280E0CA700B27357 /* HistoryPopoverView.swift in Sources */, B6E41C7029DD157F0088F9F4 /* AccountsSettingsView.swift in Sources */, - 587D9B782933BF5700BF7490 /* Interface.swift in Sources */, 6CFF967A29BEBD2400182D6F /* ViewCommands.swift in Sources */, 2072FA1E280D891500C7F8D4 /* FileLocation.swift in Sources */, 850C631229D6B03400E1444C /* SettingsPage.swift in Sources */, @@ -2694,6 +2727,7 @@ B66A4E4529C8E86D004573B4 /* CommandsFixes.swift in Sources */, 5882252F292C280D00E83CDE /* StatusBarClearButton.swift in Sources */, 58F2EB04292FB2B0004A9BDE /* SourceControlSettings.swift in Sources */, + 58710159298EB80000951BA4 /* CEWorkspaceFileManager.swift in Sources */, 582213F0291834A500EFE361 /* AboutView.swift in Sources */, 6CC9E4B229B5669900C97388 /* Environment+ActiveTabGroup.swift in Sources */, 58822526292C280D00E83CDE /* StatusBarBreakpointButton.swift in Sources */, @@ -2703,19 +2737,20 @@ B66A4E5329C91831004573B4 /* CodeEditCommands.swift in Sources */, 58822529292C280D00E83CDE /* StatusBarLineEndSelector.swift in Sources */, 5C4BB1E128212B1E00A92FB2 /* World.swift in Sources */, + 581550D029FBD30400684881 /* FileSystemTableViewCell.swift in Sources */, D7E201B227E8D50000CB86D0 /* FindNavigatorModeSelector.swift in Sources */, 287776E927E34BC700D46668 /* TabBarView.swift in Sources */, B60BE8BD297A167600841125 /* AcknowledgementRowView.swift in Sources */, B6F0517029D9E36800D72287 /* LocationsSettingsView.swift in Sources */, 587B9E6329301D8F00AC7927 /* GitLabAccount.swift in Sources */, - 285FEC7027FE4B9800E57D53 /* OutlineTableViewCell.swift in Sources */, + 285FEC7027FE4B9800E57D53 /* ProjectNavigatorTableViewCell.swift in Sources */, 6CB9144B29BEC7F100BC47F2 /* (null) in Sources */, 587B9E7429301D8F00AC7927 /* URL+URLParameters.swift in Sources */, 581BFB6B2926431000D251EC /* RecentProjectItem.swift in Sources */, + 587FB99029C1246400B519DD /* TabBarItemView.swift in Sources */, 6C5AB9D729C1496E003B5F96 /* SceneID.swift in Sources */, 587B9DA429300ABD00AC7927 /* OverlayPanel.swift in Sources */, 58D01C95293167DC00C5B6B4 /* Bundle+Info.swift in Sources */, - 587D9B762933BF5700BF7490 /* Mocks.swift in Sources */, B6C6A42A297716A500A3D28F /* TabBarItemCloseButton.swift in Sources */, 58A5DF7D2931787A00D1BD5D /* ShellClient.swift in Sources */, 5879821A292D92370085B254 /* SearchResultModel.swift in Sources */, @@ -2725,17 +2760,17 @@ 58D01C94293167DC00C5B6B4 /* Color+HEX.swift in Sources */, B640A9A129E2188F00715F20 /* SettingsDetailsView.swift in Sources */, 58798251292E78D80085B254 /* OtherFileView.swift in Sources */, - 587D9B752933BF5700BF7490 /* FileItem.swift in Sources */, 587B9E7929301D8F00AC7927 /* GitHubIssueRouter.swift in Sources */, 587B9E8029301D8F00AC7927 /* GitHubConfiguration.swift in Sources */, 58822524292C280D00E83CDE /* StatusBarView.swift in Sources */, + 581550D429FBD37D00684881 /* ProjectNavigatorToolbarBottom.swift in Sources */, 587B9E7E29301D8F00AC7927 /* GitHubGistRouter.swift in Sources */, 6CAAF69229BCC71C00A1F48A /* (null) in Sources */, 581BFB682926431000D251EC /* WelcomeView.swift in Sources */, 6CFF967829BEBCF600182D6F /* MainCommands.swift in Sources */, 587B9E7129301D8F00AC7927 /* GitURLSession.swift in Sources */, 5882252C292C280D00E83CDE /* StatusBarDrawer.swift in Sources */, - 2847019E27FDDF7600F87B6B /* OutlineView.swift in Sources */, + 2847019E27FDDF7600F87B6B /* ProjectNavigatorOutlineView.swift in Sources */, 6C14CEB32877A68F001468FE /* FindNavigatorMatchListCell.swift in Sources */, 20EBB501280C325D00F3A5DA /* FileInspectorView.swift in Sources */, 58822531292C280D00E83CDE /* View+isHovering.swift in Sources */, @@ -2753,7 +2788,6 @@ 587B9DA629300ABD00AC7927 /* ToolbarBranchPicker.swift in Sources */, 58F2EB05292FB2B0004A9BDE /* Settings.swift in Sources */, 6CBD1BC62978DE53006639D5 /* Font+Caption3.swift in Sources */, - 287776EF27E3515300D46668 /* TabBarItemView.swift in Sources */, B6E41C9429DEAE260088F9F4 /* SourceControlAccount.swift in Sources */, 2806E9022979588B000040F4 /* Contributor.swift in Sources */, 58D01C98293167DC00C5B6B4 /* String+RemoveOccurrences.swift in Sources */, @@ -2771,8 +2805,6 @@ 5879821B292D92370085B254 /* SearchResultMatchModel.swift in Sources */, 587B9E5929301D8F00AC7927 /* GitCheckoutBranchView+CheckoutBranch.swift in Sources */, 58F2EB09292FB2B0004A9BDE /* TerminalSettings.swift in Sources */, - 587D9B742933BF5700BF7490 /* FileItem+Array.swift in Sources */, - 587D9B772933BF5700BF7490 /* Live.swift in Sources */, B6EA200229DB7F81001BF195 /* View+ConstrainHeightToWindow.swift in Sources */, 6C48D8F42972DB1A00D6D205 /* Env+Window.swift in Sources */, 6C5FDF7A29E6160000BC08C0 /* AppSettings.swift in Sources */, @@ -2788,6 +2820,7 @@ 587B9E7629301D8F00AC7927 /* GitTime.swift in Sources */, 2072FA16280D83A500C7F8D4 /* FileTypeList.swift in Sources */, 587B9E5D29301D8F00AC7927 /* GitLabUserRouter.swift in Sources */, + 588847692992ABCA00996D95 /* Array+CEWorkspaceFile.swift in Sources */, 58822530292C280D00E83CDE /* FilterTextField.swift in Sources */, 6C82D6B929BFE34900495C54 /* HelpCommands.swift in Sources */, 6C147C4929A32A080089B630 /* EditorView.swift in Sources */, @@ -2828,7 +2861,7 @@ 587B9E7229301D8F00AC7927 /* GitJSONPostRouter.swift in Sources */, 5878DAB0291D627C00DD95A3 /* PathBarMenu.swift in Sources */, 587B9DA529300ABD00AC7927 /* PressActionsModifier.swift in Sources */, - 28B0A19827E385C300B73177 /* NavigatorSidebarToolbarTop.swift in Sources */, + 28B0A19827E385C300B73177 /* NavigatorSidebarToolbar.swift in Sources */, 587B9E8629301D8F00AC7927 /* GitHubComment.swift in Sources */, 6C147C4029A328BC0089B630 /* SplitViewData.swift in Sources */, 587B9E9029301D8F00AC7927 /* BitBucketTokenRouter.swift in Sources */, @@ -2841,13 +2874,12 @@ 587B9E8129301D8F00AC7927 /* PublicKey.swift in Sources */, 58F2EB0B292FB2B0004A9BDE /* AccountsSettings.swift in Sources */, 5882252A292C280D00E83CDE /* StatusBarToggleDrawerButton.swift in Sources */, - 04C3255B2801F86400C8DA2D /* OutlineViewController.swift in Sources */, + 04C3255B2801F86400C8DA2D /* ProjectNavigatorViewController.swift in Sources */, 587B9E6029301D8F00AC7927 /* GitLabOAuthRouter.swift in Sources */, 6C05A8AF284D0CA3007F4EAA /* WorkspaceDocument+Listeners.swift in Sources */, - 587D9B732933BF5700BF7490 /* FileIcon.swift in Sources */, + 588847632992A2A200996D95 /* CEWorkspaceFile.swift in Sources */, 6C2C155D29B4F4E500EA60A5 /* SplitViewReader.swift in Sources */, 58AFAA2F2933C69E00482B53 /* TabBarItemID.swift in Sources */, - 28FFE1BF27E3A441001939DB /* NavigatorSidebarToolbarBottom.swift in Sources */, 6C4104E3297C87A000F472BA /* BlurButtonStyle.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile+Recursion.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile+Recursion.swift new file mode 100644 index 000000000..1c427d6a5 --- /dev/null +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile+Recursion.swift @@ -0,0 +1,134 @@ +// +// CEWorkspaceFile+Recursion.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 30/04/2023. +// + +import Foundation + +extension CEWorkspaceFile { + + func childrenDescription(tabCount: Int) -> String { + var myDetails = "\(String(repeating: "| ", count: max(tabCount - 1, 0)))\(tabCount != 0 ? "╰--" : "")" + myDetails += "\(url.path)" + if !self.isFolder { // if im a file, just return the url + return myDetails + } else { // if im a folder, return the url and its children's details + var childDetails = "\(myDetails)" + for child in children ?? [] { + childDetails += "\n\(child.childrenDescription(tabCount: tabCount + 1))" + } + return childDetails + } + } + + /// Flattens the children of ``self`` recursively with depth. + /// - Parameters: + /// - depth: An int that indicates the how deep the tree files need to be flattened + /// - ignoringFolders: A boolean on whether to ignore files that are Folders + /// - Returns: An array of flattened `CEWorkspaceFiles` + func flattenedChildren(withDepth depth: Int, ignoringFolders: Bool) -> [CEWorkspaceFile] { + guard depth > 0 else { return [] } + guard self.isFolder else { return [self] } + var childItems: [CEWorkspaceFile] = ignoringFolders ? [] : [self] + children?.forEach { child in + childItems.append(contentsOf: child.flattenedChildren( + withDepth: depth - 1, + ignoringFolders: ignoringFolders + )) + } + return childItems + } + + /// Returns a list of `CEWorkspaceFiles` that are sibilings of ``self``. + /// The `height` parameter lets the function navigate up the folder hierarchy to + /// select a starting point from which it should start flettening the items. + /// - Parameters: + /// - height: `Int` that tells where to start in the hierarchy + /// - ignoringFolders: Wether the sibling folders should be flattened + /// - Returns: A list of `FileSystemItems` + func flattenedSiblings(withHeight height: Int, ignoringFolders: Bool) -> [CEWorkspaceFile] { + let topMostParent = self.getParent(withHeight: height) + return topMostParent.flattenedChildren(withDepth: height, ignoringFolders: ignoringFolders) + } + + /// Recursive function that returns the number of children + /// that contain the `searchString` in their path or their subitems' paths. + /// Returns `0` if the item is not a folder. + /// - Parameters: + /// - searchString: The string + /// - ignoredStrings: The prefixes to ignore if they prefix file names + /// - Returns: The number of children that match the conditiions + func appearanceWithinChildrenOf(searchString: String, ignoredStrings: [String] = [".", "~"]) -> Int { + var count = 0 + guard self.isFolder else { return 0 } + for child in self.children ?? [] { + var isIgnored: Bool = false + for ignoredString in ignoredStrings where child.name.hasPrefix(ignoredString) { + isIgnored = true // can use regex later + } + + if isIgnored { + continue + } + + guard !searchString.isEmpty else { count += 1; continue } + if child.isFolder { + count += child.appearanceWithinChildrenOf(searchString: searchString) > 0 ? 1 : 0 + } else { + count += child.name.lowercased().contains(searchString.lowercased()) ? 1 : 0 + } + } + return count + } + + /// Function that returns an array of the children + /// that contain the `searchString` in their path or their subitems' paths. + /// Similar to `appearanceWithinChildrenOf(searchString: String)` + /// Returns `[]` if the item is not a folder. + /// - Parameter searchString: The string + /// - Parameter ignoredStrings: The prefixes to ignore if they prefix file names + /// - Returns: The children that match the conditiions + func childrenSatisfying(searchString: String, ignoredStrings: [String] = [".", "~"]) -> [CEWorkspaceFile] { + var satisfyingChildren: [CEWorkspaceFile] = [] + guard self.isFolder else { return [] } + for child in self.children ?? [] { + var isIgnored: Bool = false + for ignoredString in ignoredStrings where child.name.hasPrefix(ignoredString) { + isIgnored = true // can use regex later + } + + if isIgnored { + continue + } + + guard !searchString.isEmpty else { satisfyingChildren.append(child); continue } + if child.isFolder { + if child.appearanceWithinChildrenOf(searchString: searchString) > 0 { + satisfyingChildren.append(child) + } + } else { + if child.name.lowercased().contains(searchString.lowercased()) { + satisfyingChildren.append(child) + } + } + } + return satisfyingChildren + } + + /// Using the current instance of `FileSystemItem` it will walk back up the Workspace file hiarchy + /// the amount of times specified with the `withHeight` parameter. + /// - Parameter height: The amount of times you want to up a folder. + /// - Returns: The found `FileSystemItem` object, This should always be a folder. + private func getParent(withHeight height: Int) -> CEWorkspaceFile { + var topmostParent = self + for _ in 0.. Void)? + + /// Returns the Git status of a file as ``GitType`` + var gitStatus: GitType? + + /// Returns the `id` in ``TabBarItemID`` enum form + var tabID: TabBarItemID { .codeEditor(id) } + + /// Returns a boolean that is true if ``children`` is not `nil` + var isFolder: Bool { url.hasDirectoryPath } + + /// Returns a boolean that is true if the file item is the root folder of the workspace. + var isRoot: Bool { parent == nil } + + /// Returns a boolean that is true if the file item actually exists in the file system + var doesExist: Bool { CEWorkspaceFile.fileManger.fileExists(atPath: self.url.path) } + + /// Returns a string describing a SFSymbol for the current ``FileSystemClient/FileSystemClient/FileItem`` + /// + /// Use it like this + /// ```swift + /// Image(systemName: item.systemImage) + /// ``` + var systemImage: String { + if let children = children { + // item is a folder + return folderIcon(children) + } else { + // item is a file + return FileIcon.fileIcon(fileType: type) + } + } + + /// Return the file's UTType + var contentType: UTType? { + try? url.resourceValues(forKeys: [.contentTypeKey]).contentType + } + + /// Returns a `Color` for a specific `fileType` + /// + /// If not specified otherwise this will return `Color.accentColor` + var iconColor: Color { + FileIcon.iconColor(fileType: type) + } + + var debugFileHeirachy: String { childrenDescription(tabCount: 0) } + + init( + url: URL, + children: [CEWorkspaceFile]? = nil, + changeType: GitType? = nil + ) { + self.url = url + self.children = children + self.gitStatus = changeType + } + + required init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: FileItemCodingKeys.self) + url = try values.decode(URL.self, forKey: .url) + children = try values.decode([CEWorkspaceFile]?.self, forKey: .children) + gitStatus = try values.decode(GitType.self, forKey: .changeType) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: FileItemCodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(url, forKey: .url) + try container.encode(children, forKey: .children) + try container.encode(gitStatus, forKey: .changeType) + } + + func activateWatcher() -> Bool { + guard let watcherCode else { return false } + + let descriptor = open(self.url.path, O_EVTONLY) + guard descriptor > 0 else { return false } + + // create the source + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: descriptor, + eventMask: .write, + queue: DispatchQueue.global() + ) + + if descriptor > 2000 { + print("Watcher \(descriptor) used up on \(url.path)") + } + + source.setEventHandler { watcherCode(self) } + source.setCancelHandler { close(descriptor) } + source.resume() + self.watcher = source + + // TODO: reindex the current item, because the files in the item may have changed + // since the initial load on startup. + return true + } + + /// Returns a string describing a SFSymbol for folders + /// + /// If it is the top-level folder this will return `"square.dashed.inset.filled"`. + /// If it is a `.codeedit` folder this will return `"folder.fill.badge.gearshape"`. + /// If it has children this will return `"folder.fill"` otherwise `"folder"`. + private func folderIcon(_ children: [CEWorkspaceFile]) -> String { + if self.parent == nil { + return "folder.fill.badge.gearshape" + } + if self.name == ".codeedit" { + return "folder.fill.badge.gearshape" + } + return children.isEmpty ? "folder" : "folder.fill" + } + + /// Returns the file name with optional extension (e.g.: `Package.swift`) + func fileName(typeHidden: Bool) -> String { + typeHidden ? url.deletingPathExtension().lastPathComponent : name + } + + // MARK: Statics + /// The default `FileManager` instance + static let fileManger = FileManager.default + + // MARK: Intents + /// Allows the user to view the file or folder in the finder application + func showInFinder() { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + /// Allows the user to launch the file or folder as it would be in finder + func openWithExternalEditor() { + NSWorkspace.shared.open(url) + } + + /// This function allows creation of folders in the main directory or sub-folders + /// - Parameter folderName: The name of the new folder + func addFolder(folderName: String) { + // Check if folder, if it is create folder under self, else create on same level. + var folderUrl = (self.isFolder ? + self.url.appendingPathComponent(folderName) : + self.url.deletingLastPathComponent().appendingPathComponent(folderName)) + + // If a file/folder with the same name exists, add a number to the end. + var fileNumber = 0 + while CEWorkspaceFile.fileManger.fileExists(atPath: folderUrl.path) { + fileNumber += 1 + folderUrl = folderUrl.deletingLastPathComponent().appendingPathComponent("\(folderName)\(fileNumber)") + } + + // Create the folder + do { + try CEWorkspaceFile.fileManger.createDirectory( + at: folderUrl, + withIntermediateDirectories: true, + attributes: [:] + ) + } catch { + fatalError(error.localizedDescription) + } + } + + /// This function allows creating files in the selected folder or project main directory + /// - Parameter fileName: The name of the new file + func addFile(fileName: String) { + // check the folder for other files, and see what the most common file extension is + var fileExtensions: [String: Int] = ["": 0] + + for child in (self.isFolder ? + self.flattenedSiblings(withHeight: 2, ignoringFolders: true) : + parent?.flattenedSiblings(withHeight: 2, ignoringFolders: true)) ?? [] where !child.isFolder { + // if the file extension was present before, add it now + let childFileName = child.fileName(typeHidden: false) + if let index = childFileName.lastIndex(of: ".") { + let childFileExtension = ".\(childFileName.suffix(from: index).dropFirst())" + fileExtensions[childFileExtension] = (fileExtensions[childFileExtension] ?? 0) + 1 + } else { + fileExtensions[""] = (fileExtensions[""] ?? 0) + 1 + } + } + + var largestValue = 0 + var idealExtension = "" + for (extName, count) in fileExtensions where count > largestValue { + idealExtension = extName + largestValue = count + } + + var fileUrl = nearestFolder.appendingPathComponent("\(fileName)\(idealExtension)") + // If a file/folder with the same name exists, add a number to the end. + var fileNumber = 0 + while CEWorkspaceFile.fileManger.fileExists(atPath: fileUrl.path) { + fileNumber += 1 + fileUrl = fileUrl.deletingLastPathComponent() + .appendingPathComponent("\(fileName)\(fileNumber)\(idealExtension)") + } + + // Create the file + CEWorkspaceFile.fileManger.createFile( + atPath: fileUrl.path, + contents: nil, + attributes: [FileAttributeKey.creationDate: Date()] + ) + } + + /// Nearest folder refers to the parent directory if this is a non-folder item, or itself if the item is a folder. + var nearestFolder: URL { + (self.isFolder ? + self.url : + self.url.deletingLastPathComponent()) + } + + /// This function deletes the item or folder from the current project + func delete() { + // This function also has to account for how the + // - file system can change outside of the editor + let deleteConfirmation = NSAlert() + let message: String + if self.isFolder || (self.children?.isEmpty ?? false) { // if its a file or an empty folder, call it by its name + message = String(describing: self.fileName) + } else { + message = "the \((self.children?.count ?? 0) + 1) selected items" + } + deleteConfirmation.messageText = "Do you want to move \(message) to the Trash?" + deleteConfirmation.informativeText = "This operation cannot be undone" + deleteConfirmation.alertStyle = .critical + deleteConfirmation.addButton(withTitle: "Move to Trash") + deleteConfirmation.buttons.last?.hasDestructiveAction = true + deleteConfirmation.addButton(withTitle: "Cancel") + if deleteConfirmation.runModal() == .alertFirstButtonReturn { // "Delete" button + if CEWorkspaceFile.fileManger.fileExists(atPath: self.url.path) { + do { + try CEWorkspaceFile.fileManger.removeItem(at: self.url) + } catch { + fatalError(error.localizedDescription) + } + } + } + } + + /// This function duplicates the item or folder + func duplicate() { + // If a file/folder with the same name exists, add "copy" to the end + var fileUrl = self.url + while CEWorkspaceFile.fileManger.fileExists(atPath: fileUrl.path) { + let previousName = fileUrl.lastPathComponent + let fileExtension = fileUrl.pathExtension.isEmpty ? "" : ".\(fileUrl.pathExtension)" + let fileName = fileExtension.isEmpty ? previousName : + previousName.replacingOccurrences(of: ".\(fileExtension)", with: "") + fileUrl = fileUrl.deletingLastPathComponent().appendingPathComponent("\(fileName) copy\(fileExtension)") + } + + if CEWorkspaceFile.fileManger.fileExists(atPath: self.url.path) { + do { + try CEWorkspaceFile.fileManger.copyItem(at: self.url, to: fileUrl) + } catch { + fatalError(error.localizedDescription) + } + } + } + + /// This function moves the item or folder if possible + func move(to newLocation: URL) { + guard !CEWorkspaceFile.fileManger.fileExists(atPath: newLocation.path) else { return } + createMissingParentDirectory(for: newLocation.deletingLastPathComponent()) + + do { + try CEWorkspaceFile.fileManger.moveItem(at: self.url, to: newLocation) + } catch { fatalError(error.localizedDescription) } + + // This function recursively creates missing directories if the file is moved to a directory that does not exist + func createMissingParentDirectory(for url: URL, createSelf: Bool = true) { + // if the folder's parent folder doesn't exist, create it. + if !CEWorkspaceFile.fileManger.fileExists(atPath: url.deletingLastPathComponent().path) { + createMissingParentDirectory(for: url.deletingLastPathComponent()) + } + // if the folder doesn't exist and the function was ordered to create it, create it. + if createSelf && !CEWorkspaceFile.fileManger.fileExists(atPath: url.path) { + // Create the folder + do { + try CEWorkspaceFile.fileManger.createDirectory( + at: url, + withIntermediateDirectories: true, + attributes: [:] + ) + } catch { + fatalError(error.localizedDescription) + } + } + } + } + + // MARK: Comparable + + static func == (lhs: CEWorkspaceFile, rhs: CEWorkspaceFile) -> Bool { + lhs.id == rhs.id + } + + static func < (lhs: CEWorkspaceFile, rhs: CEWorkspaceFile) -> Bool { + lhs.url.lastPathComponent < rhs.url.lastPathComponent + } + + // MARK: Hashable + + func hash(into hasher: inout Hasher) { + hasher.combine(fileIdentifier) + hasher.combine(id) + } + +} diff --git a/CodeEdit/Utils/WorkspaceClient/Model/FileIcon.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileIcon.swift similarity index 100% rename from CodeEdit/Utils/WorkspaceClient/Model/FileIcon.swift rename to CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileIcon.swift diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift new file mode 100644 index 000000000..1a7671944 --- /dev/null +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift @@ -0,0 +1,260 @@ +// +// FileSystemClient.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 04/02/2023. +// + +import Combine +import Foundation + +/// This class is used to load the files of the machine into a CodeEdit workspace. +final class CEWorkspaceFileManager { + enum FileSystemClientError: Error { + case fileNotExist + } + + // TODO: See if this needs to be removed, it isn't used anymore + private var subject = CurrentValueSubject<[CEWorkspaceFile], Never>([]) + private var isRunning = false + private var anotherInstanceRan = 0 + + private(set) var fileManager = FileManager.default + private(set) var ignoredFilesAndFolders: [String] + private(set) var flattenedFileItems: [String: CEWorkspaceFile] + + var onRefresh: () -> Void = {} + var getFiles: AnyPublisher<[CEWorkspaceFile], Never> = + CurrentValueSubject<[CEWorkspaceFile], Never>([]).eraseToAnyPublisher() + + let folderUrl: URL + let workspaceItem: CEWorkspaceFile + + init(folderUrl: URL, ignoredFilesAndFolders: [String]) { + self.folderUrl = folderUrl + self.ignoredFilesAndFolders = ignoredFilesAndFolders + + self.workspaceItem = CEWorkspaceFile(url: folderUrl, children: []) + self.flattenedFileItems = [workspaceItem.id: workspaceItem] + + self.setup() + } + + private func setup() { + // initial load + var workspaceFiles: [CEWorkspaceFile] + do { + workspaceFiles = try loadFiles(fromUrl: self.folderUrl) + } catch { + fatalError("Failed to loadFiles") + } + + // workspace fileItem + let workspaceFile = CEWorkspaceFile(url: self.folderUrl, children: workspaceFiles) + flattenedFileItems[workspaceFile.id] = workspaceFile + workspaceFiles.forEach { item in + item.parent = workspaceFile + } + + // By using `CurrentValueSubject` we can define a starting value. + // The value passed during init it's going to be send as soon as the + // consumer subscribes to the publisher. + let subject = CurrentValueSubject<[CEWorkspaceFile], Never>(workspaceFiles) + + self.getFiles = subject + .handleEvents(receiveCancel: { + for item in self.flattenedFileItems.values { + item.watcher?.cancel() + item.watcher = nil + } + }) + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + + workspaceFile.watcherCode = { sourceFileItem in + self.reloadFromWatcher(sourceFileItem: sourceFileItem) + } + reloadFromWatcher(sourceFileItem: workspaceFile) + } + + /// Recursive loading of files into `FileItem`s + /// - Parameter url: The URL of the directory to load the items of + /// - Returns: `[FileItem]` representing the contents of the directory + private func loadFiles(fromUrl url: URL) throws -> [CEWorkspaceFile] { + let directoryContents = try fileManager.contentsOfDirectory( + at: url.resolvingSymlinksInPath(), + includingPropertiesForKeys: nil + ) + var items: [CEWorkspaceFile] = [] + + for itemURL in directoryContents { + guard !ignoredFilesAndFolders.contains(itemURL.lastPathComponent) else { continue } + + var isDir: ObjCBool = false + + if fileManager.fileExists(atPath: itemURL.path, isDirectory: &isDir) { + var subItems: [CEWorkspaceFile]? + + if isDir.boolValue { + // Recursively fetch subdirectories and files if the path points to a directory + subItems = try loadFiles(fromUrl: itemURL) + } + + let newFileItem = CEWorkspaceFile( + url: itemURL, + children: subItems?.sortItems(foldersOnTop: true) + ) + + // note: watcher code will be applied after the workspaceItem is created + newFileItem.watcherCode = { sourceFileItem in + self.reloadFromWatcher(sourceFileItem: sourceFileItem) + } + subItems?.forEach { $0.parent = newFileItem } + items.append(newFileItem) + flattenedFileItems[newFileItem.id] = newFileItem + } + } + + return items + } + + /// A function that, given a file's path, returns a `FileItem` if it exists + /// within the scope of the `FileSystemClient`. + /// - Parameter id: The file's full path + /// - Returns: The file item corresponding to the file + func getFile(_ id: String) throws -> CEWorkspaceFile { + guard let item = flattenedFileItems[id] else { + throw FileSystemClientError.fileNotExist + } + + return item + } + + /// Usually run when the owner of the `FileSystemClient` doesn't need it anymore. + /// This de-inits most functions in the `FileSystemClient`, so that in case it isn't de-init'd it does not use up + /// significant amounts of RAM. + func cleanUp() { + stopListeningToDirectory() + workspaceItem.children = [] + flattenedFileItems = [workspaceItem.id: workspaceItem] + print("Cleaned up watchers and file items") + } + + // run by dispatchsource watchers. Multiple instances may be concurrent, + // so we need to be careful to avoid EXC_BAD_ACCESS errors. + /// This is a function run by `DispatchSource` file watchers. Due to the nature of watchers, multiple + /// instances may be running concurrently, so the function prevents more than one instance of it from + /// running the main code body. + /// - Parameter sourceFileItem: The `FileItem` corresponding to the file that triggered the `DispatchSource` + func reloadFromWatcher(sourceFileItem: CEWorkspaceFile) { + // Something has changed inside the directory + // We should reload the files. + guard !isRunning else { // this runs when a file change is detected but is already running + anotherInstanceRan += 1 + return + } + isRunning = true + + // inital reload of files + _ = try? rebuildFiles(fromItem: sourceFileItem) + + // re-reload if another instance tried to run while this instance was running + // TODO: optimise + while anotherInstanceRan > 0 { + let somethingChanged = try? rebuildFiles(fromItem: workspaceItem) + anotherInstanceRan = !(somethingChanged ?? false) ? 0 : anotherInstanceRan - 1 + } + + subject.send(workspaceItem.children ?? []) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.isRunning = false + } + anotherInstanceRan = 0 + + // reload data in outline view controller through the main thread + DispatchQueue.main.async { + self.onRefresh() + } + } + + /// A function to kill the watcher of a specific directory, or all directories. + /// - Parameter directory: The directory to stop watching, or nil to stop watching everything. + func stopListeningToDirectory(directory: URL? = nil) { + if directory != nil { + flattenedFileItems[directory!.relativePath]?.watcher?.cancel() + } else { + for item in flattenedFileItems.values { + item.watcher?.cancel() + item.watcher = nil + } + } + } + + /// Recursive function similar to `loadFiles`, but creates or deletes children of the + /// `FileItem` so that they are accurate with the file system, instead of creating an + /// entirely new `FileItem`, to prevent the `OutlineView` from going crazy with folding. + /// - Parameter fileItem: The `FileItem` to correct the children of + @discardableResult + func rebuildFiles(fromItem fileItem: CEWorkspaceFile) throws -> Bool { + var didChangeSomething = false + + // get the actual directory children + let directoryContentsUrls = try fileManager.contentsOfDirectory( + at: fileItem.url.resolvingSymlinksInPath(), + includingPropertiesForKeys: nil + ) + + // test for deleted children, and remove them from the index + for oldContent in fileItem.children ?? [] where !directoryContentsUrls.contains(oldContent.url) { + if let removeAt = fileItem.children?.firstIndex(of: oldContent) { + fileItem.children?[removeAt].watcher?.cancel() + fileItem.children?.remove(at: removeAt) + flattenedFileItems.removeValue(forKey: oldContent.id) + didChangeSomething = true + } + } + + // test for new children, and index them using loadFiles + for newContent in directoryContentsUrls { + guard !ignoredFilesAndFolders.contains(newContent.lastPathComponent) else { continue } + + // if the child has already been indexed, continue to the next item. + guard !(fileItem.children?.map({ $0.url }).contains(newContent) ?? false) else { continue } + + var isDir: ObjCBool = false + if fileManager.fileExists(atPath: newContent.path, isDirectory: &isDir) { + var subItems: [CEWorkspaceFile]? + + if isDir.boolValue { subItems = try loadFiles(fromUrl: newContent) } + + let newFileItem = CEWorkspaceFile( + url: newContent, + children: subItems?.sortItems(foldersOnTop: true) + ) + + newFileItem.watcherCode = { sourceFileItem in + self.reloadFromWatcher(sourceFileItem: sourceFileItem) + } + + subItems?.forEach { $0.parent = newFileItem } + + newFileItem.parent = fileItem + flattenedFileItems[newFileItem.id] = newFileItem + fileItem.children?.append(newFileItem) + didChangeSomething = true + } + } + + fileItem.children = fileItem.children?.sortItems(foldersOnTop: true) + fileItem.children?.forEach({ + if $0.isFolder { + let childChanged = try? rebuildFiles(fromItem: $0) + didChangeSomething = (childChanged ?? false) ? true : didChangeSomething + } + flattenedFileItems[$0.id] = $0 + }) + + return didChangeSomething + } + +} diff --git a/CodeEdit/Features/CodeEditUI/Views/ToolbarBranchPicker.swift b/CodeEdit/Features/CodeEditUI/Views/ToolbarBranchPicker.swift index 522d38232..c41fc234d 100644 --- a/CodeEdit/Features/CodeEditUI/Views/ToolbarBranchPicker.swift +++ b/CodeEdit/Features/CodeEditUI/Views/ToolbarBranchPicker.swift @@ -10,7 +10,7 @@ import CodeEditSymbols /// A view that pops up a branch picker. struct ToolbarBranchPicker: View { - private var workspace: WorkspaceClient? + private var workspaceFileManager: CEWorkspaceFileManager? private var gitClient: GitClient? @Environment(\.controlActiveState) @@ -30,10 +30,10 @@ struct ToolbarBranchPicker: View { /// - Parameter workspace: An instance of the current `WorkspaceClient` init( shellClient: ShellClient, - workspace: WorkspaceClient? + workspaceFileManager: CEWorkspaceFileManager? ) { - self.workspace = workspace - if let folderURL = workspace?.folderURL() { + self.workspaceFileManager = workspaceFileManager + if let folderURL = workspaceFileManager?.folderUrl { self.gitClient = GitClient(directoryURL: folderURL, shellClient: shellClient) } self._currentBranch = State(initialValue: try? gitClient?.getCurrentBranchName()) @@ -47,7 +47,7 @@ struct ToolbarBranchPicker: View { .imageScale(.large) .foregroundColor(controlActive == .inactive ? inactiveColor : .primary) } else { - Image(systemName: "square.dashed.inset.filled") + Image(systemName: "folder.fill.badge.gearshape") .font(.title3) .imageScale(.medium) .foregroundColor(controlActive == .inactive ? inactiveColor : .accentColor) @@ -94,7 +94,7 @@ struct ToolbarBranchPicker: View { } private var title: String { - workspace?.folderURL()?.lastPathComponent ?? "Empty" + workspaceFileManager?.folderUrl.lastPathComponent ?? "Empty" } // MARK: Popover View diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift index de97857b0..fc4e5bc74 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift @@ -226,9 +226,9 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, Obs let toolbarItem = NSToolbarItem(itemIdentifier: .branchPicker) let view = NSHostingView( rootView: ToolbarBranchPicker( - shellClient: currentWorld.shellClient, - workspace: workspace?.workspaceClient - ) + shellClient: currentWorld.shellClient, + workspaceFileManager: workspace?.workspaceFileManager + ) ) toolbarItem.view = view diff --git a/CodeEdit/Features/Documents/Views/WorkspaceCodeFileView.swift b/CodeEdit/Features/Documents/Views/WorkspaceCodeFileView.swift index 47aa4698e..5f8f54d3b 100644 --- a/CodeEdit/Features/Documents/Views/WorkspaceCodeFileView.swift +++ b/CodeEdit/Features/Documents/Views/WorkspaceCodeFileView.swift @@ -9,7 +9,14 @@ import SwiftUI import UniformTypeIdentifiers struct WorkspaceCodeFileView: View { - var file: WorkspaceClient.FileItem + + @EnvironmentObject + private var tabManager: TabManager + + @EnvironmentObject + private var tabgroup: TabGroupData + + var file: CEWorkspaceFile @ViewBuilder var codeView: some View { @@ -27,7 +34,7 @@ struct WorkspaceCodeFileView: View { Spacer() VStack(spacing: 10) { ProgressView() - Text("Opening \(file.fileName)...") + Text("Opening \(file.name)...") } Spacer() } @@ -36,7 +43,7 @@ struct WorkspaceCodeFileView: View { @ViewBuilder private func otherFileView( _ otherFile: CodeFileDocument, - for item: WorkspaceClient.FileItem + for item: CEWorkspaceFile ) -> some View { VStack(spacing: 0) { diff --git a/CodeEdit/Features/Documents/WorkspaceDocument+Listeners.swift b/CodeEdit/Features/Documents/WorkspaceDocument+Listeners.swift index 2b4601f17..8d79dca9f 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument+Listeners.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument+Listeners.swift @@ -9,9 +9,12 @@ import Foundation import Combine class WorkspaceNotificationModel: ObservableObject { + + @Published + var highlightedFileItem: CEWorkspaceFile? + init() { highlightedFileItem = nil } - @Published var highlightedFileItem: WorkspaceClient.FileItem? } diff --git a/CodeEdit/Features/Documents/WorkspaceDocument+Search.swift b/CodeEdit/Features/Documents/WorkspaceDocument+Search.swift index c0b250be1..69510f0ce 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument+Search.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument+Search.swift @@ -74,7 +74,7 @@ extension WorkspaceDocument { // - Lazily load strings using `FileHandle.AsyncBytes` // https://developer.apple.com/documentation/foundation/filehandle/3766681-bytes filePaths.map { url in - WorkspaceClient.FileItem(url: url, children: nil) + CEWorkspaceFile(url: url, children: nil) }.forEach { fileItem in guard let data = try? Data(contentsOf: fileItem.url), let string = String(data: data, encoding: .utf8) else { return } diff --git a/CodeEdit/Features/Documents/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument.swift index 3b5d3e5bb..826144169 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument.swift @@ -11,11 +11,10 @@ import SwiftUI import Combine @objc(WorkspaceDocument) final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { - var workspaceClient: WorkspaceClient? @Published var sortFoldersOnTop: Bool = true - @Published var fileItems: [WorkspaceClient.FileItem] = [] + var workspaceFileManager: CEWorkspaceFileManager? var tabManager = TabManager() @@ -30,6 +29,10 @@ import Combine } } + public var filter: String = "" { + didSet { workspaceFileManager?.onRefresh() } + } + var statusBarModel = StatusBarViewModel() var searchState: SearchState? var quickOpenViewModel: QuickOpenViewModel? @@ -111,9 +114,13 @@ import Combine // MARK: Set Up Workspace private func initWorkspaceState(_ url: URL) throws { - self.workspaceClient = try .default( - fileManager: .default, - folderURL: url, +// self.workspaceClient = try .default( +// fileManager: .default, +// folderURL: url, +// ignoredFilesAndFolders: ignoredFilesAndDirectory +// ) + self.workspaceFileManager = .init( + folderUrl: url, ignoredFilesAndFolders: ignoredFilesAndDirectory ) self.searchState = .init(self) @@ -123,33 +130,6 @@ import Combine override func read(from url: URL, ofType typeName: String) throws { try initWorkspaceState(url) - - // Initialize Workspace - workspaceClient? - .getFiles - .sink { [weak self] files in - guard let self else { return } - - guard !self.fileItems.isEmpty else { - self.fileItems = files - return - } - - // Instead of rebuilding the array we want to - // calculate the difference between the last iteration - // and now. If the index of the file exists in the array - // it means we need to remove the element, otherwise we need to append - // it. - let diff = files.difference(from: self.fileItems) - diff.forEach { newFile in - if let index = self.fileItems.firstIndex(of: newFile) { - self.fileItems.remove(at: index) - } else { - self.fileItems.append(newFile) - } - } - } - .store(in: &cancellables) } override func write(to url: URL, ofType typeName: String) throws {} diff --git a/CodeEdit/Features/NavigatorSidebar/FindNavigator/FindNavigatorResultList/FindNavigatorListViewController.swift b/CodeEdit/Features/NavigatorSidebar/FindNavigator/FindNavigatorResultList/FindNavigatorListViewController.swift index 5c3a0caef..1873951f8 100644 --- a/CodeEdit/Features/NavigatorSidebar/FindNavigator/FindNavigatorResultList/FindNavigatorListViewController.swift +++ b/CodeEdit/Features/NavigatorSidebar/FindNavigator/FindNavigatorResultList/FindNavigatorListViewController.swift @@ -12,7 +12,6 @@ final class FindNavigatorListViewController: NSViewController { public var workspace: WorkspaceDocument public var selectedItem: Any? - typealias FileItem = WorkspaceClient.FileItem private var searchId: UUID? private var searchItems: [SearchResultModel] = [] private var scrollView: NSScrollView! @@ -181,7 +180,7 @@ extension FindNavigatorListViewController: NSOutlineViewDelegate { width: tableColumn.width, height: prefs.general.projectNavigatorSize.rowHeight ) - let view = OutlineTableViewCell( + let view = ProjectNavigatorTableViewCell( frame: frameRect, item: (item as? SearchResultModel)?.file, isEditable: false diff --git a/CodeEdit/Features/NavigatorSidebar/NavigatorSidebarToolbarTop.swift b/CodeEdit/Features/NavigatorSidebar/NavigatorSidebarToolbar.swift similarity index 68% rename from CodeEdit/Features/NavigatorSidebar/NavigatorSidebarToolbarTop.swift rename to CodeEdit/Features/NavigatorSidebar/NavigatorSidebarToolbar.swift index f116f3a46..e8d83ed20 100644 --- a/CodeEdit/Features/NavigatorSidebar/NavigatorSidebarToolbarTop.swift +++ b/CodeEdit/Features/NavigatorSidebar/NavigatorSidebarToolbar.swift @@ -8,10 +8,12 @@ import SwiftUI import CodeEditSymbols -struct NavigatorSidebarToolbarTop: View { +struct NavigatorSidebarToolbar: View { @Environment(\.controlActiveState) private var activeState + var alignment: SidebarToolbarAlignment + @Binding private var selection: Int @@ -30,44 +32,80 @@ struct NavigatorSidebarToolbarTop: View { @State private var draggingItem: SidebarDockIcon? @State private var drugItemLocation: CGPoint? - init(selection: Binding) { + init(selection: Binding, alignment: SidebarToolbarAlignment) { self._selection = selection + self.alignment = alignment } var body: some View { + if alignment == .top { + topBody + } else { + leadingBody + } + } + + var topBody: some View { GeometryReader { proxy in - HStack(spacing: 0) { - ForEach(icons) { icon in - makeIcon(named: icon.imageName, title: icon.title, id: icon.id, sidebarWidth: proxy.size.width) - .opacity(draggingItem?.imageName == icon.imageName && - hasChangedLocation && - drugItemLocation != nil ? 0.0: icon.disabled ? 0.3 : 1.0) - .onDrop( - of: [.utf8PlainText], - delegate: NavigatorSidebarDockIconDelegate( - item: icon, - current: $draggingItem, - icons: $icons, - hasChangedLocation: $hasChangedLocation, - drugItemLocation: $drugItemLocation - ) - ) - .disabled(icon.disabled) + iconsView(size: proxy.size) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .top) { + Divider() } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .overlay(alignment: .top) { - Divider() - } - .overlay(alignment: .bottom) { - Divider() - } - .animation(.default, value: icons) + .overlay(alignment: .bottom) { + Divider() + } + .animation(.default, value: icons) } .frame(maxWidth: .infinity, idealHeight: 29) .fixedSize(horizontal: false, vertical: true) } + var leadingBody: some View { + GeometryReader { proxy in + iconsView(size: proxy.size) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .trailing) { + HStack { + Divider() + } + } + .animation(.default, value: icons) + } + .frame(idealWidth: 29, maxHeight: .infinity) + .fixedSize(horizontal: true, vertical: false) + } + + @ViewBuilder + func iconsView(size: CGSize) -> some View { + let layout = alignment == .top ? + AnyLayout(HStackLayout(spacing: 0)) : + AnyLayout(VStackLayout(spacing: 10)) + layout { + ForEach(icons) { icon in + makeIcon(named: icon.imageName, title: icon.title, id: icon.id, sidebarWidth: size.width) + .opacity(draggingItem?.imageName == icon.imageName && + hasChangedLocation && + drugItemLocation != nil ? 0.0: icon.disabled ? 0.3 : 1.0) + .onDrop( + of: [.utf8PlainText], + delegate: NavigatorSidebarDockIconDelegate( + item: icon, + current: $draggingItem, + icons: $icons, + hasChangedLocation: $hasChangedLocation, + drugItemLocation: $drugItemLocation + ) + ) + .disabled(icon.disabled) + } + if alignment == .leading { + Spacer() + } + } + } + private func makeIcon( named: String, title: String, diff --git a/CodeEdit/Features/NavigatorSidebar/NavigatorSidebarToolbarBottom.swift b/CodeEdit/Features/NavigatorSidebar/NavigatorSidebarToolbarBottom.swift deleted file mode 100644 index 8c698c3a4..000000000 --- a/CodeEdit/Features/NavigatorSidebar/NavigatorSidebarToolbarBottom.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// SideBarToolbarBottom.swift -// CodeEdit -// -// Created by Lukas Pistrol on 17.03.22. -// - -import SwiftUI - -struct NavigatorSidebarToolbarBottom: View { - @Environment(\.controlActiveState) - private var activeState - - @EnvironmentObject - var workspace: WorkspaceDocument - - var body: some View { - HStack(spacing: 10) { - addNewFileButton - Spacer() - sortButton - } - .frame(height: 29) - .frame(maxWidth: .infinity) - .padding(.horizontal, 4) - .overlay(alignment: .top) { - Divider() - } - } - - private var addNewFileButton: some View { - Menu { - Button("Add File") { - guard let folderURL = workspace.workspaceClient?.folderURL() else { return } - guard let root = try? workspace.workspaceClient?.getFileItem(folderURL.path) else { return } - let newFile = root.addFile(fileName: "untitled") // TODO: use currently selected file instead of root - - DispatchQueue.main.async { - guard let newFileItem = try? workspace.workspaceClient?.getFileItem(newFile) else { - return - } - workspace.tabManager.openTab(item: newFileItem) - } - - } - Button("Add Folder") { - guard let folderURL = workspace.workspaceClient?.folderURL() else { return } - guard let root = try? workspace.workspaceClient?.getFileItem(folderURL.path) else { return } - root.addFolder(folderName: "untitled") // TODO: use currently selected file instead of root - } - } label: { - Image(systemName: "plus") - } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .frame(maxWidth: 30) - .opacity(activeState == .inactive ? 0.45 : 1) - } - - private var sortButton: some View { - Menu { - Button { - workspace.sortFoldersOnTop.toggle() - } label: { - Text(workspace.sortFoldersOnTop ? "Alphabetically" : "Folders on top") - } - } label: { - Image(systemName: "line.3.horizontal.decrease.circle") - } - .menuStyle(.borderlessButton) - .frame(maxWidth: 30) - .opacity(activeState == .inactive ? 0.45 : 1) - } -} diff --git a/CodeEdit/Features/NavigatorSidebar/NavigatorSidebarView.swift b/CodeEdit/Features/NavigatorSidebar/NavigatorSidebarView.swift index ecefca1f8..3ce3ec40d 100644 --- a/CodeEdit/Features/NavigatorSidebar/NavigatorSidebarView.swift +++ b/CodeEdit/Features/NavigatorSidebar/NavigatorSidebarView.swift @@ -20,6 +20,8 @@ struct NavigatorSidebarView: View { self.workspace = workspace } + var sidebarAlignment: SidebarToolbarAlignment = .top + var body: some View { VStack { switch selection { @@ -33,19 +35,31 @@ struct NavigatorSidebarView: View { Spacer() } } + .padding(.top, sidebarAlignment == .leading ? toolbarPadding : 0) + .safeAreaInset(edge: .leading) { + if sidebarAlignment == .leading { + NavigatorSidebarToolbar(selection: $selection, alignment: sidebarAlignment) + .padding(.top, toolbarPadding) + .padding(.trailing, toolbarPadding) + } + } .safeAreaInset(edge: .top) { - NavigatorSidebarToolbarTop(selection: $selection) - .padding(.bottom, toolbarPadding) + if sidebarAlignment == .top { + NavigatorSidebarToolbar(selection: $selection, alignment: sidebarAlignment) + .padding(.bottom, toolbarPadding) + } else { + Divider() + } } .safeAreaInset(edge: .bottom) { Group { switch selection { case 0: - NavigatorSidebarToolbarBottom() + ProjectNavigatorToolbarBottom() case 1: SourceControlToolbarBottom() - default: - NavigatorSidebarToolbarBottom() + default: // TODO: As we implement more sidebars, put their bottom toolbars here. + EmptyView() } } .padding(.top, toolbarPadding) @@ -53,3 +67,7 @@ struct NavigatorSidebarView: View { .environmentObject(workspace) } } + +enum SidebarToolbarAlignment { + case top, leading +} diff --git a/CodeEdit/Features/NavigatorSidebar/OutlineView/FileSystemTableViewCell.swift b/CodeEdit/Features/NavigatorSidebar/OutlineView/FileSystemTableViewCell.swift new file mode 100644 index 000000000..d887f6649 --- /dev/null +++ b/CodeEdit/Features/NavigatorSidebar/OutlineView/FileSystemTableViewCell.swift @@ -0,0 +1,149 @@ +// +// FileSystemOutlineView.swift +// CodeEdit +// +// Created by TAY KAI QUAN on 14/8/22. +// + +import SwiftUI + +class FileSystemTableViewCell: StandardTableViewCell { + + var fileItem: CEWorkspaceFile! + + var changeLabelLargeWidth: NSLayoutConstraint! + var changeLabelSmallWidth: NSLayoutConstraint! + + private let prefs = Settings.shared.preferences.general + + /// Initializes the `OutlineTableViewCell` with an `icon` and `label` + /// Both the icon and label will be colored, and sized based on the user's preferences. + /// - Parameters: + /// - frameRect: The frame of the cell. + /// - item: The file item the cell represents. + /// - isEditable: Set to true if the user should be able to edit the file name. + init(frame frameRect: NSRect, item: CEWorkspaceFile?, isEditable: Bool = true) { + super.init(frame: frameRect, isEditable: isEditable) + + if let item = item { + addIcon(item: item) + } + addModel() + } + + override func configLabel(label: NSTextField, isEditable: Bool) { + super.configLabel(label: label, isEditable: isEditable) + label.delegate = self + } + + func addIcon(item: CEWorkspaceFile) { + var imageName = item.systemImage + if item.watcherCode == nil { + imageName = "exclamationmark.arrow.triangle.2.circlepath" + } + if item.watcher == nil && !item.activateWatcher() { + // watcher failed to activate + imageName = "eye.trianglebadge.exclamationmark" + } + let image = NSImage(systemSymbolName: imageName, accessibilityDescription: nil)! + fileItem = item + icon.image = image + icon.contentTintColor = color(for: item) + toolTip = label(for: item) + label.stringValue = label(for: item) + } + + func addModel() { + secondaryLabel.stringValue = fileItem.gitStatus?.description ?? "" + if secondaryLabel.stringValue == "?" { secondaryLabel.stringValue = "A" } + } + + /// *Not Implemented* + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + fatalError(""" + init(frame: ) isn't implemented on `OutlineTableViewCell`. + Please use `.init(frame: NSRect, item: FileSystemClient.FileItem?) + """) + } + + /// *Not Implemented* + required init?(coder: NSCoder) { + fatalError(""" + init?(coder: NSCoder) isn't implemented on `OutlineTableViewCell`. + Please use `.init(frame: NSRect, item: FileSystemClient.FileItem?) + """) + } + + /// Returns the font size for the current row height. Defaults to `13.0` + private var fontSize: Double { + switch self.frame.height { + case 20: return 11 + case 22: return 13 + case 24: return 14 + default: return 13 + } + } + + /// Generates a string based on user's file name preferences. + /// - Parameter item: The FileItem to generate the name for. + /// - Returns: A `String` with the name to display. + func label(for item: CEWorkspaceFile) -> String { + switch prefs.fileExtensionsVisibility { + case .hideAll: + return item.fileName(typeHidden: true) + case .showAll: + return item.fileName(typeHidden: false) + case .showOnly: + return item.fileName(typeHidden: !prefs.shownFileExtensions.extensions.contains(item.type.rawValue)) + case .hideOnly: + return item.fileName(typeHidden: prefs.hiddenFileExtensions.extensions.contains(item.type.rawValue)) + } + } + + /// Get the appropriate color for the items icon depending on the users preferences. + /// - Parameter item: The `FileItem` to get the color for + /// - Returns: A `NSColor` for the given `FileItem`. + func color(for item: CEWorkspaceFile) -> NSColor { + if item.children == nil && prefs.fileIconStyle == .color { + return NSColor(item.iconColor) + } else { + return NSColor(named: "FolderBlue")! + } + } +} + +let errorRed = NSColor(red: 1, green: 0, blue: 0, alpha: 0.2) +extension FileSystemTableViewCell: NSTextFieldDelegate { + func controlTextDidChange(_ obj: Notification) { + label.backgroundColor = validateFileName(for: label?.stringValue ?? "") ? .none : errorRed + } + func controlTextDidEndEditing(_ obj: Notification) { + label.backgroundColor = validateFileName(for: label?.stringValue ?? "") ? .none : errorRed + if validateFileName(for: label?.stringValue ?? "") { + fileItem.move(to: fileItem.url.deletingLastPathComponent() + .appendingPathComponent(label?.stringValue ?? "")) + } else { + label?.stringValue = label(for: fileItem) + } + } + + func validateFileName(for newName: String) -> Bool { + guard newName != label(for: fileItem) else { return true } + + guard !newName.isEmpty && newName.isValidFilename && + !FileManager.default.fileExists(atPath: + fileItem.url.deletingLastPathComponent().appendingPathComponent(newName).path) + else { return false } + + return true + } +} + +extension String { + var isValidFilename: Bool { + let regex = "[^:]" + let testString = NSPredicate(format: "SELF MATCHES %@", regex) + return !testString.evaluate(with: self) + } +} diff --git a/CodeEdit/Features/NavigatorSidebar/OutlineView/StandardTableViewCell.swift b/CodeEdit/Features/NavigatorSidebar/OutlineView/StandardTableViewCell.swift new file mode 100644 index 000000000..df9d6c4f6 --- /dev/null +++ b/CodeEdit/Features/NavigatorSidebar/OutlineView/StandardTableViewCell.swift @@ -0,0 +1,198 @@ +// +// StandardTableViewCell.swift +// CodeEdit +// +// Created by TAY KAI QUAN on 17/8/22. +// + +import SwiftUI + +class StandardTableViewCell: NSTableCellView { + + var label: NSTextField! + var secondaryLabel: NSTextField! + var icon: NSImageView! + + var workspace: WorkspaceDocument? + + var secondaryLabelRightAlignmed: Bool = true { + didSet { + resizeSubviews(withOldSize: .zero) + } + } + + private let prefs = Settings.shared.preferences.general + + /// Initializes the `TableViewCell` with an `icon` and `label` + /// Both the icon and label will be colored, and sized based on the user's preferences. + /// - Parameters: + /// - frameRect: The frame of the cell. + /// - item: The file item the cell represents. + /// - isEditable: Set to true if the user should be able to edit the file name. + init(frame frameRect: NSRect, isEditable: Bool = true) { + super.init(frame: frameRect) + setupViews(frame: frameRect, isEditable: isEditable) + } + + // Default init, assumes isEditable to be false + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupViews(frame: frameRect, isEditable: false) + } + + private func setupViews(frame frameRect: NSRect, isEditable: Bool) { + // Create the label + label = createLabel() + configLabel(label: self.label, isEditable: isEditable) + self.textField = label + + // Create the secondary label + secondaryLabel = createSecondaryLabel() + configSecondaryLabel(secondaryLabel: secondaryLabel) + + // Create the icon + icon = createIcon() + configIcon(icon: icon) + addSubview(icon) + imageView = icon + + // add constraints + createConstraints(frame: frameRect) + addSubview(label) + addSubview(secondaryLabel) + addSubview(icon) + } + + // MARK: Create and config stuff + func createLabel() -> NSTextField { + return SpecialSelectTextField(frame: .zero) + } + + func configLabel(label: NSTextField, isEditable: Bool) { + label.translatesAutoresizingMaskIntoConstraints = false + label.drawsBackground = false + label.isBordered = false + label.isEditable = isEditable + label.isSelectable = isEditable + label.layer?.cornerRadius = 10.0 + label.font = .labelFont(ofSize: fontSize) + label.lineBreakMode = .byTruncatingMiddle + } + + func createSecondaryLabel() -> NSTextField { + return NSTextField(frame: .zero) + } + + func configSecondaryLabel(secondaryLabel: NSTextField) { + secondaryLabel.translatesAutoresizingMaskIntoConstraints = false + secondaryLabel.drawsBackground = false + secondaryLabel.isBordered = false + secondaryLabel.isEditable = false + secondaryLabel.isSelectable = false + secondaryLabel.layer?.cornerRadius = 10.0 + secondaryLabel.font = .systemFont(ofSize: fontSize) + secondaryLabel.alignment = .center + secondaryLabel.textColor = NSColor(Color.secondary) + } + + func createIcon() -> NSImageView { + return NSImageView(frame: .zero) + } + + func configIcon(icon: NSImageView) { + icon.translatesAutoresizingMaskIntoConstraints = false + icon.symbolConfiguration = .init(pointSize: fontSize, weight: .regular, scale: .medium) + } + + func createConstraints(frame frameRect: NSRect) { + resizeSubviews(withOldSize: .zero) + } + + let iconWidth: CGFloat = 22 + override func resizeSubviews(withOldSize oldSize: NSSize) { + super.resizeSubviews(withOldSize: oldSize) + + icon.frame = NSRect( + x: 2, + y: 4, + width: iconWidth, + height: frame.height + ) + // center align the image + if let alignmentRect = icon.image?.alignmentRect { + icon.frame = NSRect( + x: (iconWidth - alignmentRect.width) / 2, + y: 4, + width: alignmentRect.width, + height: frame.height + ) + } + + // right align the secondary label + if secondaryLabelRightAlignmed { + let secondLabelWidth = secondaryLabel.frame.size.width + let newSize = secondaryLabel.sizeThatFits( + CGSize(width: secondLabelWidth, height: CGFloat.greatestFiniteMagnitude) + ) + // somehow, a width of 0 makes it resize properly. + secondaryLabel.frame = NSRect( + x: frame.width - newSize.width, + y: 3.5, + width: 0, + height: newSize.height + ) + + label.frame = NSRect( + x: iconWidth + 2, + y: 3.5, + width: secondaryLabel.frame.minX - icon.frame.maxX - 5, + height: 25 + ) + + // put the secondary label right after the primary label + } else { + let mainLabelWidth = label.frame.size.width + let newSize = label.sizeThatFits(CGSize(width: mainLabelWidth, height: CGFloat.greatestFiniteMagnitude)) + label.frame = NSRect( + x: iconWidth + 2, + y: 2.5, + width: newSize.width, + height: 25 + ) + secondaryLabel.frame = NSRect( + x: label.frame.maxX + 2, + y: 2.5, + width: frame.width - label.frame.maxX - 2, + height: 25 + ) + } + } + + /// *Not Implemented* + required init?(coder: NSCoder) { + fatalError(""" + init?(coder: NSCoder) isn't implemented on `StandardTableViewCell`. + Please use `.init(frame: NSRect, isEditable: Bool) + """) + } + + /// Returns the font size for the current row height. Defaults to `13.0` + private var fontSize: Double { + switch self.frame.height { + case 20: return 11 + case 22: return 13 + case 24: return 14 + default: return 13 + } + } + + class SpecialSelectTextField: NSTextField { +// override func becomeFirstResponder() -> Bool { + // TODO: Set text range + // this is the code to get the text range, however I cannot find a way to select it :( +// NSRange(location: 0, length: stringValue.distance(from: stringValue.startIndex, +// to: stringValue.lastIndex(of: ".") ?? stringValue.endIndex)) +// return true +// } + } +} diff --git a/CodeEdit/Features/NavigatorSidebar/OutlineView/TextTableViewCell.swift b/CodeEdit/Features/NavigatorSidebar/OutlineView/TextTableViewCell.swift new file mode 100644 index 000000000..d18e1cb1b --- /dev/null +++ b/CodeEdit/Features/NavigatorSidebar/OutlineView/TextTableViewCell.swift @@ -0,0 +1,85 @@ +// +// TextTableViewCell.swift +// CodeEdit +// +// Created by TAY KAI QUAN on 11/9/22. +// + +import SwiftUI + +class TextTableViewCell: NSTableCellView { + + var label: NSTextField! + + init(frame frameRect: NSRect, isEditable: Bool = true, startingText: String = "") { + super.init(frame: frameRect) + setupViews(frame: frameRect, isEditable: isEditable) + self.label.stringValue = startingText + } + + // Default init, assumes isEditable to be false + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupViews(frame: frameRect, isEditable: false) + } + + private func setupViews(frame frameRect: NSRect, isEditable: Bool) { + // Create the label + label = createLabel() + configLabel(label: self.label, isEditable: isEditable) + self.textField = label + + addSubview(label) + createConstraints(frame: frameRect) + } + + // MARK: Create and config stuff + func createLabel() -> NSTextField { + return NSTextField(frame: .zero) + } + + func configLabel(label: NSTextField, isEditable: Bool) { + label.translatesAutoresizingMaskIntoConstraints = false + label.drawsBackground = false + label.isBordered = false + label.isEditable = isEditable + label.isSelectable = isEditable + label.layer?.cornerRadius = 10.0 + label.font = .boldSystemFont(ofSize: fontSize) + label.lineBreakMode = .byTruncatingMiddle + label.textColor = NSColor.textColor + label.alphaValue = 0.7 + } + + func createConstraints(frame frameRect: NSRect) { + resizeSubviews(withOldSize: .zero) + } + + override func resizeSubviews(withOldSize oldSize: NSSize) { + super.resizeSubviews(withOldSize: oldSize) + label.frame = NSRect( + x: 2, + y: 2.5, + width: frame.width - 4, + height: 25 + ) + } + + /// Returns the font size for the current row height. Defaults to `13.0` + private var fontSize: Double { + switch self.frame.height { + case 20: return 11 + case 22: return 13 + case 24: return 14 + default: return 13 + } + } + + /// *Not Implemented* + required init(coder: NSCoder) { + fatalError(""" + init?(coder: NSCoder) isn't implemented on `TextTableViewCell`. + Please use `.init(frame: NSRect, isEditable: Bool) + """) + } +} diff --git a/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/OutlineTableViewCell.swift b/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/OutlineTableViewCell.swift deleted file mode 100644 index 7f985ce90..000000000 --- a/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/OutlineTableViewCell.swift +++ /dev/null @@ -1,179 +0,0 @@ -// -// OutlineTableViewCell.swift -// CodeEdit -// -// Created by Lukas Pistrol on 07.04.22. -// - -import SwiftUI - -protocol OutlineTableViewCellDelegate: AnyObject { - func moveFile(file: WorkspaceClient.FileItem, to destination: URL) - func copyFile(file: WorkspaceClient.FileItem, to destination: URL) -} - -/// A `NSTableCellView` showing an ``icon`` and a ``label`` -final class OutlineTableViewCell: NSTableCellView { - - var label: NSTextField! - var icon: NSImageView! - private var fileItem: WorkspaceClient.FileItem! - private var delegate: OutlineTableViewCellDelegate? - - /// Initializes the `OutlineTableViewCell` with an `icon` and `label` - /// Both the icon and label will be colored, and sized based on the user's preferences. - /// - Parameters: - /// - frameRect: The frame of the cell. - /// - item: The file item the cell represents. - /// - isEditable: Set to true if the user should be able to edit the file name. - init( - frame frameRect: NSRect, item: WorkspaceClient.FileItem?, - isEditable: Bool = true, - delegate: OutlineTableViewCellDelegate? = nil - ) { - super.init(frame: frameRect) - - self.delegate = delegate - - // Create the label - - self.label = NSTextField(frame: .zero) - self.label.translatesAutoresizingMaskIntoConstraints = false - self.label.drawsBackground = false - self.label.isBordered = false - self.label.isEditable = isEditable - self.label.isSelectable = isEditable - self.label.delegate = self - self.label.layer?.cornerRadius = 10.0 - self.label.font = .labelFont(ofSize: fontSize) - self.label.lineBreakMode = .byTruncatingMiddle - - self.addSubview(label) - self.textField = label - - // Create the icon - - self.icon = NSImageView(frame: .zero) - self.icon.translatesAutoresizingMaskIntoConstraints = false - self.icon.symbolConfiguration = .init(pointSize: fontSize, weight: .regular, scale: .medium) - - self.addSubview(icon) - self.imageView = icon - - // Icon constraints - - self.icon.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: -2).isActive = true - self.icon.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true - self.icon.widthAnchor.constraint(equalToConstant: 25).isActive = true - self.icon.heightAnchor.constraint(equalToConstant: frameRect.height).isActive = true - - // Label constraints - - self.label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 1).isActive = true - self.label.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: 1).isActive = true - self.label.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true - - if let item { - let image = NSImage(systemSymbolName: item.systemImage, accessibilityDescription: nil)! - fileItem = item - icon.image = image - icon.contentTintColor = color(for: item) - - label.stringValue = label(for: item) - } - } - - /// *Not Implemented* - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - fatalError(""" - init(frame: ) isn't implemented on `OutlineTableViewCell`. - Please use `.init(frame: NSRect, item: WorkspaceClient.FileItem?) - """) - } - - /// *Not Implemented* - required init?(coder: NSCoder) { - fatalError(""" - init?(coder: NSCoder) isn't implemented on `OutlineTableViewCell`. - Please use `.init(frame: NSRect, item: WorkspaceClient.FileItem?) - """) - } - - /// Returns the font size for the current row height. Defaults to `13.0` - private var fontSize: Double { - switch self.frame.height { - case 20: return 11 - case 22: return 13 - case 24: return 14 - default: return 13 - } - } - - /// Generates a string based on user's file name preferences. - /// - Parameter item: The FileItem to generate the name for. - /// - Returns: A `String` with the name to display. - private func label(for item: WorkspaceClient.FileItem) -> String { - let prefs = Settings[\.general] - switch prefs.fileExtensionsVisibility { - case .hideAll: - return item.fileName(typeHidden: true) - case .showAll: - return item.fileName(typeHidden: false) - case .showOnly: - return item.fileName(typeHidden: !prefs.shownFileExtensions.extensions.contains(item.fileType.rawValue)) - case .hideOnly: - return item.fileName(typeHidden: prefs.hiddenFileExtensions.extensions.contains(item.fileType.rawValue)) - } - } - - /// Get the appropriate color for the items icon depending on the users preferences. - /// - Parameter item: The `FileItem` to get the color for - /// - Returns: A `NSColor` for the given `FileItem`. - private func color(for item: WorkspaceClient.FileItem) -> NSColor { - return Settings[\.general.fileIconStyle] == .color - ? item.children == nil ? NSColor(item.iconColor) : NSColor(named: "FolderBlue")! - : .secondaryLabelColor - } -} - -extension OutlineTableViewCell: NSTextFieldDelegate { - var errorRed: NSColor { .init(red: 1, green: 0, blue: 0, alpha: 0.2) } - - func controlTextDidChange(_ obj: Notification) { - print("Contents changed to \(label?.stringValue ?? "idk")") - print("File validity: \(validateFileName(for: label?.stringValue ?? ""))") - label.backgroundColor = validateFileName(for: label?.stringValue ?? "") ? .textBackgroundColor : errorRed - } - func controlTextDidEndEditing(_ obj: Notification) { - print("File validity: \(validateFileName(for: label?.stringValue ?? ""))") - label.backgroundColor = validateFileName(for: label?.stringValue ?? "") ? .textBackgroundColor : errorRed - if validateFileName(for: label?.stringValue ?? "") { - let destinationURL = fileItem.url - .deletingLastPathComponent() - .appendingPathComponent(label?.stringValue ?? "") - delegate?.moveFile(file: fileItem, to: destinationURL) - } else { - label?.stringValue = fileItem.fileName - } - } - - func validateFileName(for newName: String) -> Bool { - guard newName != fileItem.fileName else { return true } - - guard newName != "" && newName.isValidFilename && - !WorkspaceClient.FileItem.fileManger.fileExists(atPath: - fileItem.url.deletingLastPathComponent().appendingPathComponent(newName).path) - else { return false } - - return true - } -} - -extension String { - var isValidFilename: Bool { - let regex = "[^:]" - let testString = NSPredicate(format: "SELF MATCHES %@", regex) - return !testString.evaluate(with: self) - } -} diff --git a/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/OutlineMenu.swift b/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/ProjectNavigatorMenu.swift similarity index 95% rename from CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/OutlineMenu.swift rename to CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/ProjectNavigatorMenu.swift index 17e1b466f..a0bbf44f9 100644 --- a/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/OutlineMenu.swift +++ b/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/ProjectNavigatorMenu.swift @@ -9,11 +9,10 @@ import SwiftUI import UniformTypeIdentifiers /// A subclass of `NSMenu` implementing the contextual menu for the project navigator -final class OutlineMenu: NSMenu { - typealias Item = WorkspaceClient.FileItem +final class ProjectNavigatorMenu: NSMenu { /// The item to show the contextual menu for - var item: Item? + var item: CEWorkspaceFile? /// The workspace, for opening the item var workspace: WorkspaceDocument? @@ -61,7 +60,7 @@ final class OutlineMenu: NSMenu { let rename = menuItem("Rename", action: #selector(renameFile)) let delete = menuItem("Delete", action: - item.url != workspace?.workspaceClient?.folderURL() + item.url != workspace?.workspaceFileManager?.folderUrl ? #selector(delete) : nil) let duplicate = menuItem("Duplicate \(item.isFolder ? "Folder" : "File")", action: #selector(duplicate)) @@ -102,7 +101,7 @@ final class OutlineMenu: NSMenu { } /// Submenu for **Open As** menu item. - private func openAsMenu(item: Item) -> NSMenu { + private func openAsMenu(item: CEWorkspaceFile) -> NSMenu { let openAsMenu = NSMenu(title: "Open As") func getMenusItems() -> ([NSMenuItem], [NSMenuItem]) { // Use UTType to distinguish between bundle file and user-browsable directory @@ -154,7 +153,7 @@ final class OutlineMenu: NSMenu { } /// Submenu for **Source Control** menu item. - private func sourceControlMenu(item: Item) -> NSMenu { + private func sourceControlMenu(item: CEWorkspaceFile) -> NSMenu { let sourceControlMenu = NSMenu(title: "Source Control") sourceControlMenu.addItem(withTitle: "Commit \"\(item.fileName)\"...", action: nil, keyEquivalent: "") sourceControlMenu.addItem(.separator()) @@ -214,7 +213,11 @@ final class OutlineMenu: NSMenu { private func renameFile() { let row = outlineView.row(forItem: item) guard row > 0, - let cell = outlineView.view(atColumn: 0, row: row, makeIfNecessary: false) as? OutlineTableViewCell else { + let cell = outlineView.view( + atColumn: 0, + row: row, + makeIfNecessary: false + ) as? ProjectNavigatorTableViewCell else { return } outlineView.window?.makeFirstResponder(cell.textField) diff --git a/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/OutlineView.swift b/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift similarity index 50% rename from CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/OutlineView.swift rename to CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift index 0d478285e..1151e6484 100644 --- a/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/OutlineView.swift +++ b/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift @@ -9,33 +9,39 @@ import SwiftUI import Combine /// Wraps an ``OutlineViewController`` inside a `NSViewControllerRepresentable` -struct OutlineView: NSViewControllerRepresentable { +struct ProjectNavigatorOutlineView: NSViewControllerRepresentable { @EnvironmentObject var workspace: WorkspaceDocument + @StateObject + var prefs: Settings = .shared + // This is mainly just used to trigger a view update. @Binding - var selection: WorkspaceClient.FileItem? + var selection: CEWorkspaceFile? - typealias NSViewControllerType = OutlineViewController + typealias NSViewControllerType = ProjectNavigatorViewController - func makeNSViewController(context: Context) -> OutlineViewController { - let controller = OutlineViewController() + func makeNSViewController(context: Context) -> ProjectNavigatorViewController { + let controller = ProjectNavigatorViewController() controller.workspace = workspace - controller.iconColor = Settings[\.general].fileIconStyle + controller.iconColor = prefs.preferences.general.fileIconStyle + workspace.workspaceFileManager?.onRefresh = { + controller.outlineView.reloadData() + } context.coordinator.controller = controller return controller } - func updateNSViewController(_ nsViewController: OutlineViewController, context: Context) { - nsViewController.iconColor = Settings[\.general].fileIconStyle - nsViewController.rowHeight = Settings[\.general].projectNavigatorSize.rowHeight - nsViewController.fileExtensionsVisibility = Settings[\.general].fileExtensionsVisibility - nsViewController.shownFileExtensions = Settings[\.general].shownFileExtensions - nsViewController.hiddenFileExtensions = Settings[\.general].hiddenFileExtensions + func updateNSViewController(_ nsViewController: ProjectNavigatorViewController, context: Context) { + nsViewController.iconColor = prefs.preferences.general.fileIconStyle + nsViewController.rowHeight = prefs.preferences.general.projectNavigatorSize.rowHeight + nsViewController.fileExtensionsVisibility = prefs.preferences.general.fileExtensionsVisibility + nsViewController.shownFileExtensions = prefs.preferences.general.shownFileExtensions + nsViewController.hiddenFileExtensions = prefs.preferences.general.hiddenFileExtensions nsViewController.updateSelection() return } @@ -60,7 +66,7 @@ struct OutlineView: NSViewControllerRepresentable { var listener: AnyCancellable? var workspace: WorkspaceDocument - var controller: OutlineViewController? + var controller: ProjectNavigatorViewController? deinit { listener?.cancel() diff --git a/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift b/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift new file mode 100644 index 000000000..389488a8f --- /dev/null +++ b/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift @@ -0,0 +1,64 @@ +// +// OutlineTableViewCell.swift +// CodeEdit +// +// Created by Lukas Pistrol on 07.04.22. +// + +import SwiftUI + +protocol OutlineTableViewCellDelegate: AnyObject { + func moveFile(file: CEWorkspaceFile, to destination: URL) + func copyFile(file: CEWorkspaceFile, to destination: URL) +} + +/// A `NSTableCellView` showing an ``icon`` and a ``label`` +final class ProjectNavigatorTableViewCell: FileSystemTableViewCell { + private var delegate: OutlineTableViewCellDelegate? + + /// Initializes the `OutlineTableViewCell` with an `icon` and `label` + /// Both the icon and label will be colored, and sized based on the user's preferences. + /// - Parameters: + /// - frameRect: The frame of the cell. + /// - item: The file item the cell represents. + /// - isEditable: Set to true if the user should be able to edit the file name. + init( + frame frameRect: NSRect, + item: CEWorkspaceFile?, + isEditable: Bool = true, + delegate: OutlineTableViewCellDelegate? = nil + ) { + super.init(frame: frameRect, item: item, isEditable: isEditable) + + self.delegate = delegate + } + + /// *Not Implemented* + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + fatalError(""" + init(frame: ) isn't implemented on `OutlineTableViewCell`. + Please use `.init(frame: NSRect, item: WorkspaceClient.FileItem?) + """) + } + + /// *Not Implemented* + required init?(coder: NSCoder) { + fatalError(""" + init?(coder: NSCoder) isn't implemented on `OutlineTableViewCell`. + Please use `.init(frame: NSRect, item: WorkspaceClient.FileItem?) + """) + } + + override func controlTextDidEndEditing(_ obj: Notification) { + label.backgroundColor = validateFileName(for: label?.stringValue ?? "") ? .none : errorRed + if validateFileName(for: label?.stringValue ?? "") { + let destinationURL = fileItem.url + .deletingLastPathComponent() + .appendingPathComponent(label?.stringValue ?? "") + delegate?.moveFile(file: fileItem, to: destinationURL) + } else { + label?.stringValue = fileItem.name + } + } +} diff --git a/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/OutlintViewController+OutlineTableViewCellDelegate.swift b/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift similarity index 65% rename from CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/OutlintViewController+OutlineTableViewCellDelegate.swift rename to CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift index 9fc423f3f..1f23f4f13 100644 --- a/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/OutlintViewController+OutlineTableViewCellDelegate.swift +++ b/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift @@ -9,8 +9,8 @@ import Foundation // MARK: - OutlineTableViewCellDelegate -extension OutlineViewController: OutlineTableViewCellDelegate { - func moveFile(file: Item, to destination: URL) { +extension ProjectNavigatorViewController: OutlineTableViewCellDelegate { + func moveFile(file: CEWorkspaceFile, to destination: URL) { if !file.isFolder { workspace?.tabManager.tabGroups.closeAllTabs(of: file) } @@ -20,7 +20,7 @@ extension OutlineViewController: OutlineTableViewCellDelegate { } } - func copyFile(file: WorkspaceClient.FileItem, to destination: URL) { - file.duplicate(to: destination) + func copyFile(file: CEWorkspaceFile, to destination: URL) { + file.duplicate() } } diff --git a/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/OutlineViewController.swift b/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift similarity index 79% rename from CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/OutlineViewController.swift rename to CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift index b0b560197..f71743036 100644 --- a/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/OutlineViewController.swift +++ b/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift @@ -11,9 +11,7 @@ import SwiftUI /// /// Adds a ``outlineView`` inside a ``scrollView`` which shows the folder structure of the /// currently open project. -final class OutlineViewController: NSViewController { - - typealias Item = WorkspaceClient.FileItem +final class ProjectNavigatorViewController: NSViewController { var scrollView: NSScrollView! var outlineView: NSOutlineView! @@ -21,11 +19,9 @@ final class OutlineViewController: NSViewController { /// Gets the folder structure /// /// Also creates a top level item "root" which represents the projects root directory and automatically expands it. - private var content: [Item] { - guard let folderURL = workspace?.workspaceClient?.folderURL() else { return [] } - let children = workspace?.fileItems.sortItems(foldersOnTop: true) - guard let root = try? workspace?.workspaceClient?.getFileItem(folderURL.path) else { return [] } - root.children = children + private var content: [CEWorkspaceFile] { + guard let folderURL = workspace?.workspaceFileManager?.folderUrl else { return [] } + guard let root = try? workspace?.workspaceFileManager?.getFile(folderURL.path) else { return [] } return [root] } @@ -58,9 +54,9 @@ final class OutlineViewController: NSViewController { self.outlineView.dataSource = self self.outlineView.delegate = self self.outlineView.autosaveExpandedItems = true - self.outlineView.autosaveName = workspace?.workspaceClient?.folderURL()?.path ?? "" + self.outlineView.autosaveName = workspace?.workspaceFileManager?.folderUrl.path ?? "" self.outlineView.headerView = nil - self.outlineView.menu = OutlineMenu(sender: self.outlineView) + self.outlineView.menu = ProjectNavigatorMenu(sender: self.outlineView) self.outlineView.menu?.delegate = self self.outlineView.doubleAction = #selector(onItemDoubleClicked) @@ -71,11 +67,14 @@ final class OutlineViewController: NSViewController { outlineView.setDraggingSourceOperationMask(.move, forLocal: false) outlineView.registerForDraggedTypes([.fileURL]) - self.scrollView.documentView = outlineView - self.scrollView.contentView.automaticallyAdjustsContentInsets = false - self.scrollView.contentView.contentInsets = .init(top: 10, left: 0, bottom: 0, right: 0) + scrollView.documentView = outlineView + scrollView.contentView.automaticallyAdjustsContentInsets = false + scrollView.contentView.contentInsets = .init(top: 10, left: 0, bottom: 0, right: 0) + scrollView.scrollerStyle = .overlay + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true - WorkspaceClient.onRefresh = self.outlineView.reloadData outlineView.expandItem(outlineView.item(atRow: 0)) } @@ -102,7 +101,7 @@ final class OutlineViewController: NSViewController { /// Expand or collapse the folder on double click @objc private func onItemDoubleClicked() { - guard let item = outlineView.item(atRow: outlineView.clickedRow) as? Item else { return } + guard let item = outlineView.item(atRow: outlineView.clickedRow) as? CEWorkspaceFile else { return } if item.children != nil { if outlineView.isItemExpanded(item) { @@ -118,7 +117,7 @@ final class OutlineViewController: NSViewController { /// Get the appropriate color for the items icon depending on the users preferences. /// - Parameter item: The `FileItem` to get the color for /// - Returns: A `NSColor` for the given `FileItem`. - private func color(for item: Item) -> NSColor { + private func color(for item: CEWorkspaceFile) -> NSColor { if item.children == nil && iconColor == .color { return NSColor(item.iconColor) } else { @@ -126,20 +125,21 @@ final class OutlineViewController: NSViewController { } } + // TODO: File filtering } // MARK: - NSOutlineViewDataSource -extension OutlineViewController: NSOutlineViewDataSource { +extension ProjectNavigatorViewController: NSOutlineViewDataSource { func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { - if let item = item as? Item { + if let item = item as? CEWorkspaceFile { return item.children?.count ?? 0 } return content.count } func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { - if let item = item as? Item, + if let item = item as? CEWorkspaceFile, let children = item.children { return children[index] } @@ -147,7 +147,7 @@ extension OutlineViewController: NSOutlineViewDataSource { } func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { - if let item = item as? Item { + if let item = item as? CEWorkspaceFile { return item.children != nil } return false @@ -155,7 +155,7 @@ extension OutlineViewController: NSOutlineViewDataSource { /// write dragged file(s) to pasteboard func outlineView(_ outlineView: NSOutlineView, pasteboardWriterForItem item: Any) -> NSPasteboardWriting? { - guard let fileItem = item as? Item else { return nil } + guard let fileItem = item as? CEWorkspaceFile else { return nil } return fileItem.url as NSURL } @@ -166,7 +166,7 @@ extension OutlineViewController: NSOutlineViewDataSource { proposedItem item: Any?, proposedChildIndex index: Int ) -> NSDragOperation { - guard let fileItem = item as? Item else { return [] } + guard let fileItem = item as? CEWorkspaceFile else { return [] } // -1 index indicates that we are hovering over a row in outline view (folder or file) if index == -1 { if !fileItem.isFolder { @@ -187,7 +187,7 @@ extension OutlineViewController: NSOutlineViewDataSource { guard let pasteboardItems = info.draggingPasteboard.readObjects(forClasses: [NSURL.self]) else { return false } let fileItemURLS = pasteboardItems.compactMap { $0 as? URL } - guard let fileItemDestination = item as? Item else { return false } + guard let fileItemDestination = item as? CEWorkspaceFile else { return false } let destParentURL = fileItemDestination.url for fileItemURL in fileItemURLS { @@ -198,23 +198,23 @@ extension OutlineViewController: NSOutlineViewDataSource { } // Needs to come before call to .removeItem or else race condition occurs - var srcFileItem: WorkspaceClient.FileItem? = try? workspace?.workspaceClient?.getFileItem(fileItemURL.path) + var srcFileItem: CEWorkspaceFile? = try? workspace?.workspaceFileManager?.getFile(fileItemURL.path) // If srcFileItem is nil, fileItemUrl is an external file url. if srcFileItem == nil { - srcFileItem = WorkspaceClient.FileItem(url: URL(fileURLWithPath: fileItemURL.path)) + srcFileItem = CEWorkspaceFile(url: URL(fileURLWithPath: fileItemURL.path)) } guard let srcFileItem else { return false } - if WorkspaceClient.FileItem.fileManger.fileExists(atPath: destURL.path) { + if CEWorkspaceFile.fileManger.fileExists(atPath: destURL.path) { let shouldReplace = replaceFileDialog(fileName: fileItemURL.lastPathComponent) guard shouldReplace else { return false } do { - try WorkspaceClient.FileItem.fileManger.removeItem(at: destURL) + try CEWorkspaceFile.fileManger.removeItem(at: destURL) } catch { fatalError(error.localizedDescription) } @@ -243,7 +243,7 @@ extension OutlineViewController: NSOutlineViewDataSource { // MARK: - NSOutlineViewDelegate -extension OutlineViewController: NSOutlineViewDelegate { +extension ProjectNavigatorViewController: NSOutlineViewDelegate { func outlineView( _ outlineView: NSOutlineView, shouldShowCellExpansionFor tableColumn: NSTableColumn?, @@ -261,7 +261,7 @@ extension OutlineViewController: NSOutlineViewDelegate { let frameRect = NSRect(x: 0, y: 0, width: tableColumn.width, height: rowHeight) - return OutlineTableViewCell(frame: frameRect, item: item as? Item, delegate: self) + return ProjectNavigatorTableViewCell(frame: frameRect, item: item as? CEWorkspaceFile, delegate: self) } func outlineViewSelectionDidChange(_ notification: Notification) { @@ -271,7 +271,7 @@ extension OutlineViewController: NSOutlineViewDelegate { let selectedIndex = outlineView.selectedRow - guard let item = outlineView.item(atRow: selectedIndex) as? Item else { return } + guard let item = outlineView.item(atRow: selectedIndex) as? CEWorkspaceFile else { return } if item.children == nil && shouldSendSelectionUpdate { workspace?.tabManager.activeTabGroup.openTab(item: item, asTemporary: true) @@ -287,13 +287,13 @@ extension OutlineViewController: NSOutlineViewDelegate { func outlineViewItemDidCollapse(_ notification: Notification) {} func outlineView(_ outlineView: NSOutlineView, itemForPersistentObject object: Any) -> Any? { - guard let id = object as? Item.ID, - let item = try? workspace?.workspaceClient?.getFileItem(id) else { return nil } + guard let id = object as? CEWorkspaceFile.ID, + let item = try? workspace?.workspaceFileManager?.getFile(id) else { return nil } return item } func outlineView(_ outlineView: NSOutlineView, persistentObjectForItem item: Any?) -> Any? { - guard let item = item as? Item else { return nil } + guard let item = item as? CEWorkspaceFile else { return nil } return item.id } @@ -301,13 +301,14 @@ extension OutlineViewController: NSOutlineViewDelegate { /// - Parameters: /// - id: the id of the item item /// - collection: the array to search for - private func select(by id: TabBarItemID, from collection: [Item]) { + private func select(by id: TabBarItemID, from collection: [CEWorkspaceFile]) { // If the user has set "Reveal file on selection change" to on, we need to reveal the item before // selecting the row. - if Settings.shared.preferences.general.revealFileOnFocusChange, - case let .codeEditor(id) = id, - let fileItem = try? workspace?.workspaceClient?.getFileItem(id as Item.ID) as? Item { - reveal(fileItem) + if Settings.shared.preferences.general.revealFileOnFocusChange { + if case let .codeEditor(id) = id, + let fileItem = try? workspace?.workspaceFileManager?.getFile(id as CEWorkspaceFile.ID) { + reveal(fileItem) + } } guard let item = collection.first(where: { $0.tabID == id }) else { @@ -328,7 +329,7 @@ extension OutlineViewController: NSOutlineViewDelegate { /// Reveals the given `fileItem` in the outline view by expanding all the parent directories of the file. /// If the file is not found, it will present an alert saying so. /// - Parameter fileItem: The file to reveal. - public func reveal(_ fileItem: Item) { + public func reveal(_ fileItem: CEWorkspaceFile) { if let parent = fileItem.parent { expandParent(item: parent) } @@ -350,8 +351,8 @@ extension OutlineViewController: NSOutlineViewDelegate { /// Method for recursively expanding a file's parent directories. /// - Parameter item: - private func expandParent(item: Item) { - if let parent = item.parent as Item? { + private func expandParent(item: CEWorkspaceFile) { + if let parent = item.parent as CEWorkspaceFile? { expandParent(item: parent) } outlineView.expandItem(item) @@ -360,7 +361,7 @@ extension OutlineViewController: NSOutlineViewDelegate { // MARK: - NSMenuDelegate -extension OutlineViewController: NSMenuDelegate { +extension ProjectNavigatorViewController: NSMenuDelegate { /// Once a menu gets requested by a `right click` setup the menu /// @@ -368,12 +369,12 @@ extension OutlineViewController: NSMenuDelegate { /// - Parameter menu: The menu that got requested func menuNeedsUpdate(_ menu: NSMenu) { let row = outlineView.clickedRow - guard let menu = menu as? OutlineMenu else { return } + guard let menu = menu as? ProjectNavigatorMenu else { return } if row == -1 { menu.item = nil } else { - if let item = outlineView.item(atRow: row) as? Item { + if let item = outlineView.item(atRow: row) as? CEWorkspaceFile { menu.item = item menu.workspace = workspace } else { diff --git a/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/ProjectNavigatorToolbarBottom.swift b/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/ProjectNavigatorToolbarBottom.swift new file mode 100644 index 000000000..2a1bf81ed --- /dev/null +++ b/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/ProjectNavigatorToolbarBottom.swift @@ -0,0 +1,108 @@ +// +// ProjectNavigatorToolbarBottom.swift +// CodeEdit +// +// Created by TAY KAI QUAN on 23/7/22. +// + +import SwiftUI + +struct ProjectNavigatorToolbarBottom: View { + @Environment(\.controlActiveState) + private var activeState + + @Environment(\.colorScheme) + private var colorScheme + + @EnvironmentObject + var workspace: WorkspaceDocument + + @State + var filter: String = "" + + var body: some View { + HStack { + addNewFileButton + .frame(width: 20) + .padding(.leading, 10) + HStack { + sortButton + TextField("Filter", text: $filter) + .textFieldStyle(.plain) + .font(.system(size: 12)) + if !filter.isEmpty { + clearFilterButton + .padding(.trailing, 5) + } + } + .onChange(of: filter, perform: { + workspace.filter = $0 + }) + .padding(.vertical, 3) + .background(colorScheme == .dark ? Color(hex: "#FFFFFF").opacity(0.1) : Color(hex: "#808080").opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.gray, lineWidth: 0.5).cornerRadius(6)) + .padding(.trailing, 5) + .padding(.leading, -8) + } + .frame(height: 29, alignment: .center) + .frame(maxWidth: .infinity) + .overlay(alignment: .top) { + Divider() + } + } + + private var addNewFileButton: some View { + Menu { + Button("Add File") { + guard let folderURL = workspace.workspaceFileManager?.folderUrl, + let root = try? workspace.workspaceFileManager?.getFile(folderURL.path) else { return } + + // TODO: use currently selected file instead of root + root.addFile(fileName: "untitled") + } + Button("Add Folder") { + guard let folderURL = workspace.workspaceFileManager?.folderUrl, + let root = try? workspace.workspaceFileManager?.getFile(folderURL.path) else { return } + + // TODO: use currently selected file instead of root + root.addFolder(folderName: "untitled") + } + } label: { + Image(systemName: "plus") + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .frame(maxWidth: 30) + .opacity(activeState == .inactive ? 0.45 : 1) + } + + private var sortButton: some View { + Menu { + Button { + workspace.sortFoldersOnTop.toggle() + } label: { + Text(workspace.sortFoldersOnTop ? "Alphabetically" : "Folders on top") + } + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + } + .menuStyle(.borderlessButton) + .frame(maxWidth: 30) + .opacity(activeState == .inactive ? 0.45 : 1) + } + + /// We clear the text and remove the first responder which removes the cursor + /// when the user clears the filter. + private var clearFilterButton: some View { + Button { + filter = "" + NSApp.keyWindow?.makeFirstResponder(nil) + } label: { + Image(systemName: "xmark.circle.fill") + .symbolRenderingMode(.hierarchical) + } + .buttonStyle(.plain) + .opacity(activeState == .inactive ? 0.45 : 1) + } +} diff --git a/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/ProjectNavigatorView.swift b/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/ProjectNavigatorView.swift index 3466bc791..2161d7cb5 100644 --- a/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/ProjectNavigatorView.swift +++ b/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/ProjectNavigatorView.swift @@ -16,9 +16,11 @@ import SwiftUI /// struct ProjectNavigatorView: View { - @EnvironmentObject var tabManager: TabManager + @EnvironmentObject + var tabManager: TabManager var body: some View { - OutlineView(selection: $tabManager.activeTabGroup.selected) + ProjectNavigatorOutlineView(selection: $tabManager.activeTabGroup.selected) } + } diff --git a/CodeEdit/Features/PathBar/Views/PathBarComponent.swift b/CodeEdit/Features/PathBar/Views/PathBarComponent.swift index 1122e595f..c114a9fcf 100644 --- a/CodeEdit/Features/PathBar/Views/PathBarComponent.swift +++ b/CodeEdit/Features/PathBar/Views/PathBarComponent.swift @@ -10,8 +10,8 @@ import Combine struct PathBarComponent: View { - private let fileItem: WorkspaceClient.FileItem - private let tappedOpenFile: (WorkspaceClient.FileItem) -> Void + private let fileItem: CEWorkspaceFile + private let tappedOpenFile: (CEWorkspaceFile) -> Void @Environment(\.colorScheme) var colorScheme @@ -23,18 +23,18 @@ struct PathBarComponent: View { var position: NSPoint? @State - var selection: WorkspaceClient.FileItem + var selection: CEWorkspaceFile init( - fileItem: WorkspaceClient.FileItem, - tappedOpenFile: @escaping (WorkspaceClient.FileItem) -> Void + fileItem: CEWorkspaceFile, + tappedOpenFile: @escaping (CEWorkspaceFile) -> Void ) { self.fileItem = fileItem self._selection = .init(wrappedValue: fileItem) self.tappedOpenFile = tappedOpenFile } - var siblings: [WorkspaceClient.FileItem] { + var siblings: [CEWorkspaceFile] { if let siblings = fileItem.parent?.children?.sortItems(foldersOnTop: true), !siblings.isEmpty { return siblings } else { diff --git a/CodeEdit/Features/PathBar/Views/PathBarMenu.swift b/CodeEdit/Features/PathBar/Views/PathBarMenu.swift index 474901c89..76da34bba 100644 --- a/CodeEdit/Features/PathBar/Views/PathBarMenu.swift +++ b/CodeEdit/Features/PathBar/Views/PathBarMenu.swift @@ -8,12 +8,12 @@ import AppKit final class PathBarMenu: NSMenu, NSMenuDelegate { - private let fileItems: [WorkspaceClient.FileItem] - private let tappedOpenFile: (WorkspaceClient.FileItem) -> Void + private let fileItems: [CEWorkspaceFile] + private let tappedOpenFile: (CEWorkspaceFile) -> Void init( - fileItems: [WorkspaceClient.FileItem], - tappedOpenFile: @escaping (WorkspaceClient.FileItem) -> Void + fileItems: [CEWorkspaceFile], + tappedOpenFile: @escaping (CEWorkspaceFile) -> Void ) { self.fileItems = fileItems self.tappedOpenFile = tappedOpenFile @@ -38,13 +38,13 @@ final class PathBarMenu: NSMenu, NSMenuDelegate { /// Only when menu item is highlighted then generate its submenu func menu(_: NSMenu, willHighlight item: NSMenuItem?) { if let highlightedItem = item, let submenuItems = highlightedItem.submenu?.items, submenuItems.isEmpty { - if let highlightedFileItem = highlightedItem.representedObject as? WorkspaceClient.FileItem { + if let highlightedFileItem = highlightedItem.representedObject as? CEWorkspaceFile { highlightedItem.submenu = generateSubmenu(highlightedFileItem) } } } - private func generateSubmenu(_ fileItem: WorkspaceClient.FileItem) -> PathBarMenu? { + private func generateSubmenu(_ fileItem: CEWorkspaceFile) -> PathBarMenu? { if let children = fileItem.children { let menu = PathBarMenu( fileItems: children, @@ -57,16 +57,16 @@ final class PathBarMenu: NSMenu, NSMenuDelegate { } final class PathBarMenuItem: NSMenuItem { - private let fileItem: WorkspaceClient.FileItem - private let tappedOpenFile: (WorkspaceClient.FileItem) -> Void + private let fileItem: CEWorkspaceFile + private let tappedOpenFile: (CEWorkspaceFile) -> Void init( - fileItem: WorkspaceClient.FileItem, - tappedOpenFile: @escaping (WorkspaceClient.FileItem) -> Void + fileItem: CEWorkspaceFile, + tappedOpenFile: @escaping (CEWorkspaceFile) -> Void ) { self.fileItem = fileItem self.tappedOpenFile = tappedOpenFile - super.init(title: fileItem.fileName, action: #selector(openFile), keyEquivalent: "") + super.init(title: fileItem.name, action: #selector(openFile), keyEquivalent: "") var icon = fileItem.systemImage var color = NSColor(fileItem.iconColor) diff --git a/CodeEdit/Features/PathBar/Views/PathBarView.swift b/CodeEdit/Features/PathBar/Views/PathBarView.swift index 6702da4ab..0576c9a02 100644 --- a/CodeEdit/Features/PathBar/Views/PathBarView.swift +++ b/CodeEdit/Features/PathBar/Views/PathBarView.swift @@ -9,8 +9,8 @@ import SwiftUI struct PathBarView: View { - private let file: WorkspaceClient.FileItem - private let tappedOpenFile: (WorkspaceClient.FileItem) -> Void + private let file: CEWorkspaceFile + private let tappedOpenFile: (CEWorkspaceFile) -> Void @Environment(\.colorScheme) private var colorScheme @@ -24,16 +24,16 @@ struct PathBarView: View { static let height = 27.0 init( - file: WorkspaceClient.FileItem, - tappedOpenFile: @escaping (WorkspaceClient.FileItem) -> Void + file: CEWorkspaceFile, + tappedOpenFile: @escaping (CEWorkspaceFile) -> Void ) { self.file = file self.tappedOpenFile = tappedOpenFile } - var fileItems: [WorkspaceClient.FileItem] { - var treePath: [WorkspaceClient.FileItem] = [] - var currentFile: WorkspaceClient.FileItem? = file + var fileItems: [CEWorkspaceFile] { + var treePath: [CEWorkspaceFile] = [] + var currentFile: CEWorkspaceFile? = file while let currentFileLoop = currentFile { treePath.insert(currentFileLoop, at: 0) diff --git a/CodeEdit/Features/QuickOpen/ViewModels/QuickOpenViewModel.swift b/CodeEdit/Features/QuickOpen/ViewModels/QuickOpenViewModel.swift index c66079884..8619ce3d4 100644 --- a/CodeEdit/Features/QuickOpen/ViewModels/QuickOpenViewModel.swift +++ b/CodeEdit/Features/QuickOpen/ViewModels/QuickOpenViewModel.swift @@ -14,7 +14,7 @@ final class QuickOpenViewModel: ObservableObject { var openQuicklyQuery: String = "" @Published - var openQuicklyFiles: [WorkspaceClient.FileItem] = [] + var openQuicklyFiles: [CEWorkspaceFile] = [] @Published var isShowingOpenQuicklyFiles: Bool = false @@ -56,7 +56,7 @@ final class QuickOpenViewModel: ObservableObject { return false } }.map { url in - WorkspaceClient.FileItem(url: url, children: nil) + CEWorkspaceFile(url: url, children: nil) } DispatchQueue.main.async { self.openQuicklyFiles = files diff --git a/CodeEdit/Features/QuickOpen/Views/QuickOpenItem.swift b/CodeEdit/Features/QuickOpen/Views/QuickOpenItem.swift index 2470c74cc..c68a582d0 100644 --- a/CodeEdit/Features/QuickOpen/Views/QuickOpenItem.swift +++ b/CodeEdit/Features/QuickOpen/Views/QuickOpenItem.swift @@ -10,11 +10,11 @@ import SwiftUI struct QuickOpenItem: View { private let baseDirectory: URL - private let fileItem: WorkspaceClient.FileItem + private let fileItem: CEWorkspaceFile init( baseDirectory: URL, - fileItem: WorkspaceClient.FileItem + fileItem: CEWorkspaceFile ) { self.baseDirectory = baseDirectory self.fileItem = fileItem diff --git a/CodeEdit/Features/QuickOpen/Views/QuickOpenPreviewView.swift b/CodeEdit/Features/QuickOpen/Views/QuickOpenPreviewView.swift index 5c232173e..ade3df6f6 100644 --- a/CodeEdit/Features/QuickOpen/Views/QuickOpenPreviewView.swift +++ b/CodeEdit/Features/QuickOpen/Views/QuickOpenPreviewView.swift @@ -10,13 +10,13 @@ import SwiftUI struct QuickOpenPreviewView: View { private let queue = DispatchQueue(label: "austincondiff.CodeEdit.quickOpen.preview") - private let item: WorkspaceClient.FileItem + private let item: CEWorkspaceFile @ObservedObject var document: CodeFileDocument init( - item: WorkspaceClient.FileItem + item: CEWorkspaceFile ) { self.item = item let doc = try? CodeFileDocument( diff --git a/CodeEdit/Features/QuickOpen/Views/QuickOpenView.swift b/CodeEdit/Features/QuickOpen/Views/QuickOpenView.swift index db266d9e1..e87158c7e 100644 --- a/CodeEdit/Features/QuickOpen/Views/QuickOpenView.swift +++ b/CodeEdit/Features/QuickOpen/Views/QuickOpenView.swift @@ -10,18 +10,18 @@ import SwiftUI struct QuickOpenView: View { private let onClose: () -> Void - private let openFile: (WorkspaceClient.FileItem) -> Void + private let openFile: (CEWorkspaceFile) -> Void @ObservedObject private var state: QuickOpenViewModel @State - private var selectedItem: WorkspaceClient.FileItem? + private var selectedItem: CEWorkspaceFile? init( state: QuickOpenViewModel, onClose: @escaping () -> Void, - openFile: @escaping (WorkspaceClient.FileItem) -> Void + openFile: @escaping (CEWorkspaceFile) -> Void ) { self.state = state self.onClose = onClose diff --git a/CodeEdit/Features/Search/Model/SearchResultMatchModel.swift b/CodeEdit/Features/Search/Model/SearchResultMatchModel.swift index fb89fa29b..b582e6471 100644 --- a/CodeEdit/Features/Search/Model/SearchResultMatchModel.swift +++ b/CodeEdit/Features/Search/Model/SearchResultMatchModel.swift @@ -12,7 +12,7 @@ import Cocoa class SearchResultMatchModel: Hashable, Identifiable { init( lineNumber: Int, - file: WorkspaceClient.FileItem, + file: CEWorkspaceFile, lineContent: String, keywordRange: Range ) { @@ -24,7 +24,7 @@ class SearchResultMatchModel: Hashable, Identifiable { } var id: UUID - var file: WorkspaceClient.FileItem + var file: CEWorkspaceFile var lineNumber: Int var lineContent: String var keywordRange: Range diff --git a/CodeEdit/Features/Search/Model/SearchResultModel.swift b/CodeEdit/Features/Search/Model/SearchResultModel.swift index 4c42d435d..84dc43251 100644 --- a/CodeEdit/Features/Search/Model/SearchResultModel.swift +++ b/CodeEdit/Features/Search/Model/SearchResultModel.swift @@ -9,11 +9,12 @@ import Foundation /// A struct for holding information about a file and any matches it may have for a search query. class SearchResultModel: Hashable { - var file: WorkspaceClient.FileItem + + var file: CEWorkspaceFile var lineMatches: [SearchResultMatchModel] init( - file: WorkspaceClient.FileItem, + file: CEWorkspaceFile, lineMatches: [SearchResultMatchModel] = [] ) { self.file = file @@ -29,4 +30,5 @@ class SearchResultModel: Hashable { hasher.combine(file) hasher.combine(lineMatches) } + } diff --git a/CodeEdit/Features/StatusBar/Views/StatusBarDrawer/StatusBarDrawer.swift b/CodeEdit/Features/StatusBar/Views/StatusBarDrawer/StatusBarDrawer.swift index 40596fdf0..f4eb5b792 100644 --- a/CodeEdit/Features/StatusBar/Views/StatusBarDrawer/StatusBarDrawer.swift +++ b/CodeEdit/Features/StatusBar/Views/StatusBarDrawer/StatusBarDrawer.swift @@ -39,7 +39,7 @@ struct StatusBarDrawer: View { } var body: some View { - if let url = workspace.workspaceClient?.folderURL() { + if let url = workspace.workspaceFileManager?.folderUrl { VStack(spacing: 0) { TerminalEmulatorView(url: url) .padding(.top, 10) diff --git a/CodeEdit/Features/Tabs/Models/TabBarItemRepresentable.swift b/CodeEdit/Features/Tabs/Models/TabBarItemRepresentable.swift index cfbe23241..4c7ca8a08 100644 --- a/CodeEdit/Features/Tabs/Models/TabBarItemRepresentable.swift +++ b/CodeEdit/Features/Tabs/Models/TabBarItemRepresentable.swift @@ -12,7 +12,7 @@ protocol TabBarItemRepresentable { /// Unique tab identifier var tabID: TabBarItemID { get } /// String to be shown as tab's title - var title: String { get } + var name: String { get } /// Image to be shown as tab's icon var icon: Image { get } /// Color of the tab's icon diff --git a/CodeEdit/Features/Tabs/Models/TabManager.swift b/CodeEdit/Features/Tabs/Models/TabManager.swift index 8ff36c76a..964b4ed6e 100644 --- a/CodeEdit/Features/Tabs/Models/TabManager.swift +++ b/CodeEdit/Features/Tabs/Models/TabManager.swift @@ -11,10 +11,12 @@ import DequeModule class TabManager: ObservableObject { /// Collection of all the tabgroups. - @Published var tabGroups: TabGroup + @Published + var tabGroups: TabGroup /// The TabGroup with active focus. - @Published var activeTabGroup: TabGroupData { + @Published + var activeTabGroup: TabGroupData { didSet { activeTabGroupHistory.prepend { [weak oldValue] in oldValue } } @@ -23,7 +25,7 @@ class TabManager: ObservableObject { /// History of last-used tab groups. var activeTabGroupHistory: Deque<() -> TabGroupData?> = [] - var fileDocuments: [WorkspaceClient.FileItem: CodeFileDocument] = [:] + var fileDocuments: [CEWorkspaceFile: CodeFileDocument] = [:] init() { let tab = TabGroupData() @@ -43,7 +45,7 @@ class TabManager: ObservableObject { /// - Parameters: /// - item: The tab to open. /// - tabgroup: The tabgroup to add the tab to. If nil, it is added to the active tab group. - func openTab(item: WorkspaceClient.FileItem, in tabgroup: TabGroupData? = nil) { + func openTab(item: CEWorkspaceFile, in tabgroup: TabGroupData? = nil) { let tabgroup = tabgroup ?? activeTabGroup tabgroup.openTab(item: item) } diff --git a/CodeEdit/Features/Tabs/TabGroup/TabGroup.swift b/CodeEdit/Features/Tabs/TabGroup/TabGroup.swift index 1e005ec08..25b627017 100644 --- a/CodeEdit/Features/Tabs/TabGroup/TabGroup.swift +++ b/CodeEdit/Features/Tabs/TabGroup/TabGroup.swift @@ -14,7 +14,7 @@ enum TabGroup { /// Closes all tabs which present the given file /// - Parameter file: a file. - func closeAllTabs(of file: WorkspaceClient.FileItem) { + func closeAllTabs(of file: CEWorkspaceFile) { switch self { case .one(let tabGroupData): tabGroupData.tabs.remove(file) @@ -45,7 +45,7 @@ enum TabGroup { } /// Forms a set of all files currently represented by tabs. - func gatherOpenFiles() -> Set { + func gatherOpenFiles() -> Set { switch self { case .one(let tabGroupData): return Set(tabGroupData.tabs) diff --git a/CodeEdit/Features/Tabs/TabGroup/TabGroupData.swift b/CodeEdit/Features/Tabs/TabGroup/TabGroupData.swift index f4cebbdd8..570158065 100644 --- a/CodeEdit/Features/Tabs/TabGroup/TabGroupData.swift +++ b/CodeEdit/Features/Tabs/TabGroup/TabGroupData.swift @@ -10,10 +10,11 @@ import OrderedCollections import DequeModule final class TabGroupData: ObservableObject, Identifiable { - typealias Tab = WorkspaceClient.FileItem + typealias Tab = CEWorkspaceFile /// Set of open tabs. - @Published var tabs: OrderedSet = [] { + @Published + var tabs: OrderedSet = [] { didSet { let change = tabs.symmetricDifference(oldValue) @@ -33,11 +34,9 @@ final class TabGroupData: ObservableObject, Identifiable { } } - /// History of tab switching. - @Published var history: Deque = [] - /// The current offset in the history list. - @Published var historyOffset: Int = 0 { + @Published + var historyOffset: Int = 0 { didSet { let tab = history[historyOffset] @@ -52,10 +51,16 @@ final class TabGroupData: ObservableObject, Identifiable { } } + /// History of tab switching. + @Published + var history: Deque = [] + /// Currently selected tab. - @Published var selected: Tab? + @Published + var selected: Tab? - @Published var temporaryTab: Tab? + @Published + var temporaryTab: Tab? let id = UUID() diff --git a/CodeEdit/Features/Tabs/Views/TabBarContextMenu.swift b/CodeEdit/Features/Tabs/Views/TabBarContextMenu.swift index f1ff9d074..64362e630 100644 --- a/CodeEdit/Features/Tabs/Views/TabBarContextMenu.swift +++ b/CodeEdit/Features/Tabs/Views/TabBarContextMenu.swift @@ -9,14 +9,14 @@ import Foundation import SwiftUI extension View { - func tabBarContextMenu(item: WorkspaceClient.FileItem, isTemporary: Bool) -> some View { + func tabBarContextMenu(item: CEWorkspaceFile, isTemporary: Bool) -> some View { modifier(TabBarContextMenu(item: item, isTemporary: isTemporary)) } } struct TabBarContextMenu: ViewModifier { init( - item: WorkspaceClient.FileItem, + item: CEWorkspaceFile, isTemporary: Bool ) { self.item = item @@ -31,7 +31,7 @@ struct TabBarContextMenu: ViewModifier { @Environment(\.splitEditor) var splitEditor - private var item: WorkspaceClient.FileItem + private var item: CEWorkspaceFile private var isTemporary: Bool // swiftlint:disable:next function_body_length @@ -131,7 +131,7 @@ struct TabBarContextMenu: ViewModifier { /// Copies the absolute path of the given `FileItem` /// - Parameter item: The `FileItem` to use. - private func copyPath(item: WorkspaceClient.FileItem) { + private func copyPath(item: CEWorkspaceFile) { NSPasteboard.general.clearContents() NSPasteboard.general.setString(item.url.standardizedFileURL.path, forType: .string) } @@ -145,8 +145,8 @@ struct TabBarContextMenu: ViewModifier { /// Copies the relative path from the workspace folder to the given file item to the pasteboard. /// - Parameter item: The `FileItem` to use. - private func copyRelativePath(item: WorkspaceClient.FileItem) { - guard let rootPath = workspace.workspaceClient?.folderURL() else { + private func copyRelativePath(item: CEWorkspaceFile) { + guard let rootPath = workspace.workspaceFileManager?.folderUrl else { return } // Calculate the relative path diff --git a/CodeEdit/Features/Tabs/Views/TabBarItemView.swift b/CodeEdit/Features/Tabs/Views/TabBarItemView.swift index 4bcbbcbe2..573fb9baa 100644 --- a/CodeEdit/Features/Tabs/Views/TabBarItemView.swift +++ b/CodeEdit/Features/Tabs/Views/TabBarItemView.swift @@ -9,8 +9,6 @@ import SwiftUI struct TabBarItemView: View { - typealias Item = WorkspaceClient.FileItem - @Environment(\.colorScheme) private var colorScheme @@ -58,9 +56,9 @@ struct TabBarItemView: View { /// The id associating with the tab that is currently being dragged. /// /// When `nil`, then there is no tab being dragged. - private var draggingTabId: Item.ID? + private var draggingTabId: CEWorkspaceFile.ID? - private var onDragTabId: Item.ID? + private var onDragTabId: CEWorkspaceFile.ID? @Binding private var closeButtonGestureActive: Bool @@ -71,7 +69,7 @@ struct TabBarItemView: View { /// The item associated with the current tab. /// /// You can get tab-related information from here, like `label`, `icon`, etc. - private var item: Item + private var item: CEWorkspaceFile var index: Int @@ -120,10 +118,10 @@ struct TabBarItemView: View { init( expectedWidth: CGFloat, - item: Item, + item: CEWorkspaceFile, index: Int, - draggingTabId: Item.ID?, - onDragTabId: Item.ID?, + draggingTabId: CEWorkspaceFile.ID?, + onDragTabId: CEWorkspaceFile.ID?, closeButtonGestureActive: Binding ) { self.expectedWidth = expectedWidth @@ -155,7 +153,7 @@ struct TabBarItemView: View { : .secondary ) .frame(width: 12, height: 12) - Text(item.fileName) + Text(item.name) .font( isTemporary ? .system(size: 11.0).italic() diff --git a/CodeEdit/Features/Tabs/Views/TabBarView.swift b/CodeEdit/Features/Tabs/Views/TabBarView.swift index 3d5a668e2..078457931 100644 --- a/CodeEdit/Features/Tabs/Views/TabBarView.swift +++ b/CodeEdit/Features/Tabs/Views/TabBarView.swift @@ -14,14 +14,18 @@ import SwiftUI // - TODO: TabBarItemView drop-outside event handler. struct TabBarView: View { - @Environment(\.modifierKeys) var modifierKeys - - typealias TabID = WorkspaceClient.FileItem.ID + typealias TabID = CEWorkspaceFile.ID /// The height of tab bar. /// I am not making it a private variable because it may need to be used in outside views. static let height = 28.0 + @Environment(\.modifierKeys) + var modifierKeys + + @Environment(\.splitEditor) + var splitEditor + @Environment(\.colorScheme) private var colorScheme @@ -38,8 +42,6 @@ struct TabBarView: View { @EnvironmentObject private var tabgroup: TabGroupData - @Environment(\.splitEditor) var splitEditor - @AppSettings(\.general.tabBarStyle) var tabBarStyle /// The tab id of current dragging tab. @@ -471,7 +473,7 @@ struct TabBarView: View { } label: { HStack { tab.icon - Text(tab.fileName) + Text(tab.name) } } } @@ -500,7 +502,7 @@ struct TabBarView: View { } label: { HStack { tab.icon - Text(tab.fileName) + Text(tab.name) } } } diff --git a/CodeEdit/Utils/Extensions/Array/Array+CEWorkspaceFile.swift b/CodeEdit/Utils/Extensions/Array/Array+CEWorkspaceFile.swift new file mode 100644 index 000000000..5bd9a2e20 --- /dev/null +++ b/CodeEdit/Utils/Extensions/Array/Array+CEWorkspaceFile.swift @@ -0,0 +1,43 @@ +// +// Array+FileSystem.FileItem.swift +// CodeEdit +// +// Created by Matthijs Eikelenboom on 07/02/2023. +// + +import Foundation + +extension Array where Element == CEWorkspaceFile { + + /// Sorts the elements in alphabetical order. + /// - Parameter foldersOnTop: if set to `true` folders will always be on top of files. + /// - Returns: A sorted array of ``FileSystemClient/FileSystemClient/FileItem`` + func sortItems(foldersOnTop: Bool) -> Self { + var alphabetically = sorted { $0.name < $1.name } + + if foldersOnTop { + var foldersOnTop = alphabetically.filter { $0.children != nil } + alphabetically.removeAll { $0.children != nil } + + foldersOnTop.append(contentsOf: alphabetically) + + return foldersOnTop + } else { + return alphabetically + } + } + +} + +extension Array where Element: Hashable { + + /// Checks the difference between two given items. + /// - Parameter other: Other element + /// - Returns: symmetricDifference + func difference(from other: [Element]) -> [Element] { + let thisSet = Set(self) + let otherSet = Set(other) + return Array(thisSet.symmetricDifference(otherSet)) + } + +} diff --git a/CodeEdit/Utils/WorkspaceClient/Interface.swift b/CodeEdit/Utils/WorkspaceClient/Interface.swift deleted file mode 100644 index c1c803273..000000000 --- a/CodeEdit/Utils/WorkspaceClient/Interface.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// Interface.swift -// CodeEditModules/WorkspaceClient -// -// Created by Marco Carnevali on 16/03/22. -// - -import Combine -import Foundation - -// TODO: DOCS (Marco Carnevali) -struct WorkspaceClient { - - var folderURL: () -> URL? - - var getFiles: AnyPublisher<[FileItem], Never> - - var getFileItem: (_ id: String) throws -> FileItem - - /// callback function that is run when a change is detected in the file system. - /// This usually contains a `reloadData` function. - static var onRefresh: () -> Void = {} - - // For some strange reason, swiftlint thinks this is wrong? - init( - folderURL: @escaping () -> URL?, - getFiles: AnyPublisher<[FileItem], Never>, - getFileItem: @escaping (_ id: String) throws -> FileItem - ) { - self.folderURL = folderURL - self.getFiles = getFiles - self.getFileItem = getFileItem - } - - enum WorkspaceClientError: Error { - case fileNotExist - } -} diff --git a/CodeEdit/Utils/WorkspaceClient/Live.swift b/CodeEdit/Utils/WorkspaceClient/Live.swift deleted file mode 100644 index 0bc35b6aa..000000000 --- a/CodeEdit/Utils/WorkspaceClient/Live.swift +++ /dev/null @@ -1,178 +0,0 @@ -// -// Live.swift -// CodeEditModules/WorkspaceClient -// -// Created by Marco Carnevali on 16/03/22. -// - -import Combine -import Foundation - -// TODO: DOCS (Marco Carnevali) -extension WorkspaceClient { - // swiftlint:disable:next function_body_length - static func `default`( - fileManager: FileManager, - folderURL: URL, - ignoredFilesAndFolders: [String] - ) throws -> Self { - var flattenedFileItems: [String: FileItem] = [:] - - // Recursive loading of files into `FileItem`s - // - Parameter url: The URL of the directory to load the items of - // - Returns: `[FileItem]` representing the contents of the directory - func loadFiles(fromURL url: URL) throws -> [FileItem] { - let directoryContents = try fileManager.contentsOfDirectory( - at: url.resolvingSymlinksInPath(), - includingPropertiesForKeys: nil - ) - var items: [FileItem] = [] - - for itemURL in directoryContents { - // Skip file if it is in ignore list - guard !ignoredFilesAndFolders.contains(itemURL.lastPathComponent) else { continue } - - var isDir: ObjCBool = false - - if fileManager.fileExists(atPath: itemURL.path, isDirectory: &isDir) { - var subItems: [FileItem]? - - if isDir.boolValue { - // Recursively fetch subdirectories and files if the path points to a directory - subItems = try loadFiles(fromURL: itemURL) - } - - let newFileItem = FileItem(url: itemURL, children: subItems?.sortItems(foldersOnTop: true)) - subItems?.forEach { $0.parent = newFileItem } - items.append(newFileItem) - flattenedFileItems[newFileItem.id] = newFileItem - } - } - - return items - } - - // initial load - let fileItems = try loadFiles(fromURL: folderURL) - // workspace fileItem - let workspaceItem = FileItem(url: folderURL, children: fileItems) - flattenedFileItems[workspaceItem.id] = workspaceItem - fileItems.forEach { item in - item.parent = workspaceItem - } - - // By using `CurrentValueSubject` we can define a starting value. - // The value passed during init it's going to be send as soon as the - // consumer subscribes to the publisher. - let subject = CurrentValueSubject<[FileItem], Never>(fileItems) - - var isRunning: Bool = false - var anotherInstanceRan: Int = 0 - - // Recursive function similar to `loadFiles`, but creates or deletes children of the - // `FileItem` so that they are accurate with the file system, instead of creating an - // entirely new `FileItem`, to prevent the `OutlineView` from going crazy with folding. - // - Parameter fileItem: The `FileItem` to correct the children of - func rebuildFiles(fromItem fileItem: FileItem) throws -> Bool { - var didChangeSomething = false - - // get the actual directory children - let directoryContentsUrls = try fileManager.contentsOfDirectory( - at: fileItem.url.resolvingSymlinksInPath(), - includingPropertiesForKeys: nil - ) - - // test for deleted children, and remove them from the index - for oldContent in fileItem.children ?? [] where !directoryContentsUrls.contains(oldContent.url) { - if let removeAt = fileItem.children?.firstIndex(of: oldContent) { - fileItem.children?.remove(at: removeAt) - flattenedFileItems.removeValue(forKey: oldContent.id) - didChangeSomething = true - } - } - - // test for new children, and index them using loadFiles - for newContent in directoryContentsUrls { - guard !ignoredFilesAndFolders.contains(newContent.lastPathComponent) else { continue } - - var childExists = false - fileItem.children?.forEach({ childExists = $0.url == newContent ? true : childExists }) - if childExists { - continue - } - - var isDir: ObjCBool = false - if fileManager.fileExists(atPath: newContent.path, isDirectory: &isDir) { - var subItems: [FileItem]? - - if isDir.boolValue { subItems = try loadFiles(fromURL: newContent) } - - let newFileItem = FileItem(url: newContent, children: subItems?.sortItems(foldersOnTop: true)) - subItems?.forEach { $0.parent = newFileItem } - newFileItem.parent = fileItem - flattenedFileItems[newFileItem.id] = newFileItem - fileItem.children?.append(newFileItem) - didChangeSomething = true - } - } - - fileItem.children = fileItem.children?.sortItems(foldersOnTop: true) - fileItem.children?.forEach({ - if $0.isFolder { - let childChanged = try? rebuildFiles(fromItem: $0) - didChangeSomething = (childChanged ?? false) ? true : didChangeSomething - } - flattenedFileItems[$0.id] = $0 - }) - - return didChangeSomething - } - - FileItem.watcherCode = { - // Something has changed inside the directory - // We should reload the files. - guard !isRunning else { // this runs when a file change is detected but is already running - anotherInstanceRan += 1 - return - } - isRunning = true - flattenedFileItems = [workspaceItem.id: workspaceItem] - _ = try? rebuildFiles(fromItem: workspaceItem) - while anotherInstanceRan > 0 { // TODO: optimise - let somethingChanged = try? rebuildFiles(fromItem: workspaceItem) - anotherInstanceRan = !(somethingChanged ?? false) ? 0 : anotherInstanceRan - 1 - } - subject.send(workspaceItem.children ?? []) - isRunning = false - anotherInstanceRan = 0 - // reload data in outline view controller through the main thread - DispatchQueue.main.async { onRefresh() } - } - - func stopListeningToDirectory(directory: URL? = nil) { - if directory != nil { - flattenedFileItems[directory!.relativePath]?.watcher?.cancel() - } else { - for item in flattenedFileItems.values { - item.watcher?.cancel() - } - } - } - - return Self( - folderURL: { folderURL }, - getFiles: subject - .handleEvents(receiveCancel: { - stopListeningToDirectory() - }) - .receive(on: RunLoop.main) - .eraseToAnyPublisher(), - getFileItem: { id in - guard let item = flattenedFileItems[id] else { - throw WorkspaceClientError.fileNotExist - } - return item - } - ) - } -} diff --git a/CodeEdit/Utils/WorkspaceClient/Mocks.swift b/CodeEdit/Utils/WorkspaceClient/Mocks.swift deleted file mode 100644 index 14b690601..000000000 --- a/CodeEdit/Utils/WorkspaceClient/Mocks.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Mocks.swift -// CodeEditModules/WorkspaceClient -// -// Created by Marco Carnevali on 16/03/22. -// - -import Combine -import Foundation - -// TODO: DOCS (Marco Carnevali) -extension WorkspaceClient { - static var empty = Self( - folderURL: { nil }, - getFiles: CurrentValueSubject<[FileItem], Never>([]).eraseToAnyPublisher(), - getFileItem: { _ in throw WorkspaceClientError.fileNotExist } - ) -} diff --git a/CodeEdit/Utils/WorkspaceClient/Model/FileItem+Array.swift b/CodeEdit/Utils/WorkspaceClient/Model/FileItem+Array.swift deleted file mode 100644 index c6a089338..000000000 --- a/CodeEdit/Utils/WorkspaceClient/Model/FileItem+Array.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// FileItem+Array.swift -// CodeEditModules/WorkspaceClient -// -// Created by Lukas Pistrol on 17.03.22. -// - -import Foundation - -extension Array where Element == WorkspaceClient.FileItem { - - /// Sorts the elements in alphabetical order. - /// - Parameter foldersOnTop: if set to `true` folders will always be on top of files. - /// - Returns: A sorted array of ``WorkspaceClient/WorkspaceClient/FileItem`` - func sortItems(foldersOnTop: Bool) -> Self { - var alphabetically = sorted { $0.fileName < $1.fileName } - - if foldersOnTop { - var foldersOnTop = alphabetically.filter { $0.children != nil } - alphabetically.removeAll { $0.children != nil } - - foldersOnTop.append(contentsOf: alphabetically) - - return foldersOnTop - } else { - return alphabetically - } - } -} diff --git a/CodeEdit/Utils/WorkspaceClient/Model/FileItem.swift b/CodeEdit/Utils/WorkspaceClient/Model/FileItem.swift deleted file mode 100644 index 7ad7f7e74..000000000 --- a/CodeEdit/Utils/WorkspaceClient/Model/FileItem.swift +++ /dev/null @@ -1,338 +0,0 @@ -// -// FileItem.swift -// CodeEditModules/WorkspaceClient -// -// Created by Marco Carnevali on 16/03/22. -// - -import Foundation -import SwiftUI -import UniformTypeIdentifiers - -extension WorkspaceClient { - enum FileItemCodingKeys: String, CodingKey { - case id - case url - case children - } - - /// An object containing all necessary information and actions for a specific file in the workspace - final class FileItem: Identifiable, Codable, TabBarItemRepresentable { - var tabID: TabBarItemID { - .codeEditor(id) - } - - var title: String { - url.lastPathComponent - } - - var icon: Image { - Image(systemName: systemImage) - } - - typealias ID = String - - private let uuid: UUID - - var watcher: DispatchSourceFileSystemObject? - static var watcherCode: () -> Void = {} - - func activateWatcher() -> Bool { - let descriptor = open(self.url.path, O_EVTONLY) - guard descriptor > 0 else { return false } - let source = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: descriptor, - eventMask: .write, - queue: DispatchQueue.global() - ) - source.setEventHandler { FileItem.watcherCode() } - source.setCancelHandler { close(descriptor) } - source.resume() - self.watcher = source - return true - } - - init( - url: URL, - children: [FileItem]? = nil - ) { - self.url = url - self.children = children - id = url.relativePath - uuid = UUID() - } - - required init(from decoder: Decoder) throws { - let values = try decoder.container(keyedBy: FileItemCodingKeys.self) - id = try values.decode(String.self, forKey: .id) - url = try values.decode(URL.self, forKey: .url) - children = try values.decode([FileItem]?.self, forKey: .children) - uuid = UUID() - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: FileItemCodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(url, forKey: .url) - try container.encode(children, forKey: .children) - } - - /// The id of the ``WorkspaceClient/FileItem``. - /// - /// This is equal to `url.relativePath` - var id: ID - - /// Returns the URL of the ``WorkspaceClient/FileItem`` - var url: URL - - /// Returns the children of the current ``WorkspaceClient/FileItem``. - /// - /// If the current ``WorkspaceClient/FileItem`` is a file this will be `nil`. - /// If it is an empty folder this will be an empty array. - var children: [FileItem]? - - /// Returns a parent ``WorkspaceClient/FileItem``. - /// - /// If the item already is the top-level ``WorkspaceClient/FileItem`` this returns `nil`. - var parent: FileItem? - - /// A boolean that is true if ``children`` is not `nil` - var isFolder: Bool { - children != nil - } - - /// A boolean that is true if the file item is the root folder of the workspace. - var isRoot: Bool { - parent == nil - } - - /// Returns a string describing a SFSymbol for the current ``WorkspaceClient/FileItem`` - /// - /// Use it like this - /// ```swift - /// Image(systemName: item.systemImage) - /// ``` - var systemImage: String { - switch children { - case nil: - return FileIcon.fileIcon(fileType: fileType) - case .some where parent == nil: - return "folder.fill.badge.gearshape" - case let .some(children): - if self.watcher == nil && !self.activateWatcher() { - return "questionmark.folder" - } - return folderIcon(children) - } - } - - /// Returns the file name (e.g.: `Package.swift`) - var fileName: String { - url.lastPathComponent - } - - /// Returns the extension of the file or an empty string if no extension is present. - var fileType: FileIcon.FileType { - .init(rawValue: url.pathExtension) ?? .txt - } - - /// Returns a string describing a SFSymbol for folders - /// - /// If it is the top-level folder this will return `"square.dashed.inset.filled"`. - /// If it is a `.codeedit` folder this will return `"folder.fill.badge.gearshape"`. - /// If it has children this will return `"folder.fill"` otherwise `"folder"`. - private func folderIcon(_ children: [FileItem]) -> String { - if self.parent == nil { - return "square.dashed.inset.filled" - } - if self.fileName == ".codeedit" { - return "folder.fill.badge.gearshape" - } - return children.isEmpty ? "folder" : "folder.fill" - } - - /// Returns the file name with optional extension (e.g.: `Package.swift`) - func fileName(typeHidden: Bool) -> String { - typeHidden ? url.deletingPathExtension().lastPathComponent : fileName - } - - /// Return the file's UTType - var contentType: UTType? { - try? url.resourceValues(forKeys: [.contentTypeKey]).contentType - } - - /// Returns a `Color` for a specific `fileType` - /// - /// If not specified otherwise this will return `Color.accentColor` - var iconColor: Color { - FileIcon.iconColor(fileType: fileType) - } - - var fileDocument: CodeFileDocument? - - // MARK: Statics - - /// The default `FileManager` instance - static let fileManger = FileManager.default - - // MARK: Intents - - /// Allows the user to view the file or folder in the finder application - func showInFinder() { - NSWorkspace.shared.activateFileViewerSelecting([url]) - } - - /// Allows the user to launch the file or folder as it would be in finder - func openWithExternalEditor() { - NSWorkspace.shared.open(url) - } - - /// This function allows creation of folders in the main directory or sub-folders - /// - Parameter folderName: The name of the new folder - func addFolder(folderName: String) { - // check if folder, if it is create folder under self, else create on same level. - var folderUrl = (self.isFolder ? - self.url.appendingPathComponent(folderName) : - self.url.deletingLastPathComponent().appendingPathComponent(folderName)) - - // if a file/folder with the same name exists, add a number to the end. - var fileNumber = 0 - while FileItem.fileManger.fileExists(atPath: folderUrl.path) { - fileNumber += 1 - folderUrl = folderUrl.deletingLastPathComponent().appendingPathComponent("\(folderName)\(fileNumber)") - } - - // create the folder - do { - try FileItem.fileManger.createDirectory( - at: folderUrl, - withIntermediateDirectories: true, - attributes: [:] - ) - } catch { - fatalError(error.localizedDescription) - } - } - - /// This function allows creating files in the selected folder or project main directory - /// - Parameter fileName: The name of the new file - @discardableResult - func addFile(fileName: String) -> String { - // check if folder, if it is create file under self - var fileUrl = (self.isFolder ? - self.url.appendingPathComponent(fileName) : - self.url.deletingLastPathComponent().appendingPathComponent(fileName)) - - // if a file/folder with the same name exists, add a number to the end. - var fileNumber = 0 - while FileItem.fileManger.fileExists(atPath: fileUrl.path) { - fileNumber += 1 - fileUrl = fileUrl.deletingLastPathComponent().appendingPathComponent("\(fileName)\(fileNumber)") - } - - // create the file - FileItem.fileManger.createFile( - atPath: fileUrl.path, - contents: nil, - attributes: [FileAttributeKey.creationDate: Date()] - ) - - return fileUrl.path - } - - /// This function deletes the item or folder from the current project - func delete() { - // this function also has to account for how the - // file system can change outside of the editor - - let deleteConfirmation = NSAlert() - let message = "\(self.fileName)\(self.isFolder ? " and its children" :"")" - deleteConfirmation.messageText = "Are you sure you want to move \(message) to the Trash?" - deleteConfirmation.alertStyle = .critical - deleteConfirmation.addButton(withTitle: "Delete") - deleteConfirmation.buttons.last?.hasDestructiveAction = true - deleteConfirmation.addButton(withTitle: "Cancel") - if deleteConfirmation.runModal() == .alertFirstButtonReturn { // "Delete" button - if FileItem.fileManger.fileExists(atPath: self.url.path) { - do { - try FileItem.fileManger.removeItem(at: self.url) - } catch { - fatalError(error.localizedDescription) - } - } - } - } - - /// This function duplicates the item or folder - func duplicate(to destination: URL? = nil) { - var fileUrl = destination == nil ? self.url : destination! - // if a file/folder with the same name exists, add "copy" to the end - while FileItem.fileManger.fileExists(atPath: fileUrl.path) { - let previousName = fileUrl.deletingPathExtension().lastPathComponent - let filextension = fileUrl.pathExtension - let duplicateName = "\(previousName)-copy" - - fileUrl = fileUrl.deletingLastPathComponent() - fileUrl.appendPathComponent(duplicateName) - fileUrl.appendPathExtension(filextension) - } - - if FileItem.fileManger.fileExists(atPath: self.url.path) { - do { - try FileItem.fileManger.copyItem(at: self.url, to: fileUrl) - } catch { - print("Error at \(self.url.path) to \(fileUrl.path)") - fatalError(error.localizedDescription) - } - } - } - - /// This function moves the item or folder if possible - func move(to newLocation: URL) { - guard !FileItem.fileManger.fileExists(atPath: newLocation.path) else { return } - do { - try FileItem.fileManger.moveItem(at: self.url, to: newLocation) - self.url = newLocation - } catch { - let errorCode = (error as NSError).code - let errorAlert = NSAlert() - errorAlert.messageText = """ - The operation can’t be completed because an unexpected error occurred (error code \(String(errorCode))). - """ - errorAlert.alertStyle = .critical - errorAlert.addButton(withTitle: "OK") - errorAlert.runModal() - } - } - } -} - -// MARK: Hashable - -extension WorkspaceClient.FileItem: Hashable { - func hash(into hasher: inout Hasher) { - hasher.combine(uuid) - } -} - -// MARK: Comparable - -extension WorkspaceClient.FileItem: Comparable { - static func == (lhs: WorkspaceClient.FileItem, rhs: WorkspaceClient.FileItem) -> Bool { - lhs.id == rhs.id - } - - static func < (lhs: WorkspaceClient.FileItem, rhs: WorkspaceClient.FileItem) -> Bool { - lhs.url.lastPathComponent < rhs.url.lastPathComponent - } -} - -extension Array where Element: Hashable { - - // TODO: DOCS (Marco Carnevali) - func difference(from other: [Element]) -> [Element] { - let thisSet = Set(self) - let otherSet = Set(other) - return Array(thisSet.symmetricDifference(otherSet)) - } -} diff --git a/CodeEdit/WorkspaceView.swift b/CodeEdit/WorkspaceView.swift index db99d790f..f0ac3f6ce 100644 --- a/CodeEdit/WorkspaceView.swift +++ b/CodeEdit/WorkspaceView.swift @@ -37,7 +37,7 @@ struct WorkspaceView: View { var focusedEditor: TabGroupData? var body: some View { - if workspace.workspaceClient != nil { + if workspace.workspaceFileManager != nil { VStack { SplitViewReader { proxy in SplitView(axis: .vertical) { diff --git a/CodeEditTests/Features/CodeEditUI/CodeEditUITests.swift b/CodeEditTests/Features/CodeEditUI/CodeEditUITests.swift index d72e55795..73fccef97 100644 --- a/CodeEditTests/Features/CodeEditUI/CodeEditUITests.swift +++ b/CodeEditTests/Features/CodeEditUI/CodeEditUITests.swift @@ -88,7 +88,7 @@ final class CodeEditUIUnitTests: XCTestCase { func testBranchPickerLight() throws { let view = ToolbarBranchPicker( shellClient: ShellClient(), - workspace: nil + workspaceFileManager: nil ) let hosting = NSHostingView(rootView: view) hosting.appearance = .init(named: .aqua) @@ -99,7 +99,7 @@ final class CodeEditUIUnitTests: XCTestCase { func testBranchPickerDark() throws { let view = ToolbarBranchPicker( shellClient: ShellClient(), - workspace: nil + workspaceFileManager: nil ) let hosting = NSHostingView(rootView: view) hosting.appearance = .init(named: .darkAqua) diff --git a/CodeEditTests/Utils/WorkspaceClient/WorkspaceClientTests.swift b/CodeEditTests/Utils/WorkspaceClient/WorkspaceClientTests.swift index 6d0fa87a3..0cd27fa6f 100644 --- a/CodeEditTests/Utils/WorkspaceClient/WorkspaceClientTests.swift +++ b/CodeEditTests/Utils/WorkspaceClient/WorkspaceClientTests.swift @@ -34,13 +34,12 @@ final class WorkspaceClientUnitTests: XCTestCase { .appendingPathComponent($0) try fakeData!.write(to: fileUrl) } - let client: WorkspaceClient = try .default( - fileManager: .default, - folderURL: directory, + let client = CEWorkspaceFileManager( + folderUrl: directory, ignoredFilesAndFolders: [] ) - var newFiles: [WorkspaceClient.FileItem] = [] + var newFiles: [CEWorkspaceFile] = [] cancellable = client .getFiles @@ -80,13 +79,12 @@ final class WorkspaceClientUnitTests: XCTestCase { try fakeData!.write(to: fileUrl) } - let client: WorkspaceClient = try .default( - fileManager: .default, - folderURL: directory, + let client = CEWorkspaceFileManager( + folderUrl: directory, ignoredFilesAndFolders: [] ) - var newFiles: [WorkspaceClient.FileItem] = [] + var newFiles: [CEWorkspaceFile] = [] cancellable = client .getFiles