Skip to content

Commit adf6fa7

Browse files
fix: Update Recently Opened Menu (#1919)
* Duplicates Title Is Always On * Sync With AppKit
1 parent 7fd614f commit adf6fa7

File tree

11 files changed

+292
-114
lines changed

11 files changed

+292
-114
lines changed

CodeEdit.xcodeproj/project.pbxproj

Lines changed: 47 additions & 20 deletions
Large diffs are not rendered by default.

CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"originHash" : "5c4a5d433333474763817b9804d7f1856ab3b416ed87b190a2bd6e86c0c9834c",
2+
"originHash" : "aef43d6aa0c467418565c574c33495a50d6e24057eb350c17704ab4ae2aead6c",
33
"pins" : [
44
{
55
"identity" : "anycodable",

CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,6 @@ final class CodeEditDocumentController: NSDocumentController {
4141
return panel.url
4242
}
4343

44-
override func noteNewRecentDocument(_ document: NSDocument) {
45-
// The super method is run manually when opening new documents.
46-
}
47-
4844
override func openDocument(_ sender: Any?) {
4945
self.openDocument(onCompletion: { document, documentWasAlreadyOpen in
5046
// TODO: handle errors
@@ -63,17 +59,16 @@ final class CodeEditDocumentController: NSDocumentController {
6359
display displayDocument: Bool,
6460
completionHandler: @escaping (NSDocument?, Bool, Error?) -> Void
6561
) {
66-
super.noteNewRecentDocumentURL(url)
6762
super.openDocument(withContentsOf: url, display: displayDocument) { document, documentWasAlreadyOpen, error in
6863

6964
if let document {
7065
self.addDocument(document)
71-
self.updateRecent(url)
7266
} else {
7367
let errorMessage = error?.localizedDescription ?? "unknown error"
7468
print("Unable to open document '\(url)': \(errorMessage)")
7569
}
7670

71+
RecentProjectsStore.documentOpened(at: url)
7772
completionHandler(document, documentWasAlreadyOpen, error)
7873
}
7974
}
@@ -98,11 +93,6 @@ final class CodeEditDocumentController: NSDocumentController {
9893
}
9994
}
10095

101-
override func clearRecentDocuments(_ sender: Any?) {
102-
super.clearRecentDocuments(sender)
103-
UserDefaults.standard.set([Any](), forKey: "recentProjectPaths")
104-
}
105-
10696
override func addDocument(_ document: NSDocument) {
10797
super.addDocument(document)
10898
if let document = document as? CodeFileDocument {
@@ -138,7 +128,6 @@ extension NSDocumentController {
138128
alert.runModal()
139129
return
140130
}
141-
self.updateRecent(url)
142131
onCompletion(document, documentWasAlreadyOpen)
143132
print("Document:", document)
144133
print("Was already open?", documentWasAlreadyOpen)
@@ -148,16 +137,4 @@ extension NSDocumentController {
148137
}
149138
}
150139
}
151-
152-
final func updateRecent(_ url: URL) {
153-
var recentProjectPaths: [String] = UserDefaults.standard.array(
154-
forKey: "recentProjectPaths"
155-
) as? [String] ?? []
156-
if let containedIndex = recentProjectPaths.firstIndex(of: url.path) {
157-
recentProjectPaths.move(fromOffsets: IndexSet(integer: containedIndex), toOffset: 0)
158-
} else {
159-
recentProjectPaths.insert(url.path, at: 0)
160-
}
161-
UserDefaults.standard.set(recentProjectPaths, forKey: "recentProjectPaths")
162-
}
163140
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
//
2+
// RecentProjectsUtil.swift
3+
// CodeEdit
4+
//
5+
// Created by Khan Winter on 10/22/24.
6+
//
7+
8+
import AppKit
9+
import CoreSpotlight
10+
11+
/// Helper methods for managing the recent projects list and donating list items to CoreSpotlight.
12+
///
13+
/// Limits the number of remembered projects to 100 items.
14+
///
15+
/// If a UI element needs to listen to changes in this list, listen for the
16+
/// ``RecentProjectsStore/didUpdateNotification`` notification.
17+
enum RecentProjectsStore {
18+
private static let defaultsKey = "recentProjectPaths"
19+
static let didUpdateNotification = Notification.Name("RecentProjectsStore.didUpdate")
20+
21+
static func recentProjectPaths() -> [String] {
22+
UserDefaults.standard.array(forKey: defaultsKey) as? [String] ?? []
23+
}
24+
25+
static func recentProjectURLs() -> [URL] {
26+
recentProjectPaths().map { URL(filePath: $0) }
27+
}
28+
29+
private static func setPaths(_ paths: [String]) {
30+
var paths = paths
31+
// Remove duplicates
32+
var foundPaths = Set<String>()
33+
for (idx, path) in paths.enumerated().reversed() {
34+
if foundPaths.contains(path) {
35+
paths.remove(at: idx)
36+
} else {
37+
foundPaths.insert(path)
38+
}
39+
}
40+
41+
// Limit list to to 100 items after de-duplication
42+
UserDefaults.standard.setValue(Array(paths.prefix(100)), forKey: defaultsKey)
43+
setDocumentControllerRecents()
44+
donateSearchableItems()
45+
NotificationCenter.default.post(name: Self.didUpdateNotification, object: nil)
46+
}
47+
48+
/// Notify the store that a url was opened.
49+
/// Moves the path to the front if it was in the list already, or prepends it.
50+
/// Saves the list to defaults when called.
51+
/// - Parameter url: The url that was opened. Any url is accepted. File, directory, https.
52+
static func documentOpened(at url: URL) {
53+
var paths = recentProjectURLs()
54+
if let containedIndex = paths.firstIndex(where: { $0.componentCompare(url) }) {
55+
paths.move(fromOffsets: IndexSet(integer: containedIndex), toOffset: 0)
56+
} else {
57+
paths.insert(url, at: 0)
58+
}
59+
setPaths(paths.map { $0.path(percentEncoded: false) })
60+
}
61+
62+
/// Remove all paths in the set.
63+
/// - Parameter paths: The paths to remove.
64+
/// - Returns: The remaining urls in the recent projects list.
65+
static func removeRecentProjects(_ paths: Set<URL>) -> [URL] {
66+
var recentProjectPaths = recentProjectURLs()
67+
recentProjectPaths.removeAll(where: { paths.contains($0) })
68+
setPaths(recentProjectPaths.map { $0.path(percentEncoded: false) })
69+
return recentProjectURLs()
70+
}
71+
72+
static func clearList() {
73+
setPaths([])
74+
}
75+
76+
/// Syncs AppKit's recent documents list with ours, keeping the dock menu and other lists up-to-date.
77+
private static func setDocumentControllerRecents() {
78+
CodeEditDocumentController.shared.clearRecentDocuments(nil)
79+
for path in recentProjectURLs().prefix(10) {
80+
CodeEditDocumentController.shared.noteNewRecentDocumentURL(path)
81+
}
82+
}
83+
84+
/// Donates all recent URLs to Core Search, making them searchable in Spotlight
85+
private static func donateSearchableItems() {
86+
let searchableItems = recentProjectURLs().map { entity in
87+
let attributeSet = CSSearchableItemAttributeSet(contentType: .content)
88+
attributeSet.title = entity.lastPathComponent
89+
attributeSet.relatedUniqueIdentifier = entity.path()
90+
return CSSearchableItem(
91+
uniqueIdentifier: entity.path(),
92+
domainIdentifier: "app.codeedit.CodeEdit.ProjectItem",
93+
attributeSet: attributeSet
94+
)
95+
}
96+
CSSearchableIndex.default().indexSearchableItems(searchableItems) { error in
97+
if let error = error {
98+
print(error)
99+
}
100+
}
101+
}
102+
}

CodeEdit/Features/Welcome/Views/RecentProjectItem.swift renamed to CodeEdit/Features/Welcome/Views/RecentProjectListItem.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// RecentProjectItem.swift
2+
// RecentProjectListItem.swift
33
// CodeEditModules/WelcomeModule
44
//
55
// Created by Ziyuan Zhao on 2022/3/18.
@@ -13,7 +13,7 @@ extension String {
1313
}
1414
}
1515

16-
struct RecentProjectItem: View {
16+
struct RecentProjectListItem: View {
1717
let projectPath: URL
1818

1919
init(projectPath: URL) {

CodeEdit/Features/Welcome/Views/RecentProjectsListView.swift

Lines changed: 14 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,8 @@ struct RecentProjectsListView: View {
1919
init(openDocument: @escaping (URL?, @escaping () -> Void) -> Void, dismissWindow: @escaping () -> Void) {
2020
self.openDocument = openDocument
2121
self.dismissWindow = dismissWindow
22-
23-
let recentProjectPaths: [String] = UserDefaults.standard.array(
24-
forKey: "recentProjectPaths"
25-
) as? [String] ?? []
26-
let projectsURL = recentProjectPaths.map { URL(filePath: $0) }
27-
_selection = .init(initialValue: Set(projectsURL.prefix(1)))
28-
_recentProjects = .init(initialValue: projectsURL)
29-
donateSearchableItems()
22+
self._recentProjects = .init(initialValue: RecentProjectsStore.recentProjectURLs())
23+
self._selection = .init(initialValue: Set(RecentProjectsStore.recentProjectURLs().prefix(1)))
3024
}
3125

3226
var listEmptyView: some View {
@@ -41,7 +35,7 @@ struct RecentProjectsListView: View {
4135

4236
var body: some View {
4337
List(recentProjects, id: \.self, selection: $selection) { project in
44-
RecentProjectItem(projectPath: project)
38+
RecentProjectListItem(projectPath: project)
4539
}
4640
.listStyle(.sidebar)
4741
.contextMenu(forSelectionType: URL.self) { items in
@@ -60,33 +54,22 @@ struct RecentProjectsListView: View {
6054
}
6155

6256
Button("Remove from Recents") {
63-
removeRecentProjects(items)
57+
removeRecentProjects()
6458
}
6559
}
6660
} primaryAction: { items in
67-
items.forEach {
68-
openDocument($0, dismissWindow)
69-
}
61+
items.forEach { openDocument($0, dismissWindow) }
7062
}
7163
.onCopyCommand {
72-
selection.map {
73-
NSItemProvider(object: $0.path(percentEncoded: false) as NSString)
74-
}
64+
selection.map { NSItemProvider(object: $0.path(percentEncoded: false) as NSString) }
7565
}
7666
.onDeleteCommand {
77-
removeRecentProjects(selection)
67+
removeRecentProjects()
7868
}
7969
.background(EffectView(.underWindowBackground, blendingMode: .behindWindow))
80-
.onReceive(NSApp.publisher(for: \.keyWindow)) { _ in
81-
// Update the list whenever the key window changes.
82-
// Ideally, this should be 'whenever a doc opens/closes'.
83-
updateRecentProjects()
84-
}
8570
.background {
8671
Button("") {
87-
selection.forEach {
88-
openDocument($0, dismissWindow)
89-
}
72+
selection.forEach { openDocument($0, dismissWindow) }
9073
}
9174
.keyboardShortcut(.defaultAction)
9275
.hidden()
@@ -98,44 +81,16 @@ struct RecentProjectsListView: View {
9881
}
9982
}
10083
}
101-
}
102-
103-
func removeRecentProjects(_ items: Set<URL>) {
104-
var recentProjectPaths: [String] = UserDefaults.standard.array(
105-
forKey: "recentProjectPaths"
106-
) as? [String] ?? []
107-
items.forEach { url in
108-
recentProjectPaths.removeAll { url == URL(filePath: $0) }
109-
selection.remove(url)
84+
.onReceive(NotificationCenter.default.publisher(for: RecentProjectsStore.didUpdateNotification)) { _ in
85+
updateRecentProjects()
11086
}
111-
UserDefaults.standard.set(recentProjectPaths, forKey: "recentProjectPaths")
112-
let projectsURL = recentProjectPaths.map { URL(filePath: $0) }
113-
recentProjects = projectsURL
11487
}
11588

116-
func updateRecentProjects() {
117-
let recentProjectPaths: [String] = UserDefaults.standard.array(
118-
forKey: "recentProjectPaths"
119-
) as? [String] ?? []
120-
let projectsURL = recentProjectPaths.map { URL(filePath: $0) }
121-
recentProjects = projectsURL
89+
func removeRecentProjects() {
90+
recentProjects = RecentProjectsStore.removeRecentProjects(selection)
12291
}
12392

124-
func donateSearchableItems() {
125-
let searchableItems = recentProjects.map { entity in
126-
let attributeSet = CSSearchableItemAttributeSet(contentType: .content)
127-
attributeSet.title = entity.lastPathComponent
128-
attributeSet.relatedUniqueIdentifier = entity.path()
129-
return CSSearchableItem(
130-
uniqueIdentifier: entity.path(),
131-
domainIdentifier: "app.codeedit.CodeEdit.ProjectItem",
132-
attributeSet: attributeSet
133-
)
134-
}
135-
CSSearchableIndex.default().indexSearchableItems(searchableItems) { error in
136-
if let error = error {
137-
print(error)
138-
}
139-
}
93+
func updateRecentProjects() {
94+
recentProjects = RecentProjectsStore.recentProjectURLs()
14095
}
14196
}

CodeEdit/Features/WindowCommands/FileCommands.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import SwiftUI
99

1010
struct FileCommands: Commands {
11+
static let recentProjectsMenu = RecentProjectsMenu()
12+
1113
@Environment(\.openWindow)
1214
private var openWindow
1315

@@ -29,8 +31,8 @@ struct FileCommands: Commands {
2931
.keyboardShortcut("o")
3032

3133
// Leave this empty, is done through a hidden API in WindowCommands/Utils/CommandsFixes.swift
32-
// This can't be done in SwiftUI Commands yet, as they don't support images in menu items.
33-
Menu("Open Recent") {}
34+
// We set this with a custom NSMenu. See WindowCommands/Utils/RecentProjectsMenu.swift
35+
Menu("Open Recent") { }
3436

3537
Button("Open Quickly") {
3638
NSApp.sendAction(#selector(CodeEditWindowController.openQuickly(_:)), to: nil, from: nil)

CodeEdit/Features/WindowCommands/Utils/CommandsFixes.swift

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ extension EventModifiers {
1414
extension NSMenuItem {
1515
@objc
1616
fileprivate func fixAlternate(_ newValue: NSEvent.ModifierFlags) {
17-
1817
if newValue.contains(.numericPad) {
1918
isAlternate = true
2019
fixAlternate(newValue.subtracting(.numericPad))
@@ -23,10 +22,7 @@ extension NSMenuItem {
2322
fixAlternate(newValue)
2423

2524
if self.title == "Open Recent" {
26-
let openRecentMenu = NSMenu(title: "Open Recent")
27-
openRecentMenu.perform(NSSelectorFromString("_setMenuName:"), with: "NSRecentDocumentsMenu")
28-
self.submenu = openRecentMenu
29-
NSDocumentController.shared.value(forKey: "_installOpenRecentMenus")
25+
self.submenu = FileCommands.recentProjectsMenu.makeMenu()
3026
}
3127

3228
if self.title == "OpenWindowAction" || self.title.isEmpty {

0 commit comments

Comments
 (0)