Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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 @@ -68,7 +68,7 @@ final class CodeEditDocumentController: NSDocumentController {
print("Unable to open document '\(url)': \(errorMessage)")
}

RecentProjectsStore.documentOpened(at: url)
RecentProjectsStore.default.documentOpened(at: url)
completionHandler(document, documentWasAlreadyOpen, error)
}
}
Expand Down
71 changes: 52 additions & 19 deletions CodeEdit/Features/Welcome/Model/RecentProjectsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,50 @@ import CoreSpotlight
///
/// 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 {
/// The default projects store, uses the `UserDefaults.standard` storage location.
static let `default` = 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

/// Create a new store with a `UserDefaults` storage location.
init(defaults: UserDefaults = UserDefaults.standard) {
self.defaults = defaults
}

/// Gets the recent paths array from `UserDefaults`.
private func recentPaths() -> [String] {
defaults.array(forKey: Self.projectsdDefaultsKey) as? [String] ?? []
}

/// 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) }
}

static func recentProjectURLs() -> [URL] {
recentProjectPaths().map { URL(filePath: $0) }
/// Gets the recent **Project** `URL`s from `UserDefaults`.
/// To get both single files and projects, use ``recentURLs()``.
func recentProjectURLs() -> [URL] {
recentURLs().filter { $0.isFolder }
}

private static func setPaths(_ paths: [String]) {
/// 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]) {
var paths = paths

// Remove duplicates
var foundPaths = Set<String>()
for (idx, path) in paths.enumerated().reversed() {
Expand All @@ -39,7 +69,7 @@ enum RecentProjectsStore {
}

// Limit list to to 100 items after de-duplication
UserDefaults.standard.setValue(Array(paths.prefix(100)), forKey: defaultsKey)
defaults.setValue(Array(paths.prefix(100)), forKey: Self.projectsdDefaultsKey)
setDocumentControllerRecents()
donateSearchableItems()
NotificationCenter.default.post(name: Self.didUpdateNotification, object: nil)
Expand All @@ -49,40 +79,43 @@ 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] {
func removeRecentProjects(_ paths: Set<URL>) -> [URL] {
var recentProjectPaths = recentProjectURLs()
recentProjectPaths.removeAll(where: { paths.contains($0) })
setPaths(recentProjectPaths.map { $0.path(percentEncoded: false) })
return recentProjectURLs()
}

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) {
CodeEditDocumentController.shared.noteNewRecentDocumentURL(path)
}
}

/// Donates all recent URLs to Core Search, making them searchable in Spotlight
private static func donateSearchableItems() {
private func donateSearchableItems() {
let searchableItems = recentProjectURLs().map { entity in
let attributeSet = CSSearchableItemAttributeSet(contentType: .content)
attributeSet.title = entity.lastPathComponent
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.default.recentURLs())
self._selection = .init(initialValue: Set(RecentProjectsStore.default.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.default.removeRecentProjects(selection)
}

func updateRecentProjects() {
recentProjects = RecentProjectsStore.recentProjectURLs()
recentProjects = RecentProjectsStore.default.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 = .default) {
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()
}
}
50 changes: 50 additions & 0 deletions CodeEditTests/Features/Welcome/RecentProjectsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//
// RecentProjectsTests.swift
// CodeEditTests
//
// Created by Khan Winter on 5/27/25.
//

import Testing
import Foundation
@testable import CodeEdit

class RecentProjectsTests {
let store: RecentProjectsStore

init() {
let defaults = UserDefaults(suiteName: #file)!
store = RecentProjectsStore(defaults: defaults)
}

deinit {
try? FileManager.default.removeItem(atPath: #file + ".plist")
}

@Test
func newStoreEmpty() {
#expect(store.recentURLs().isEmpty)
}

@Test
func savesURLs() {
store.documentOpened(at: URL(filePath: "Directory/", directoryHint: .isDirectory))
store.documentOpened(at: URL(filePath: "Directory/file.txt", directoryHint: .notDirectory))

#expect(store.recentURLs().count == 2)
}

@Test
func maxesOutAt100Items() {
for idx in 0..<200 {
store.documentOpened(
at: URL(
filePath: "file\(idx).txt",
directoryHint: Bool.random() ? .isDirectory : .notDirectory
)
)
}

#expect(store.recentURLs().count == 100)
}
}
Loading