Skip to content

Commit 4a8ee2c

Browse files
atsrusatsrusthecoolwinter
authored
Separated files and folders in the "Open Recent" sub-menu (#2039)
* Issue #1061 Recents menu * Tidying comment * Merge Folders and Files Storage, Make Class * Update Menu And ListView * Add Tests * Fix Icon For Paths With Spaces, Spelling Error * Rename To `shared`, Test Inserted Order * Complete Test Coverage * Debug-only Init --------- Co-authored-by: atsrus <[email protected]> Co-authored-by: Khan Winter <[email protected]>
1 parent 76d4d01 commit 4a8ee2c

File tree

6 files changed

+207
-66
lines changed

6 files changed

+207
-66
lines changed

CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ final class CodeEditDocumentController: NSDocumentController {
7171
print("Unable to open document '\(url)': \(errorMessage)")
7272
}
7373

74-
RecentProjectsStore.documentOpened(at: url)
74+
RecentProjectsStore.shared.documentOpened(at: url)
7575
completionHandler(document, documentWasAlreadyOpen, error)
7676
}
7777
}

CodeEdit/Features/Welcome/Model/RecentProjectsStore.swift

Lines changed: 68 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,39 +7,66 @@
77

88
import AppKit
99
import CoreSpotlight
10+
import OSLog
1011

