Skip to content

Separated files and folders in the "Open Recent" sub-menu #2039

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
May 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ final class CodeEditDocumentController: NSDocumentController {
print("Unable to open document '\(url)': \(errorMessage)")
}

RecentProjectsStore.documentOpened(at: url)
RecentProjectsStore.shared.documentOpened(at: url)
completionHandler(document, documentWasAlreadyOpen, error)
}
}
Expand Down
105 changes: 68 additions & 37 deletions CodeEdit/Features/Welcome/Model/RecentProjectsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,66 @@

import AppKit
import CoreSpotlight
import OSLog

/// 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"
class RecentProjectsStore {
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "RecentProjectsStore")

/// The default projects store, uses the `UserDefaults.standard` storage location.
static let shared = RecentProjectsStore()

private static let projectsdDefaultsKey = "recentProjectPaths"
static let didUpdateNotification = Notification.Name("RecentProjectsStore.didUpdate")

static func recentProjectPaths() -> [String] {
UserDefaults.standard.array(forKey: defaultsKey) as? [String] ?? []
/// The storage location for recent projects
let defaults: UserDefaults

#if DEBUG
/// Create a new store with a `UserDefaults` storage location.
init(defaults: UserDefaults = UserDefaults.standard) {
self.defaults = defaults
}
#else
/// Create a new store with a `UserDefaults` storage location.
private init(defaults: UserDefaults = UserDefaults.standard) {
self.defaults = defaults
}
#endif

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

private static func setPaths(_ paths: [String]) {
var paths = paths
// Remove duplicates
var foundPaths = Set<String>()
for (idx, path) in paths.enumerated().reversed() {
if foundPaths.contains(path) {
paths.remove(at: idx)
} else {
foundPaths.insert(path)
}
}
/// Gets all recent paths from `UserDefaults` as an array of `URL`s. Includes both **projects** and
/// **single files**.
/// To filter for either projects or single files, use ``recentProjectURLs()`` or ``recentFileURLs``, respectively.
func recentURLs() -> [URL] {
recentPaths().map { URL(filePath: $0) }
}

// Limit list to to 100 items after de-duplication
UserDefaults.standard.setValue(Array(paths.prefix(100)), forKey: defaultsKey)
/// Gets the recent **Project** `URL`s from `UserDefaults`.
/// To get both single files and projects, use ``recentURLs()``.
func recentProjectURLs() -> [URL] {
recentURLs().filter { $0.isFolder }
}

/// Gets the recent **Single File** `URL`s from `UserDefaults`.
/// To get both single files and projects, use ``recentURLs()``.
func recentFileURLs() -> [URL] {
recentURLs().filter { !$0.isFolder }
}

/// Save a new paths array to defaults. Automatically limits the list to the most recent `100` items, donates
/// search items to Spotlight, and notifies observers.
private func setPaths(_ paths: [String]) {
defaults.setValue(Array(paths.prefix(100)), forKey: Self.projectsdDefaultsKey)
setDocumentControllerRecents()
donateSearchableItems()
NotificationCenter.default.post(name: Self.didUpdateNotification, object: nil)
Expand All @@ -49,41 +76,45 @@ enum RecentProjectsStore {
/// 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)
func documentOpened(at url: URL) {
var projectURLs = recentURLs()

if let containedIndex = projectURLs.firstIndex(where: { $0.componentCompare(url) }) {
projectURLs.move(fromOffsets: IndexSet(integer: containedIndex), toOffset: 0)
} else {
paths.insert(url, at: 0)
projectURLs.insert(url, at: 0)
}
setPaths(paths.map { $0.path(percentEncoded: false) })

setPaths(projectURLs.map { $0.path(percentEncoded: false) })
}

/// Remove all paths in the set.
/// Remove all project 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>) -> [URL] {
var recentProjectPaths = recentProjectURLs()
func removeRecentProjects(_ paths: Set<URL>) -> [URL] {
let paths = Set(paths.map { $0.path(percentEncoded: false) })
var recentProjectPaths = recentPaths()
recentProjectPaths.removeAll(where: { paths.contains($0) })
setPaths(recentProjectPaths.map { $0.path(percentEncoded: false) })
return recentProjectURLs()
setPaths(recentProjectPaths)
return recentURLs()
}

static func clearList() {
func clearList() {
setPaths([])
NotificationCenter.default.post(name: Self.didUpdateNotification, object: nil)
}

/// Syncs AppKit's recent documents list with ours, keeping the dock menu and other lists up-to-date.
private static func setDocumentControllerRecents() {
private func setDocumentControllerRecents() {
CodeEditDocumentController.shared.clearRecentDocuments(nil)
for path in recentProjectURLs().prefix(10) {
for path in recentURLs().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
private func donateSearchableItems() {
let searchableItems = recentURLs().map { entity in
let attributeSet = CSSearchableItemAttributeSet(contentType: .content)
attributeSet.title = entity.lastPathComponent
attributeSet.relatedUniqueIdentifier = entity.path()
Expand All @@ -93,9 +124,9 @@ enum RecentProjectsStore {
attributeSet: attributeSet
)
}
CSSearchableIndex.default().indexSearchableItems(searchableItems) { error in
CSSearchableIndex.default().indexSearchableItems(searchableItems) { [weak self] error in
if let error = error {
print(error)
self?.logger.debug("Failed to donate recent projects, error: \(error, privacy: .auto)")
}
}
}
Expand Down
14 changes: 9 additions & 5 deletions CodeEdit/Features/Welcome/Views/RecentProjectsListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ struct RecentProjectsListView: View {
init(openDocument: @escaping (URL?, @escaping () -> Void) -> Void, dismissWindow: @escaping () -> Void) {
self.openDocument = openDocument
self.dismissWindow = dismissWindow
self._recentProjects = .init(initialValue: RecentProjectsStore.recentProjectURLs())
self._selection = .init(initialValue: Set(RecentProjectsStore.recentProjectURLs().prefix(1)))
self._recentProjects = .init(initialValue: RecentProjectsStore.shared.recentURLs())
self._selection = .init(initialValue: Set(RecentProjectsStore.shared.recentURLs().prefix(1)))
}

var listEmptyView: some View {
Expand Down Expand Up @@ -81,16 +81,20 @@ struct RecentProjectsListView: View {
}
}
}
.onReceive(NotificationCenter.default.publisher(for: RecentProjectsStore.didUpdateNotification)) { _ in
.onReceive(
NotificationCenter
.default
.publisher(for: RecentProjectsStore.didUpdateNotification).receive(on: RunLoop.main)
) { _ in
updateRecentProjects()
}
}

func removeRecentProjects() {
recentProjects = RecentProjectsStore.removeRecentProjects(selection)
recentProjects = RecentProjectsStore.shared.removeRecentProjects(selection)
}

func updateRecentProjects() {
recentProjects = RecentProjectsStore.recentProjectURLs()
recentProjects = RecentProjectsStore.shared.recentURLs()
}
}
53 changes: 31 additions & 22 deletions CodeEdit/Features/WindowCommands/Utils/RecentProjectsMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,48 @@
import AppKit

class RecentProjectsMenu: NSObject {
let projectsStore: RecentProjectsStore

init(projectsStore: RecentProjectsStore = .shared) {
self.projectsStore = projectsStore
}

func makeMenu() -> NSMenu {
let menu = NSMenu(title: NSLocalizedString("Open Recent", comment: "Open Recent menu title"))

let paths = RecentProjectsStore.recentProjectURLs().prefix(10)
addFileURLs(to: menu, fileURLs: projectsStore.recentProjectURLs().prefix(10))
menu.addItem(NSMenuItem.separator())
addFileURLs(to: menu, fileURLs: projectsStore.recentFileURLs().prefix(10))
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
}

for projectPath in paths {
let icon = NSWorkspace.shared.icon(forFile: projectPath.path())
private func addFileURLs(to menu: NSMenu, fileURLs: ArraySlice<URL>) {
for url in fileURLs {
let icon = NSWorkspace.shared.icon(forFile: url.path(percentEncoded: false))
icon.size = NSSize(width: 16, height: 16)
let alternateTitle = alternateTitle(for: projectPath)
let alternateTitle = alternateTitle(for: url)

let primaryItem = NSMenuItem(
title: projectPath.lastPathComponent,
title: url.lastPathComponent,
action: #selector(recentProjectItemClicked(_:)),
keyEquivalent: ""
)
primaryItem.target = self
primaryItem.image = icon
primaryItem.representedObject = projectPath
primaryItem.representedObject = url

let containsDuplicate = paths.contains { url in
url != projectPath && url.lastPathComponent == projectPath.lastPathComponent
let containsDuplicate = fileURLs.contains { otherURL in
url != otherURL && url.lastPathComponent == otherURL.lastPathComponent
}

// If there's a duplicate, add the path.
Expand All @@ -44,25 +65,13 @@ class RecentProjectsMenu: NSObject {
alternateItem.attributedTitle = alternateTitle
alternateItem.target = self
alternateItem.image = icon
alternateItem.representedObject = projectPath
alternateItem.representedObject = url
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 {
Expand Down Expand Up @@ -94,6 +103,6 @@ class RecentProjectsMenu: NSObject {

@objc
func clearMenuItemClicked(_ sender: NSMenuItem) {
RecentProjectsStore.clearList()
projectsStore.clearList()
}
}
1 change: 0 additions & 1 deletion CodeEditTestPlan.xctestplan
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
}
],
"defaultOptions" : {
"codeCoverage" : false,
"targetForVariableExpansion" : {
"containerPath" : "container:CodeEdit.xcodeproj",
"identifier" : "B658FB2B27DA9E0F00EA4DBD",
Expand Down
Loading