From adf6fa727c9df71472443a4b7973be7267457943 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 9 Nov 2024 09:12:14 -0600 Subject: [PATCH] fix: Update Recently Opened Menu (#1919) * Duplicates Title Is Always On * Sync With AppKit --- CodeEdit.xcodeproj/project.pbxproj | 67 ++++++++---- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../CodeEditDocumentController.swift | 25 +---- .../Welcome/Model/RecentProjectsStore.swift | 102 ++++++++++++++++++ ...Item.swift => RecentProjectListItem.swift} | 4 +- .../Views/RecentProjectsListView.swift | 73 +++---------- .../WindowCommands/FileCommands.swift | 6 +- .../WindowCommands/Utils/CommandsFixes.swift | 6 +- .../Utils/RecentProjectsMenu.swift | 99 +++++++++++++++++ .../WindowControllerPropertyWrapper.swift | 4 +- .../Extensions/URL/URL+componentCompare.swift | 18 ++++ 11 files changed, 292 insertions(+), 114 deletions(-) create mode 100644 CodeEdit/Features/Welcome/Model/RecentProjectsStore.swift rename CodeEdit/Features/Welcome/Views/{RecentProjectItem.swift => RecentProjectListItem.swift} (94%) create mode 100644 CodeEdit/Features/WindowCommands/Utils/RecentProjectsMenu.swift create mode 100644 CodeEdit/Utils/Extensions/URL/URL+componentCompare.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 8b12fa027..f6a278454 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -108,7 +108,7 @@ 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 */; }; - 581BFB6B2926431000D251EC /* RecentProjectItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581BFB5E2926431000D251EC /* RecentProjectItem.swift */; }; + 581BFB6B2926431000D251EC /* RecentProjectListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581BFB5E2926431000D251EC /* RecentProjectListItem.swift */; }; 582213F0291834A500EFE361 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 582213EF291834A500EFE361 /* AboutView.swift */; }; 583E528C29361B39001AB554 /* CodeEditUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 583E527529361B39001AB554 /* CodeEditUITests.swift */; }; 583E528D29361B39001AB554 /* testHelpButtonDark.1.png in Resources */ = {isa = PBXBuildFile; fileRef = 583E527929361B39001AB554 /* testHelpButtonDark.1.png */; }; @@ -357,6 +357,7 @@ 66F370342BEE537B00D3B823 /* NonTextFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66F370332BEE537B00D3B823 /* NonTextFileView.swift */; }; 6C049A372A49E2DB00D42923 /* DirectoryEventStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C049A362A49E2DB00D42923 /* DirectoryEventStream.swift */; }; 6C05A8AF284D0CA3007F4EAA /* WorkspaceDocument+Listeners.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C05A8AE284D0CA3007F4EAA /* WorkspaceDocument+Listeners.swift */; }; + 6C05CF9E2CDE8699006AAECD /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C05CF9D2CDE8699006AAECD /* CodeEditSourceEditor */; }; 6C0617D62BDB4432008C9C42 /* LogStream in Frameworks */ = {isa = PBXBuildFile; productRef = 6C0617D52BDB4432008C9C42 /* LogStream */; }; 6C08249C2C556F7400A0751E /* TerminalCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C08249B2C556F7400A0751E /* TerminalCache.swift */; }; 6C08249E2C55768400A0751E /* UtilityAreaTerminal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C08249D2C55768400A0751E /* UtilityAreaTerminal.swift */; }; @@ -383,6 +384,9 @@ 6C2C155A29B4F4CC00EA60A5 /* Variadic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2C155929B4F4CC00EA60A5 /* Variadic.swift */; }; 6C2C155D29B4F4E500EA60A5 /* SplitViewReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2C155C29B4F4E500EA60A5 /* SplitViewReader.swift */; }; 6C2C156129B4F52F00EA60A5 /* SplitViewModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2C156029B4F52F00EA60A5 /* SplitViewModifiers.swift */; }; + 6C3E12D32CC830D700DD12F1 /* RecentProjectsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3E12D22CC830D700DD12F1 /* RecentProjectsStore.swift */; }; + 6C3E12D62CC8388000DD12F1 /* URL+componentCompare.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3E12D52CC8388000DD12F1 /* URL+componentCompare.swift */; }; + 6C3E12D82CC83CB600DD12F1 /* RecentProjectsMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3E12D72CC83CB600DD12F1 /* RecentProjectsMenu.swift */; }; 6C4104E3297C87A000F472BA /* BlurButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C4104E2297C87A000F472BA /* BlurButtonStyle.swift */; }; 6C4104E6297C884F00F472BA /* AboutDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C4104E5297C884F00F472BA /* AboutDetailView.swift */; }; 6C4104E9297C970F00F472BA /* AboutDefaultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C4104E8297C970F00F472BA /* AboutDefaultView.swift */; }; @@ -451,6 +455,7 @@ 6CBA0D512A1BF524002C6FAA /* SegmentedControlImproved.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CBA0D502A1BF524002C6FAA /* SegmentedControlImproved.swift */; }; 6CBD1BC62978DE53006639D5 /* Font+Caption3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CBD1BC52978DE53006639D5 /* Font+Caption3.swift */; }; 6CBE1CFB2B71DAA6003AC32E /* Loopable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CBE1CFA2B71DAA6003AC32E /* Loopable.swift */; }; + 6CC00A8B2CBEF150004E8134 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CC00A8A2CBEF150004E8134 /* CodeEditSourceEditor */; }; 6CC17B4F2C432AE000834E2C /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CC17B4E2C432AE000834E2C /* CodeEditSourceEditor */; }; 6CC17B512C43311900834E2C /* ProjectNavigatorViewController+NSOutlineViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CC17B502C43311900834E2C /* ProjectNavigatorViewController+NSOutlineViewDataSource.swift */; }; 6CC17B532C43314000834E2C /* ProjectNavigatorViewController+NSOutlineViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CC17B522C43314000834E2C /* ProjectNavigatorViewController+NSOutlineViewDelegate.swift */; }; @@ -464,7 +469,6 @@ 6CD26C7B2C8EA8A500ADBA38 /* LSPCache+Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C792C8EA8A500ADBA38 /* LSPCache+Data.swift */; }; 6CD26C7D2C8EA8F400ADBA38 /* LanguageServer+DocumentSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C7C2C8EA8F400ADBA38 /* LanguageServer+DocumentSync.swift */; }; 6CD26C812C8F8A4400ADBA38 /* LanguageIdentifier+CodeLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C802C8F8A4400ADBA38 /* LanguageIdentifier+CodeLanguage.swift */; }; - 6CD26C852C8F907800ADBA38 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CD26C842C8F907800ADBA38 /* CodeEditSourceEditor */; }; 6CD26C872C8F90FD00ADBA38 /* LazyServiceWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C862C8F90FD00ADBA38 /* LazyServiceWrapper.swift */; }; 6CD26C8A2C8F91ED00ADBA38 /* LanguageServer+DocumentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C892C8F91ED00ADBA38 /* LanguageServer+DocumentTests.swift */; }; 6CD3CA552C8B508200D83DCD /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */; }; @@ -786,7 +790,7 @@ 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 = ""; }; - 581BFB5E2926431000D251EC /* RecentProjectItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecentProjectItem.swift; sourceTree = ""; }; + 581BFB5E2926431000D251EC /* RecentProjectListItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecentProjectListItem.swift; sourceTree = ""; }; 582213EF291834A500EFE361 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; 583E527529361B39001AB554 /* CodeEditUITests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CodeEditUITests.swift; sourceTree = ""; }; 583E527929361B39001AB554 /* testHelpButtonDark.1.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = testHelpButtonDark.1.png; sourceTree = ""; }; @@ -1059,6 +1063,9 @@ 6C2C155929B4F4CC00EA60A5 /* Variadic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Variadic.swift; sourceTree = ""; }; 6C2C155C29B4F4E500EA60A5 /* SplitViewReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitViewReader.swift; sourceTree = ""; }; 6C2C156029B4F52F00EA60A5 /* SplitViewModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitViewModifiers.swift; sourceTree = ""; }; + 6C3E12D22CC830D700DD12F1 /* RecentProjectsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentProjectsStore.swift; sourceTree = ""; }; + 6C3E12D52CC8388000DD12F1 /* URL+componentCompare.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+componentCompare.swift"; sourceTree = ""; }; + 6C3E12D72CC83CB600DD12F1 /* RecentProjectsMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentProjectsMenu.swift; sourceTree = ""; }; 6C4104E2297C87A000F472BA /* BlurButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurButtonStyle.swift; sourceTree = ""; }; 6C4104E5297C884F00F472BA /* AboutDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutDetailView.swift; sourceTree = ""; }; 6C4104E8297C970F00F472BA /* AboutDefaultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutDefaultView.swift; sourceTree = ""; }; @@ -1305,6 +1312,7 @@ 58F2EB1E292FB954004A9BDE /* Sparkle in Frameworks */, 6C147C4529A329350089B630 /* OrderedCollections in Frameworks */, 6CE21E872C650D2C0031B056 /* SwiftTerm in Frameworks */, + 6CC00A8B2CBEF150004E8134 /* CodeEditSourceEditor in Frameworks */, 6CD3CA552C8B508200D83DCD /* CodeEditSourceEditor in Frameworks */, 6C0617D62BDB4432008C9C42 /* LogStream in Frameworks */, 6CC17B4F2C432AE000834E2C /* CodeEditSourceEditor in Frameworks */, @@ -1313,8 +1321,8 @@ 6C6BD6F429CD142C00235D17 /* CollectionConcurrencyKit in Frameworks */, 6C85BB442C210EFD00EB5DEF /* SwiftUIIntrospect in Frameworks */, 6CB446402B6DFF3A00539ED0 /* CodeEditSourceEditor in Frameworks */, + 6C05CF9E2CDE8699006AAECD /* CodeEditSourceEditor in Frameworks */, 2816F594280CF50500DD548B /* CodeEditSymbols in Frameworks */, - 6CD26C852C8F907800ADBA38 /* CodeEditSourceEditor in Frameworks */, 30CB64942C16CA9100CC8A9E /* LanguageClient in Frameworks */, 6C6BD6F829CD14D100235D17 /* CodeEditKit in Frameworks */, 6C0824A12C5C0C9700A0751E /* SwiftTerm in Frameworks */, @@ -1646,6 +1654,7 @@ 581BFB4B2926431000D251EC /* Welcome */ = { isa = PBXGroup; children = ( + 6C3E12D42CC830DE00DD12F1 /* Model */, 581BFB562926431000D251EC /* Views */, ); path = Welcome; @@ -1659,7 +1668,7 @@ 581BFB5B2926431000D251EC /* WelcomeView.swift */, 581BFB5C2926431000D251EC /* WelcomeActionView.swift */, 6C186209298BF5A800C663EA /* RecentProjectsListView.swift */, - 581BFB5E2926431000D251EC /* RecentProjectItem.swift */, + 581BFB5E2926431000D251EC /* RecentProjectListItem.swift */, ); path = Views; sourceTree = ""; @@ -2861,6 +2870,14 @@ path = ChangedFile; sourceTree = ""; }; + 6C3E12D42CC830DE00DD12F1 /* Model */ = { + isa = PBXGroup; + children = ( + 6C3E12D22CC830D700DD12F1 /* RecentProjectsStore.swift */, + ); + path = Model; + sourceTree = ""; + }; 6C48B5DB2C0D664A001E9955 /* Model */ = { isa = PBXGroup; children = ( @@ -2913,8 +2930,9 @@ children = ( B66A4E4429C8E86D004573B4 /* CommandsFixes.swift */, 6C82D6BB29C00CD900495C54 /* FirstResponderPropertyWrapper.swift */, - 6CC17B5A2C44258700834E2C /* WindowControllerPropertyWrapper.swift */, + 6C3E12D72CC83CB600DD12F1 /* RecentProjectsMenu.swift */, 6CD035892C3461160091E1F4 /* KeyWindowControllerObserver.swift */, + 6CC17B5A2C44258700834E2C /* WindowControllerPropertyWrapper.swift */, ); path = Utils; sourceTree = ""; @@ -3090,6 +3108,7 @@ children = ( 77EF6C032C57DE4B00984B69 /* URL+ResouceValues.swift */, 77EF6C0A2C60C80800984B69 /* URL+Filename.swift */, + 6C3E12D52CC8388000DD12F1 /* URL+componentCompare.swift */, ); path = URL; sourceTree = ""; @@ -3699,8 +3718,9 @@ 6CE21E862C650D2C0031B056 /* SwiftTerm */, 6C4E37FB2C73E00700AEE7B5 /* SwiftTerm */, 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */, - 6CD26C842C8F907800ADBA38 /* CodeEditSourceEditor */, 6CB94D022CA1205100E8651C /* AsyncAlgorithms */, + 6CC00A8A2CBEF150004E8134 /* CodeEditSourceEditor */, + 6C05CF9D2CDE8699006AAECD /* CodeEditSourceEditor */, ); productName = CodeEdit; productReference = B658FB2C27DA9E0F00EA4DBD /* CodeEdit.app */; @@ -3797,8 +3817,8 @@ 303E88452C276FD100EEA8D9 /* XCRemoteSwiftPackageReference "LanguageClient" */, 303E88462C276FD600EEA8D9 /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */, 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */, - 6CD26C832C8F907800ADBA38 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, + 6C05CF9C2CDE8699006AAECD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, ); productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */; projectDirPath = ""; @@ -4094,6 +4114,7 @@ 587B9E8229301D8F00AC7927 /* GitHubPreviewHeader.swift in Sources */, 611191FC2B08CCB800D4459B /* SearchIndexer+AsyncController.swift in Sources */, B6966A282C2F683300259C2D /* SourceControlPullView.swift in Sources */, + 6C3E12D32CC830D700DD12F1 /* RecentProjectsStore.swift in Sources */, 30B088112C0D53080063A882 /* LanguageServer+SignatureHelp.swift in Sources */, 6C578D8929CD36E400DC73B2 /* Commands+ForEach.swift in Sources */, 611192082B08CCFD00D4459B /* SearchIndexer+Terms.swift in Sources */, @@ -4136,6 +4157,7 @@ 58798238292E30B90085B254 /* FeedbackWindowController.swift in Sources */, B6CFD80D2C1B9A8000E63F1A /* FontWeightPicker.swift in Sources */, 587B9E6C29301D8F00AC7927 /* GitLabNamespace.swift in Sources */, + 6C3E12D82CC83CB600DD12F1 /* RecentProjectsMenu.swift in Sources */, 30AB4EC22BF7253200ED4431 /* KeyValueTable.swift in Sources */, 6139B9142C29B35D00CA584B /* TaskManager.swift in Sources */, 6C48D8F22972DAFC00D6D205 /* Env+IsFullscreen.swift in Sources */, @@ -4222,7 +4244,7 @@ 6CB9144B29BEC7F100BC47F2 /* (null) in Sources */, 587B9E7429301D8F00AC7927 /* URL+URLParameters.swift in Sources */, 61538B902B111FE800A88846 /* String+AppearancesOfSubstring.swift in Sources */, - 581BFB6B2926431000D251EC /* RecentProjectItem.swift in Sources */, + 581BFB6B2926431000D251EC /* RecentProjectListItem.swift in Sources */, 587FB99029C1246400B519DD /* EditorTabView.swift in Sources */, 587B9DA429300ABD00AC7927 /* SearchPanel.swift in Sources */, 58D01C95293167DC00C5B6B4 /* Bundle+Info.swift in Sources */, @@ -4248,6 +4270,7 @@ 581550D429FBD37D00684881 /* ProjectNavigatorToolbarBottom.swift in Sources */, 66AF6CE72BF17FFB00D83C9D /* UpdateStatusBarInfo.swift in Sources */, 587B9E7E29301D8F00AC7927 /* GitHubGistRouter.swift in Sources */, + 6C3E12D62CC8388000DD12F1 /* URL+componentCompare.swift in Sources */, B6AB09A52AAAC00F0003A3A6 /* EditorTabBarTrailingAccessories.swift in Sources */, 04BA7C0B2AE2A2D100584E1C /* GitBranch.swift in Sources */, 6CAAF69229BCC71C00A1F48A /* (null) in Sources */, @@ -5631,6 +5654,14 @@ version = 2.3.0; }; }; + 6C05CF9C2CDE8699006AAECD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.8.1; + }; + }; 6C0617D42BDB4432008C9C42 /* XCRemoteSwiftPackageReference "LogStream" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Wouter01/LogStream"; @@ -5695,14 +5726,6 @@ version = 1.0.1; }; }; - 6CD26C832C8F907800ADBA38 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.8.1; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -5731,6 +5754,11 @@ package = 58F2EB1C292FB954004A9BDE /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; + 6C05CF9D2CDE8699006AAECD /* CodeEditSourceEditor */ = { + isa = XCSwiftPackageProductDependency; + package = 6C05CF9C2CDE8699006AAECD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */; + productName = CodeEditSourceEditor; + }; 6C0617D52BDB4432008C9C42 /* LogStream */ = { isa = XCSwiftPackageProductDependency; package = 6C0617D42BDB4432008C9C42 /* XCRemoteSwiftPackageReference "LogStream" */; @@ -5793,13 +5821,12 @@ package = 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */; productName = AsyncAlgorithms; }; - 6CC17B4E2C432AE000834E2C /* CodeEditSourceEditor */ = { + 6CC00A8A2CBEF150004E8134 /* CodeEditSourceEditor */ = { isa = XCSwiftPackageProductDependency; productName = CodeEditSourceEditor; }; - 6CD26C842C8F907800ADBA38 /* CodeEditSourceEditor */ = { + 6CC17B4E2C432AE000834E2C /* CodeEditSourceEditor */ = { isa = XCSwiftPackageProductDependency; - package = 6CD26C832C8F907800ADBA38 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */; productName = CodeEditSourceEditor; }; 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */ = { diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a723cdba1..65c856838 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "5c4a5d433333474763817b9804d7f1856ab3b416ed87b190a2bd6e86c0c9834c", + "originHash" : "aef43d6aa0c467418565c574c33495a50d6e24057eb350c17704ab4ae2aead6c", "pins" : [ { "identity" : "anycodable", diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift b/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift index 6e976d049..778f70734 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift @@ -41,10 +41,6 @@ final class CodeEditDocumentController: NSDocumentController { return panel.url } - override func noteNewRecentDocument(_ document: NSDocument) { - // The super method is run manually when opening new documents. - } - override func openDocument(_ sender: Any?) { self.openDocument(onCompletion: { document, documentWasAlreadyOpen in // TODO: handle errors @@ -63,17 +59,16 @@ final class CodeEditDocumentController: NSDocumentController { display displayDocument: Bool, completionHandler: @escaping (NSDocument?, Bool, Error?) -> Void ) { - super.noteNewRecentDocumentURL(url) super.openDocument(withContentsOf: url, display: displayDocument) { document, documentWasAlreadyOpen, error in if let document { self.addDocument(document) - self.updateRecent(url) } else { let errorMessage = error?.localizedDescription ?? "unknown error" print("Unable to open document '\(url)': \(errorMessage)") } + RecentProjectsStore.documentOpened(at: url) completionHandler(document, documentWasAlreadyOpen, error) } } @@ -98,11 +93,6 @@ final class CodeEditDocumentController: NSDocumentController { } } - override func clearRecentDocuments(_ sender: Any?) { - super.clearRecentDocuments(sender) - UserDefaults.standard.set([Any](), forKey: "recentProjectPaths") - } - override func addDocument(_ document: NSDocument) { super.addDocument(document) if let document = document as? CodeFileDocument { @@ -138,7 +128,6 @@ extension NSDocumentController { alert.runModal() return } - self.updateRecent(url) onCompletion(document, documentWasAlreadyOpen) print("Document:", document) print("Was already open?", documentWasAlreadyOpen) @@ -148,16 +137,4 @@ extension NSDocumentController { } } } - - final func updateRecent(_ url: URL) { - var recentProjectPaths: [String] = UserDefaults.standard.array( - forKey: "recentProjectPaths" - ) as? [String] ?? [] - if let containedIndex = recentProjectPaths.firstIndex(of: url.path) { - recentProjectPaths.move(fromOffsets: IndexSet(integer: containedIndex), toOffset: 0) - } else { - recentProjectPaths.insert(url.path, at: 0) - } - UserDefaults.standard.set(recentProjectPaths, forKey: "recentProjectPaths") - } } diff --git a/CodeEdit/Features/Welcome/Model/RecentProjectsStore.swift b/CodeEdit/Features/Welcome/Model/RecentProjectsStore.swift new file mode 100644 index 000000000..4f03ef850 --- /dev/null +++ b/CodeEdit/Features/Welcome/Model/RecentProjectsStore.swift @@ -0,0 +1,102 @@ +// +// RecentProjectsUtil.swift +// CodeEdit +// +// Created by Khan Winter on 10/22/24. +// + +import AppKit +import CoreSpotlight + +/// Helper methods for managing the recent projects list and donating list items to CoreSpotlight. +/// +/// Limits the number of remembered projects to 100 items. +/// +/// If a UI element needs to listen to changes in this list, listen for the +/// ``RecentProjectsStore/didUpdateNotification`` notification. +enum RecentProjectsStore { + private static let defaultsKey = "recentProjectPaths" + static let didUpdateNotification = Notification.Name("RecentProjectsStore.didUpdate") + + static func recentProjectPaths() -> [String] { + UserDefaults.standard.array(forKey: defaultsKey) as? [String] ?? [] + } + + static func recentProjectURLs() -> [URL] { + recentProjectPaths().map { URL(filePath: $0) } + } + + private static func setPaths(_ paths: [String]) { + var paths = paths + // Remove duplicates + var foundPaths = Set() + for (idx, path) in paths.enumerated().reversed() { + if foundPaths.contains(path) { + paths.remove(at: idx) + } else { + foundPaths.insert(path) + } + } + + // Limit list to to 100 items after de-duplication + UserDefaults.standard.setValue(Array(paths.prefix(100)), forKey: defaultsKey) + setDocumentControllerRecents() + donateSearchableItems() + NotificationCenter.default.post(name: Self.didUpdateNotification, object: nil) + } + + /// Notify the store that a url was opened. + /// Moves the path to the front if it was in the list already, or prepends it. + /// Saves the list to defaults when called. + /// - Parameter url: The url that was opened. Any url is accepted. File, directory, https. + static func documentOpened(at url: URL) { + var paths = recentProjectURLs() + if let containedIndex = paths.firstIndex(where: { $0.componentCompare(url) }) { + paths.move(fromOffsets: IndexSet(integer: containedIndex), toOffset: 0) + } else { + paths.insert(url, at: 0) + } + setPaths(paths.map { $0.path(percentEncoded: false) }) + } + + /// Remove all paths in the set. + /// - Parameter paths: The paths to remove. + /// - Returns: The remaining urls in the recent projects list. + static func removeRecentProjects(_ paths: Set) -> [URL] { + var recentProjectPaths = recentProjectURLs() + recentProjectPaths.removeAll(where: { paths.contains($0) }) + setPaths(recentProjectPaths.map { $0.path(percentEncoded: false) }) + return recentProjectURLs() + } + + static func clearList() { + setPaths([]) + } + + /// Syncs AppKit's recent documents list with ours, keeping the dock menu and other lists up-to-date. + private static func setDocumentControllerRecents() { + CodeEditDocumentController.shared.clearRecentDocuments(nil) + for path in recentProjectURLs().prefix(10) { + CodeEditDocumentController.shared.noteNewRecentDocumentURL(path) + } + } + + /// Donates all recent URLs to Core Search, making them searchable in Spotlight + private static func donateSearchableItems() { + let searchableItems = recentProjectURLs().map { entity in + let attributeSet = CSSearchableItemAttributeSet(contentType: .content) + attributeSet.title = entity.lastPathComponent + attributeSet.relatedUniqueIdentifier = entity.path() + return CSSearchableItem( + uniqueIdentifier: entity.path(), + domainIdentifier: "app.codeedit.CodeEdit.ProjectItem", + attributeSet: attributeSet + ) + } + CSSearchableIndex.default().indexSearchableItems(searchableItems) { error in + if let error = error { + print(error) + } + } + } +} diff --git a/CodeEdit/Features/Welcome/Views/RecentProjectItem.swift b/CodeEdit/Features/Welcome/Views/RecentProjectListItem.swift similarity index 94% rename from CodeEdit/Features/Welcome/Views/RecentProjectItem.swift rename to CodeEdit/Features/Welcome/Views/RecentProjectListItem.swift index f0a0ab71a..e69eeee01 100644 --- a/CodeEdit/Features/Welcome/Views/RecentProjectItem.swift +++ b/CodeEdit/Features/Welcome/Views/RecentProjectListItem.swift @@ -1,5 +1,5 @@ // -// RecentProjectItem.swift +// RecentProjectListItem.swift // CodeEditModules/WelcomeModule // // Created by Ziyuan Zhao on 2022/3/18. @@ -13,7 +13,7 @@ extension String { } } -struct RecentProjectItem: View { +struct RecentProjectListItem: View { let projectPath: URL init(projectPath: URL) { diff --git a/CodeEdit/Features/Welcome/Views/RecentProjectsListView.swift b/CodeEdit/Features/Welcome/Views/RecentProjectsListView.swift index 9e3820fb8..381b61571 100644 --- a/CodeEdit/Features/Welcome/Views/RecentProjectsListView.swift +++ b/CodeEdit/Features/Welcome/Views/RecentProjectsListView.swift @@ -19,14 +19,8 @@ struct RecentProjectsListView: View { init(openDocument: @escaping (URL?, @escaping () -> Void) -> Void, dismissWindow: @escaping () -> Void) { self.openDocument = openDocument self.dismissWindow = dismissWindow - - let recentProjectPaths: [String] = UserDefaults.standard.array( - forKey: "recentProjectPaths" - ) as? [String] ?? [] - let projectsURL = recentProjectPaths.map { URL(filePath: $0) } - _selection = .init(initialValue: Set(projectsURL.prefix(1))) - _recentProjects = .init(initialValue: projectsURL) - donateSearchableItems() + self._recentProjects = .init(initialValue: RecentProjectsStore.recentProjectURLs()) + self._selection = .init(initialValue: Set(RecentProjectsStore.recentProjectURLs().prefix(1))) } var listEmptyView: some View { @@ -41,7 +35,7 @@ struct RecentProjectsListView: View { var body: some View { List(recentProjects, id: \.self, selection: $selection) { project in - RecentProjectItem(projectPath: project) + RecentProjectListItem(projectPath: project) } .listStyle(.sidebar) .contextMenu(forSelectionType: URL.self) { items in @@ -60,33 +54,22 @@ struct RecentProjectsListView: View { } Button("Remove from Recents") { - removeRecentProjects(items) + removeRecentProjects() } } } primaryAction: { items in - items.forEach { - openDocument($0, dismissWindow) - } + items.forEach { openDocument($0, dismissWindow) } } .onCopyCommand { - selection.map { - NSItemProvider(object: $0.path(percentEncoded: false) as NSString) - } + selection.map { NSItemProvider(object: $0.path(percentEncoded: false) as NSString) } } .onDeleteCommand { - removeRecentProjects(selection) + removeRecentProjects() } .background(EffectView(.underWindowBackground, blendingMode: .behindWindow)) - .onReceive(NSApp.publisher(for: \.keyWindow)) { _ in - // Update the list whenever the key window changes. - // Ideally, this should be 'whenever a doc opens/closes'. - updateRecentProjects() - } .background { Button("") { - selection.forEach { - openDocument($0, dismissWindow) - } + selection.forEach { openDocument($0, dismissWindow) } } .keyboardShortcut(.defaultAction) .hidden() @@ -98,44 +81,16 @@ struct RecentProjectsListView: View { } } } - } - - func removeRecentProjects(_ items: Set) { - var recentProjectPaths: [String] = UserDefaults.standard.array( - forKey: "recentProjectPaths" - ) as? [String] ?? [] - items.forEach { url in - recentProjectPaths.removeAll { url == URL(filePath: $0) } - selection.remove(url) + .onReceive(NotificationCenter.default.publisher(for: RecentProjectsStore.didUpdateNotification)) { _ in + updateRecentProjects() } - UserDefaults.standard.set(recentProjectPaths, forKey: "recentProjectPaths") - let projectsURL = recentProjectPaths.map { URL(filePath: $0) } - recentProjects = projectsURL } - func updateRecentProjects() { - let recentProjectPaths: [String] = UserDefaults.standard.array( - forKey: "recentProjectPaths" - ) as? [String] ?? [] - let projectsURL = recentProjectPaths.map { URL(filePath: $0) } - recentProjects = projectsURL + func removeRecentProjects() { + recentProjects = RecentProjectsStore.removeRecentProjects(selection) } - func donateSearchableItems() { - let searchableItems = recentProjects.map { entity in - let attributeSet = CSSearchableItemAttributeSet(contentType: .content) - attributeSet.title = entity.lastPathComponent - attributeSet.relatedUniqueIdentifier = entity.path() - return CSSearchableItem( - uniqueIdentifier: entity.path(), - domainIdentifier: "app.codeedit.CodeEdit.ProjectItem", - attributeSet: attributeSet - ) - } - CSSearchableIndex.default().indexSearchableItems(searchableItems) { error in - if let error = error { - print(error) - } - } + func updateRecentProjects() { + recentProjects = RecentProjectsStore.recentProjectURLs() } } diff --git a/CodeEdit/Features/WindowCommands/FileCommands.swift b/CodeEdit/Features/WindowCommands/FileCommands.swift index 95e3d51f3..130ce0e5b 100644 --- a/CodeEdit/Features/WindowCommands/FileCommands.swift +++ b/CodeEdit/Features/WindowCommands/FileCommands.swift @@ -8,6 +8,8 @@ import SwiftUI struct FileCommands: Commands { + static let recentProjectsMenu = RecentProjectsMenu() + @Environment(\.openWindow) private var openWindow @@ -29,8 +31,8 @@ struct FileCommands: Commands { .keyboardShortcut("o") // Leave this empty, is done through a hidden API in WindowCommands/Utils/CommandsFixes.swift - // This can't be done in SwiftUI Commands yet, as they don't support images in menu items. - Menu("Open Recent") {} + // We set this with a custom NSMenu. See WindowCommands/Utils/RecentProjectsMenu.swift + Menu("Open Recent") { } Button("Open Quickly") { NSApp.sendAction(#selector(CodeEditWindowController.openQuickly(_:)), to: nil, from: nil) diff --git a/CodeEdit/Features/WindowCommands/Utils/CommandsFixes.swift b/CodeEdit/Features/WindowCommands/Utils/CommandsFixes.swift index 6da317fcc..d4de82808 100644 --- a/CodeEdit/Features/WindowCommands/Utils/CommandsFixes.swift +++ b/CodeEdit/Features/WindowCommands/Utils/CommandsFixes.swift @@ -14,7 +14,6 @@ extension EventModifiers { extension NSMenuItem { @objc fileprivate func fixAlternate(_ newValue: NSEvent.ModifierFlags) { - if newValue.contains(.numericPad) { isAlternate = true fixAlternate(newValue.subtracting(.numericPad)) @@ -23,10 +22,7 @@ extension NSMenuItem { fixAlternate(newValue) if self.title == "Open Recent" { - let openRecentMenu = NSMenu(title: "Open Recent") - openRecentMenu.perform(NSSelectorFromString("_setMenuName:"), with: "NSRecentDocumentsMenu") - self.submenu = openRecentMenu - NSDocumentController.shared.value(forKey: "_installOpenRecentMenus") + self.submenu = FileCommands.recentProjectsMenu.makeMenu() } if self.title == "OpenWindowAction" || self.title.isEmpty { diff --git a/CodeEdit/Features/WindowCommands/Utils/RecentProjectsMenu.swift b/CodeEdit/Features/WindowCommands/Utils/RecentProjectsMenu.swift new file mode 100644 index 000000000..483a6baa2 --- /dev/null +++ b/CodeEdit/Features/WindowCommands/Utils/RecentProjectsMenu.swift @@ -0,0 +1,99 @@ +// +// RecentProjectsMenu.swift +// CodeEdit +// +// Created by Khan Winter on 10/22/24. +// + +import AppKit + +class RecentProjectsMenu: NSObject { + func makeMenu() -> NSMenu { + let menu = NSMenu(title: NSLocalizedString("Open Recent", comment: "Open Recent menu title")) + + let paths = RecentProjectsStore.recentProjectURLs().prefix(10) + + for projectPath in paths { + let icon = NSWorkspace.shared.icon(forFile: projectPath.path()) + icon.size = NSSize(width: 16, height: 16) + let alternateTitle = alternateTitle(for: projectPath) + + let primaryItem = NSMenuItem( + title: projectPath.lastPathComponent, + action: #selector(recentProjectItemClicked(_:)), + keyEquivalent: "" + ) + primaryItem.target = self + primaryItem.image = icon + primaryItem.representedObject = projectPath + + let containsDuplicate = paths.contains { url in + url != projectPath && url.lastPathComponent == projectPath.lastPathComponent + } + + // If there's a duplicate, add the path. + if containsDuplicate { + primaryItem.attributedTitle = alternateTitle + } + + let alternateItem = NSMenuItem( + title: "", + action: #selector(recentProjectItemClicked(_:)), + keyEquivalent: "" + ) + alternateItem.attributedTitle = alternateTitle + alternateItem.target = self + alternateItem.image = icon + alternateItem.representedObject = projectPath + alternateItem.isAlternate = true + alternateItem.keyEquivalentModifierMask = [.option] + + menu.addItem(primaryItem) + menu.addItem(alternateItem) + } + + menu.addItem(NSMenuItem.separator()) + + let clearMenuItem = NSMenuItem( + title: NSLocalizedString("Clear Menu", comment: "Recent project menu clear button"), + action: #selector(clearMenuItemClicked(_:)), + keyEquivalent: "" + ) + clearMenuItem.target = self + menu.addItem(clearMenuItem) + + return menu + } + + private func alternateTitle(for projectPath: URL) -> NSAttributedString { + let parentPath = projectPath + .deletingLastPathComponent() + .path(percentEncoded: false) + .abbreviatingWithTildeInPath() + let alternateTitle = NSMutableAttributedString( + string: projectPath.lastPathComponent + " ", attributes: [.foregroundColor: NSColor.labelColor] + ) + alternateTitle.append(NSAttributedString( + string: parentPath, + attributes: [.foregroundColor: NSColor.secondaryLabelColor] + )) + return alternateTitle + } + + @objc + func recentProjectItemClicked(_ sender: NSMenuItem) { + guard let projectURL = sender.representedObject as? URL else { + return + } + CodeEditDocumentController.shared.openDocument( + withContentsOf: projectURL, + display: true, + completionHandler: { _, _, _ in } + ) + } + + @objc + func clearMenuItemClicked(_ sender: NSMenuItem) { + RecentProjectsStore.clearList() + } +} diff --git a/CodeEdit/Features/WindowCommands/Utils/WindowControllerPropertyWrapper.swift b/CodeEdit/Features/WindowCommands/Utils/WindowControllerPropertyWrapper.swift index 6b7311afa..288db12e6 100644 --- a/CodeEdit/Features/WindowCommands/Utils/WindowControllerPropertyWrapper.swift +++ b/CodeEdit/Features/WindowCommands/Utils/WindowControllerPropertyWrapper.swift @@ -42,7 +42,9 @@ struct UpdatingWindowController: DynamicProperty { private var activeEditorCancellable: AnyCancellable? init() { - windowCancellable = NSApp.publisher(for: \.keyWindow).sink { [weak self] window in + windowCancellable = NSApp.publisher(for: \.keyWindow).receive(on: RunLoop.main).sink { [weak self] window in + // Fix an issue where NSMenuItems with custom views would trigger this callback. + guard window?.className != "NSPopupMenuWindow" else { return } self?.setNewController(window?.windowController as? CodeEditWindowController) } } diff --git a/CodeEdit/Utils/Extensions/URL/URL+componentCompare.swift b/CodeEdit/Utils/Extensions/URL/URL+componentCompare.swift new file mode 100644 index 000000000..3d411e914 --- /dev/null +++ b/CodeEdit/Utils/Extensions/URL/URL+componentCompare.swift @@ -0,0 +1,18 @@ +// +// URL+componentCompare.swift +// CodeEdit +// +// Created by Khan Winter on 10/22/24. +// + +import Foundation + +extension URL { + /// Compare a URL using its path components. + /// - Parameter other: The URL to compare to + /// - Returns: `true` if the URL points to the same path on disk. Regardless of query parameters, trailing + /// slashes, etc. + func componentCompare(_ other: URL) -> Bool { + return self.pathComponents == other.pathComponents + } +}