1112
/// Helper methods for managing the recent projects list and donating list items to CoreSpotlight.
1213
///
1314
/// Limits the number of remembered projects to 100 items.
1415
///
1516
/// If a UI element needs to listen to changes in this list, listen for the
1617
/// ``RecentProjectsStore/didUpdateNotification`` notification.
17-
enum RecentProjectsStore {
18-
private static let defaultsKey = "recentProjectPaths"
18+
class RecentProjectsStore {
19+
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "RecentProjectsStore")
20+
21+
/// The default projects store, uses the `UserDefaults.standard` storage location.
22+
static let shared = RecentProjectsStore()
23+
24+
private static let projectsdDefaultsKey = "recentProjectPaths"
1925
static let didUpdateNotification = Notification.Name("RecentProjectsStore.didUpdate")
2026

21-
static func recentProjectPaths() -> [String] {
22-
UserDefaults.standard.array(forKey: defaultsKey) as? [String] ?? []
27+
/// The storage location for recent projects
28+
let defaults: UserDefaults
29+
30+
#if DEBUG
31+
/// Create a new store with a `UserDefaults` storage location.
32+
init(defaults: UserDefaults = UserDefaults.standard) {
33+
self.defaults = defaults
34+
}
35+
#else
36+
/// Create a new store with a `UserDefaults` storage location.
37+
private init(defaults: UserDefaults = UserDefaults.standard) {
38+
self.defaults = defaults
2339
}
40+
#endif
2441

25-
static func recentProjectURLs() -> [URL] {
26-
recentProjectPaths().map { URL(filePath: $0) }
42+
/// Gets the recent paths array from `UserDefaults`.
43+
private func recentPaths() -> [String] {
44+
defaults.array(forKey: Self.projectsdDefaultsKey) as? [String] ?? []
2745
}
2846

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-
}
47+
/// Gets all recent paths from `UserDefaults` as an array of `URL`s. Includes both **projects** and
48+
/// **single files**.
49+
/// To filter for either projects or single files, use ``recentProjectURLs()`` or ``recentFileURLs``, respectively.
50+
func recentURLs() -> [URL] {
51+
recentPaths().map { URL(filePath: $0) }
52+
}
4053

41-
// Limit list to to 100 items after de-duplication
42-
UserDefaults.standard.setValue(Array(paths.prefix(100)), forKey: defaultsKey)
54+
/// Gets the recent **Project** `URL`s from `UserDefaults`.
55+
/// To get both single files and projects, use ``recentURLs()``.
56+
func recentProjectURLs() -> [URL] {
57+
recentURLs().filter { $0.isFolder }
58+
}
59+
60+
/// Gets the recent **Single File** `URL`s from `UserDefaults`.
61+
/// To get both single files and projects, use ``recentURLs()``.
62+
func recentFileURLs() -> [URL] {
63+
recentURLs().filter { !$0.isFolder }
64+
}
65+
66+
/// Save a new paths array to defaults. Automatically limits the list to the most recent `100` items, donates
67+
/// search items to Spotlight, and notifies observers.
68+
private func setPaths(_ paths: [String]) {
69+
defaults.setValue(Array(paths.prefix(100)), forKey: Self.projectsdDefaultsKey)
4370
setDocumentControllerRecents()
4471
donateSearchableItems()
4572
NotificationCenter.default.post(name: Self.didUpdateNotification, object: nil)
@@ -49,41 +76,45 @@ enum RecentProjectsStore {
4976
/// Moves the path to the front if it was in the list already, or prepends it.
5077
/// Saves the list to defaults when called.
5178
/// - 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)
79+
func documentOpened(at url: URL) {
80+
var projectURLs = recentURLs()
81+
82+
if let containedIndex = projectURLs.firstIndex(where: { $0.componentCompare(url) }) {
83+
projectURLs.move(fromOffsets: IndexSet(integer: containedIndex), toOffset: 0)
5684
} else {
57-
paths.insert(url, at: 0)
85+
projectURLs.insert(url, at: 0)
5886
}
59-
setPaths(paths.map { $0.path(percentEncoded: false) })
87+
88+
setPaths(projectURLs.map { $0.path(percentEncoded: false) })
6089
}
6190

62-
/// Remove all paths in the set.
91+
/// Remove all project paths in the set.
6392
/// - Parameter paths: The paths to remove.
6493
/// - Returns: The remaining urls in the recent projects list.
65-
static func removeRecentProjects(_ paths: Set<URL>) -> [URL] {
66-
var recentProjectPaths = recentProjectURLs()
94+
func removeRecentProjects(_ paths: Set<URL>) -> [URL] {
95+
let paths = Set(paths.map { $0.path(percentEncoded: false) })
96+
var recentProjectPaths = recentPaths()
6797
recentProjectPaths.removeAll(where: { paths.contains($0) })
68-
setPaths(recentProjectPaths.map { $0.path(percentEncoded: false) })
69-
return recentProjectURLs()
98+
setPaths(recentProjectPaths)
99+
return recentURLs()
70100
}
71101

72-
static func clearList() {
102+
func clearList() {
73103
setPaths([])
104+
NotificationCenter.default.post(name: Self.didUpdateNotification, object: nil)
74105
}
75106

76107
/// Syncs AppKit's recent documents list with ours, keeping the dock menu and other lists up-to-date.
77-
private static func setDocumentControllerRecents() {
108+
private func setDocumentControllerRecents() {
78109
CodeEditDocumentController.shared.clearRecentDocuments(nil)
79-
for path in recentProjectURLs().prefix(10) {
110+
for path in recentURLs().prefix(10) {
80111
CodeEditDocumentController.shared.noteNewRecentDocumentURL(path)
81112
}
82113
}
83114

84115
/// Donates all recent URLs to Core Search, making them searchable in Spotlight
85-
private static func donateSearchableItems() {
86-
let searchableItems = recentProjectURLs().map { entity in
116+
private func donateSearchableItems() {
117+
let searchableItems = recentURLs().map { entity in
87118
let attributeSet = CSSearchableItemAttributeSet(contentType: .content)
88119
attributeSet.title = entity.lastPathComponent
89120
attributeSet.relatedUniqueIdentifier = entity.path()
@@ -93,9 +124,9 @@ enum RecentProjectsStore {
93124
attributeSet: attributeSet
94125
)
95126
}
96-
CSSearchableIndex.default().indexSearchableItems(searchableItems) { error in
127+
CSSearchableIndex.default().indexSearchableItems(searchableItems) { [weak self] error in
97128
if let error = error {
98-
print(error)
129+
self?.logger.debug("Failed to donate recent projects, error: \(error, privacy: .auto)")
99130
}
100131
}
101132
}

CodeEdit/Features/Welcome/Views/RecentProjectsListView.swift

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +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-
self._recentProjects = .init(initialValue: RecentProjectsStore.recentProjectURLs())
23-
self._selection = .init(initialValue: Set(RecentProjectsStore.recentProjectURLs().prefix(1)))
22+
self._recentProjects = .init(initialValue: RecentProjectsStore.shared.recentURLs())
23+
self._selection = .init(initialValue: Set(RecentProjectsStore.shared.recentURLs().prefix(1)))
2424
}
2525

2626
var listEmptyView: some View {
@@ -81,16 +81,20 @@ struct RecentProjectsListView: View {
8181
}
8282
}
8383
}
84-
.onReceive(NotificationCenter.default.publisher(for: RecentProjectsStore.didUpdateNotification)) { _ in
84+
.onReceive(
85+
NotificationCenter
86+
.default
87+
.publisher(for: RecentProjectsStore.didUpdateNotification).receive(on: RunLoop.main)
88+
) { _ in
8589
updateRecentProjects()
8690
}
8791
}
8892

8993
func removeRecentProjects() {
90-
recentProjects = RecentProjectsStore.removeRecentProjects(selection)
94+
recentProjects = RecentProjectsStore.shared.removeRecentProjects(selection)
9195
}
9296

9397
func updateRecentProjects() {
94-
recentProjects = RecentProjectsStore.recentProjectURLs()
98+
recentProjects = RecentProjectsStore.shared.recentURLs()
9599
}
96100
}

CodeEdit/Features/WindowCommands/Utils/RecentProjectsMenu.swift

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,27 +8,48 @@
88
import AppKit
99

1010
class RecentProjectsMenu: NSObject {
11+
let projectsStore: RecentProjectsStore
12+
13+
init(projectsStore: RecentProjectsStore = .shared) {
14+
self.projectsStore = projectsStore
15+
}
16+
1117
func makeMenu() -> NSMenu {
1218
let menu = NSMenu(title: NSLocalizedString("Open Recent", comment: "Open Recent menu title"))
1319

14-
let paths = RecentProjectsStore.recentProjectURLs().prefix(10)
20+
addFileURLs(to: menu, fileURLs: projectsStore.recentProjectURLs().prefix(10))
21+
menu.addItem(NSMenuItem.separator())
22+
addFileURLs(to: menu, fileURLs: projectsStore.recentFileURLs().prefix(10))
23+
menu.addItem(NSMenuItem.separator())
24+
25+
let clearMenuItem = NSMenuItem(
26+
title: NSLocalizedString("Clear Menu", comment: "Recent project menu clear button"),
27+
action: #selector(clearMenuItemClicked(_:)),
28+
keyEquivalent: ""
29+
)
30+
clearMenuItem.target = self
31+
menu.addItem(clearMenuItem)
32+
33+
return menu
34+
}
1535

16-
for projectPath in paths {
17-
let icon = NSWorkspace.shared.icon(forFile: projectPath.path())
36+
private func addFileURLs(to menu: NSMenu, fileURLs: ArraySlice<URL>) {
37+
for url in fileURLs {
38+
let icon = NSWorkspace.shared.icon(forFile: url.path(percentEncoded: false))
1839
icon.size = NSSize(width: 16, height: 16)
19-
let alternateTitle = alternateTitle(for: projectPath)
40+
let alternateTitle = alternateTitle(for: url)
2041

2142
let primaryItem = NSMenuItem(
22-
title: projectPath.lastPathComponent,
43+
title: url.lastPathComponent,
2344
action: #selector(recentProjectItemClicked(_:)),
2445
keyEquivalent: ""
2546
)
2647
primaryItem.target = self
2748
primaryItem.image = icon
28-
primaryItem.representedObject = projectPath
49+
primaryItem.representedObject = url
2950

30-
let containsDuplicate = paths.contains { url in
31-
url != projectPath && url.lastPathComponent == projectPath.lastPathComponent
51+
let containsDuplicate = fileURLs.contains { otherURL in
52+
url != otherURL && url.lastPathComponent == otherURL.lastPathComponent
3253
}
3354

3455
// If there's a duplicate, add the path.
@@ -44,25 +65,13 @@ class RecentProjectsMenu: NSObject {
4465
alternateItem.attributedTitle = alternateTitle
4566
alternateItem.target = self
4667
alternateItem.image = icon
47-
alternateItem.representedObject = projectPath
68+
alternateItem.representedObject = url
4869
alternateItem.isAlternate = true
4970
alternateItem.keyEquivalentModifierMask = [.option]
5071

5172
menu.addItem(primaryItem)
5273
menu.addItem(alternateItem)
5374
}
54-
55-
menu.addItem(NSMenuItem.separator())
56-
57-
let clearMenuItem = NSMenuItem(
58-
title: NSLocalizedString("Clear Menu", comment: "Recent project menu clear button"),
59-
action: #selector(clearMenuItemClicked(_:)),
60-
keyEquivalent: ""
61-
)
62-
clearMenuItem.target = self
63-
menu.addItem(clearMenuItem)
64-
65-
return menu
6675
}
6776

6877
private func alternateTitle(for projectPath: URL) -> NSAttributedString {
@@ -94,6 +103,6 @@ class RecentProjectsMenu: NSObject {
94103

95104
@objc
96105
func clearMenuItemClicked(_ sender: NSMenuItem) {
97-
RecentProjectsStore.clearList()
106+
projectsStore.clearList()
98107
}
99108
}

CodeEditTestPlan.xctestplan

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
}
1010
],
1111
"defaultOptions" : {
12-
"codeCoverage" : false,
1312
"targetForVariableExpansion" : {
1413
"containerPath" : "container:CodeEdit.xcodeproj",
1514
"identifier" : "B658FB2B27DA9E0F00EA4DBD",

0 commit comments

Comments
 (0)