Add pinned recent files workflows

This commit is contained in:
h3p 2026-03-15 15:56:58 +01:00
parent 776788b2ea
commit c176c411f8
8 changed files with 384 additions and 34 deletions

View file

@ -361,7 +361,7 @@
CODE_SIGNING_ALLOWED = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 517;
CURRENT_PROJECT_VERSION = 518;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_APP_SANDBOX = YES;
@ -444,7 +444,7 @@
CODE_SIGNING_ALLOWED = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 517;
CURRENT_PROJECT_VERSION = 518;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_APP_SANDBOX = YES;

View file

@ -15,6 +15,8 @@ struct NeonVisionMacAppCommands: Commands {
let openAIDiagnosticsWindow: () -> Void
let postWindowCommand: (_ name: Notification.Name, _ object: Any?) -> Void
let isUpdaterEnabled: Bool
let recentFilesProvider: () -> [RecentFilesStore.Item]
let clearRecentFiles: () -> Void
@Binding var useAppleIntelligence: Bool
@Binding var appleAIStatus: String
@ -60,6 +62,10 @@ struct NeonVisionMacAppCommands: Commands {
postWindowCommand(name, object)
}
private var recentFiles: [RecentFilesStore.Item] {
recentFilesProvider()
}
@CommandsBuilder
private var appSettingsCommands: some Commands {
CommandGroup(before: .appSettings) {
@ -98,6 +104,31 @@ struct NeonVisionMacAppCommands: Commands {
.keyboardShortcut("o", modifiers: [.command, .shift])
}
CommandMenu("Open Recent") {
if recentFiles.isEmpty {
Button("No Recent Files") {}
.disabled(true)
} else {
ForEach(Array(recentFiles.prefix(10))) { item in
Button {
post(.openRecentFileRequested, object: item.url)
} label: {
if item.isPinned {
Label(item.title, systemImage: "star.fill")
} else {
Text(item.title)
}
}
}
Divider()
Button("Clear Unpinned Recents") {
clearRecentFiles()
}
}
}
CommandGroup(replacing: .saveItem) {
Button("Save") {
let current = activeEditorViewModel()

View file

@ -393,6 +393,8 @@ struct NeonVisionEditorApp: App {
postWindowCommand(name, object: object)
},
isUpdaterEnabled: ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution,
recentFilesProvider: { RecentFilesStore.items(limit: 10) },
clearRecentFiles: { RecentFilesStore.clearUnpinned() },
useAppleIntelligence: $useAppleIntelligence,
appleAIStatus: $appleAIStatus,
appleAIRoundTripMS: $appleAIRoundTripMS,

View file

@ -0,0 +1,96 @@
import Foundation
struct RecentFilesStore {
struct Item: Identifiable, Equatable {
let url: URL
let isPinned: Bool
var id: String { url.standardizedFileURL.path }
var title: String { url.lastPathComponent }
var subtitle: String { url.standardizedFileURL.path }
}
private static let recentPathsKey = "RecentFilesPathsV1"
private static let pinnedPathsKey = "PinnedRecentFilesPathsV1"
private static let maximumItemCount = 30
static func items(limit: Int = maximumItemCount) -> [Item] {
let defaults = UserDefaults.standard
let recentPaths = sanitizedPaths(from: defaults.stringArray(forKey: recentPathsKey) ?? [])
let pinnedPaths = sanitizedPaths(from: defaults.stringArray(forKey: pinnedPathsKey) ?? [])
let pinnedSet = Set(pinnedPaths)
let orderedPaths = pinnedPaths + recentPaths.filter { !pinnedSet.contains($0) }
let urls = orderedPaths.prefix(limit).map { URL(fileURLWithPath: $0) }
return urls.map { Item(url: $0, isPinned: pinnedSet.contains($0.standardizedFileURL.path)) }
}
static func remember(_ url: URL) {
let standardizedPath = url.standardizedFileURL.path
let defaults = UserDefaults.standard
var recentPaths = sanitizedPaths(from: defaults.stringArray(forKey: recentPathsKey) ?? [])
recentPaths.removeAll { $0 == standardizedPath }
recentPaths.insert(standardizedPath, at: 0)
let pinnedPaths = sanitizedPaths(from: defaults.stringArray(forKey: pinnedPathsKey) ?? [])
let pinnedSet = Set(pinnedPaths)
let retainedUnpinned = recentPaths.filter { !pinnedSet.contains($0) }
let availableUnpinnedSlots = max(0, maximumItemCount - pinnedPaths.count)
let trimmedRecent = Array(retainedUnpinned.prefix(availableUnpinnedSlots))
defaults.set(trimmedRecent, forKey: recentPathsKey)
defaults.set(pinnedPaths, forKey: pinnedPathsKey)
postDidChange()
}
static func togglePinned(_ url: URL) {
let standardizedPath = url.standardizedFileURL.path
let defaults = UserDefaults.standard
var pinnedPaths = sanitizedPaths(from: defaults.stringArray(forKey: pinnedPathsKey) ?? [])
var recentPaths = sanitizedPaths(from: defaults.stringArray(forKey: recentPathsKey) ?? [])
if let existingIndex = pinnedPaths.firstIndex(of: standardizedPath) {
pinnedPaths.remove(at: existingIndex)
recentPaths.removeAll { $0 == standardizedPath }
recentPaths.insert(standardizedPath, at: 0)
} else {
pinnedPaths.removeAll { $0 == standardizedPath }
pinnedPaths.insert(standardizedPath, at: 0)
pinnedPaths = Array(pinnedPaths.prefix(maximumItemCount))
recentPaths.removeAll { $0 == standardizedPath }
}
let pinnedSet = Set(pinnedPaths)
let availableUnpinnedSlots = max(0, maximumItemCount - pinnedPaths.count)
recentPaths = Array(recentPaths.filter { !pinnedSet.contains($0) }.prefix(availableUnpinnedSlots))
defaults.set(recentPaths, forKey: recentPathsKey)
defaults.set(pinnedPaths, forKey: pinnedPathsKey)
postDidChange()
}
static func clearUnpinned() {
let defaults = UserDefaults.standard
defaults.removeObject(forKey: recentPathsKey)
postDidChange()
}
private static func sanitizedPaths(from rawPaths: [String]) -> [String] {
var seen: Set<String> = []
return rawPaths.compactMap { rawPath in
let path = URL(fileURLWithPath: rawPath).standardizedFileURL.path
guard !path.isEmpty else { return nil }
guard FileManager.default.fileExists(atPath: path) else { return nil }
guard !seen.contains(path) else { return nil }
seen.insert(path)
return path
}
}
private static func postDidChange() {
guard NSClassFromString("XCTestCase") == nil else { return }
DispatchQueue.main.async {
NotificationCenter.default.post(name: .recentFilesDidChange, object: nil)
}
}
}

View file

@ -1461,6 +1461,9 @@ class EditorViewModel {
isLargeCandidate: result.isLargeCandidate
)
)
if let fileURL = tabs.first(where: { $0.id == tabID })?.fileURL {
RecentFilesStore.remember(fileURL)
}
EditorPerformanceMonitor.shared.endFileOpen(
tabID: tabID,
success: true,

View file

@ -290,6 +290,7 @@ struct ContentView: View {
@State var quickSwitcherQuery: String = ""
@State var quickSwitcherProjectFileURLs: [URL] = []
@State private var quickSwitcherRecentItemIDs: [String] = []
@State private var recentFilesRefreshToken: UUID = UUID()
@State var showFindInFiles: Bool = false
@State var findInFilesQuery: String = ""
@State var findInFilesCaseSensitive: Bool = false
@ -1806,7 +1807,7 @@ struct ContentView: View {
}
}
let viewWithPanels = viewWithEditorActions
let viewWithPanelTriggers = viewWithEditorActions
.onReceive(NotificationCenter.default.publisher(for: .showFindReplaceRequested)) { notif in
guard matchesCurrentWindow(notif) else { return }
showFindReplace = true
@ -1820,6 +1821,14 @@ struct ContentView: View {
quickSwitcherQuery = ""
showQuickSwitcher = true
}
.onReceive(NotificationCenter.default.publisher(for: .openRecentFileRequested)) { notif in
guard matchesCurrentWindow(notif) else { return }
guard let url = notif.object as? URL else { return }
_ = viewModel.openFile(url: url)
}
.onReceive(NotificationCenter.default.publisher(for: .recentFilesDidChange)) { _ in
recentFilesRefreshToken = UUID()
}
.onReceive(NotificationCenter.default.publisher(for: .addNextMatchRequested)) { notif in
guard matchesCurrentWindow(notif) else { return }
addNextMatchSelection()
@ -1843,10 +1852,12 @@ struct ContentView: View {
guard matchesCurrentWindow(notif) else { return }
showSupportPromptSheet = true
}
.onReceive(NotificationCenter.default.publisher(for: .toggleProjectStructureSidebarRequested)) { notif in
guard matchesCurrentWindow(notif) else { return }
toggleProjectSidebarFromToolbar()
}
let viewWithPanels = viewWithPanelTriggers
.onReceive(NotificationCenter.default.publisher(for: .toggleProjectStructureSidebarRequested)) { notif in
guard matchesCurrentWindow(notif) else { return }
toggleProjectSidebarFromToolbar()
}
.onReceive(NotificationCenter.default.publisher(for: .openProjectFolderRequested)) { notif in
guard matchesCurrentWindow(notif) else { return }
openProjectFolder()
@ -2329,7 +2340,8 @@ struct ContentView: View {
QuickFileSwitcherPanel(
query: contentView.$quickSwitcherQuery,
items: contentView.quickSwitcherItems,
onSelect: { contentView.selectQuickSwitcherItem($0) }
onSelect: { contentView.selectQuickSwitcherItem($0) },
onTogglePin: { contentView.toggleQuickSwitcherPin($0) }
)
}
.sheet(isPresented: contentView.$showFindInFiles) {
@ -3996,6 +4008,11 @@ struct ContentView: View {
}
)
.id(currentLanguage)
.overlay {
if shouldShowStartupRecentFilesCard {
startupRecentFilesCard
}
}
}
}
.frame(maxWidth: brainDumpLayoutEnabled ? 920 : .infinity)
@ -4979,16 +4996,17 @@ struct ContentView: View {
}
private var quickSwitcherItems: [QuickFileSwitcherPanel.Item] {
_ = recentFilesRefreshToken
var items: [QuickFileSwitcherPanel.Item] = []
let fileURLSet = Set(viewModel.tabs.compactMap { $0.fileURL?.standardizedFileURL.path })
let commandItems: [QuickFileSwitcherPanel.Item] = [
.init(id: "cmd:new_tab", title: "New Tab", subtitle: "Create a new empty tab"),
.init(id: "cmd:open_file", title: "Open File", subtitle: "Open files from disk"),
.init(id: "cmd:save_file", title: "Save", subtitle: "Save current tab"),
.init(id: "cmd:save_as", title: "Save As", subtitle: "Save current tab to a new file"),
.init(id: "cmd:find_replace", title: "Find and Replace", subtitle: "Search and replace in current document"),
.init(id: "cmd:find_in_files", title: "Find in Files", subtitle: "Search across project files"),
.init(id: "cmd:toggle_sidebar", title: "Toggle Sidebar", subtitle: "Show or hide the outline sidebar")
.init(id: "cmd:new_tab", title: "New Tab", subtitle: "Create a new empty tab", isPinned: false, canTogglePin: false),
.init(id: "cmd:open_file", title: "Open File", subtitle: "Open files from disk", isPinned: false, canTogglePin: false),
.init(id: "cmd:save_file", title: "Save", subtitle: "Save current tab", isPinned: false, canTogglePin: false),
.init(id: "cmd:save_as", title: "Save As", subtitle: "Save current tab to a new file", isPinned: false, canTogglePin: false),
.init(id: "cmd:find_replace", title: "Find and Replace", subtitle: "Search and replace in current document", isPinned: false, canTogglePin: false),
.init(id: "cmd:find_in_files", title: "Find in Files", subtitle: "Search across project files", isPinned: false, canTogglePin: false),
.init(id: "cmd:toggle_sidebar", title: "Toggle Sidebar", subtitle: "Show or hide the outline sidebar", isPinned: false, canTogglePin: false)
]
items.append(contentsOf: commandItems)
@ -4998,7 +5016,23 @@ struct ContentView: View {
QuickFileSwitcherPanel.Item(
id: "tab:\(tab.id.uuidString)",
title: tab.name,
subtitle: subtitle
subtitle: subtitle,
isPinned: false,
canTogglePin: false
)
)
}
for recent in RecentFilesStore.items(limit: 12) {
let standardized = recent.url.standardizedFileURL.path
if fileURLSet.contains(standardized) { continue }
items.append(
QuickFileSwitcherPanel.Item(
id: "file:\(standardized)",
title: recent.title,
subtitle: recent.subtitle,
isPinned: recent.isPinned,
canTogglePin: true
)
)
}
@ -5006,11 +5040,14 @@ struct ContentView: View {
for url in quickSwitcherProjectFileURLs {
let standardized = url.standardizedFileURL.path
if fileURLSet.contains(standardized) { continue }
if items.contains(where: { $0.id == "file:\(standardized)" }) { continue }
items.append(
QuickFileSwitcherPanel.Item(
id: "file:\(standardized)",
title: url.lastPathComponent,
subtitle: standardized
subtitle: standardized,
isPinned: false,
canTogglePin: true
)
)
}
@ -5019,14 +5056,22 @@ struct ContentView: View {
if query.isEmpty {
return Array(
items
.sorted { quickSwitcherRecencyScore(for: $0.id) > quickSwitcherRecencyScore(for: $1.id) }
.sorted {
let leftPinned = $0.isPinned ? 1 : 0
let rightPinned = $1.isPinned ? 1 : 0
if leftPinned != rightPinned {
return leftPinned > rightPinned
}
return quickSwitcherRecencyScore(for: $0.id) > quickSwitcherRecencyScore(for: $1.id)
}
.prefix(300)
)
}
let ranked = items.compactMap { item -> (QuickFileSwitcherPanel.Item, Int)? in
guard let score = quickSwitcherMatchScore(for: item, query: query) else { return nil }
return (item, score + quickSwitcherRecencyScore(for: item.id))
let pinBoost = item.isPinned ? 400 : 0
return (item, score + quickSwitcherRecencyScore(for: item.id) + pinBoost)
}
.sorted {
if $0.1 == $1.1 {
@ -5057,6 +5102,13 @@ struct ContentView: View {
}
}
private func toggleQuickSwitcherPin(_ item: QuickFileSwitcherPanel.Item) {
guard item.canTogglePin, item.id.hasPrefix("file:") else { return }
let path = String(item.id.dropFirst(5))
RecentFilesStore.togglePinned(URL(fileURLWithPath: path))
recentFilesRefreshToken = UUID()
}
private func performQuickSwitcherCommand(_ commandID: String) {
switch commandID {
case "cmd:new_tab":
@ -5087,6 +5139,82 @@ struct ContentView: View {
UserDefaults.standard.set(quickSwitcherRecentItemIDs, forKey: quickSwitcherRecentsDefaultsKey)
}
private var startupRecentFiles: [RecentFilesStore.Item] {
_ = recentFilesRefreshToken
return RecentFilesStore.items(limit: 5)
}
private var shouldShowStartupRecentFilesCard: Bool {
guard !brainDumpLayoutEnabled else { return false }
guard viewModel.tabs.count == 1 else { return false }
guard let tab = viewModel.selectedTab else { return false }
guard !tab.isLoadingContent else { return false }
guard tab.fileURL == nil else { return false }
guard tab.content.isEmpty else { return false }
return !startupRecentFiles.isEmpty
}
private var startupRecentFilesCard: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Recent Files")
.font(.headline)
ForEach(startupRecentFiles) { item in
HStack(spacing: 12) {
Button {
_ = viewModel.openFile(url: item.url)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.lineLimit(1)
Text(item.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
Button {
RecentFilesStore.togglePinned(item.url)
} label: {
Image(systemName: item.isPinned ? "star.fill" : "star")
.foregroundStyle(item.isPinned ? Color.yellow : .secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(item.isPinned ? "Unpin recent file" : "Pin recent file")
.accessibilityHint("Keeps this file near the top of recent files")
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.thinMaterial)
)
}
Button("Open File…") {
openFileFromToolbar()
}
.font(.subheadline.weight(.semibold))
}
.padding(20)
.frame(maxWidth: 520)
.background(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(.regularMaterial)
)
.overlay(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
)
.shadow(color: Color.black.opacity(0.08), radius: 16, x: 0, y: 6)
.padding(24)
.accessibilityElement(children: .contain)
.accessibilityLabel("Recent files")
}
private func quickSwitcherRecencyScore(for itemID: String) -> Int {
guard let index = quickSwitcherRecentItemIDs.firstIndex(of: itemID) else { return 0 }
return max(0, 120 - (index * 5))

View file

@ -166,11 +166,14 @@ struct QuickFileSwitcherPanel: View {
let id: String
let title: String
let subtitle: String
let isPinned: Bool
let canTogglePin: Bool
}
@Binding var query: String
let items: [Item]
let onSelect: (Item) -> Void
let onTogglePin: (Item) -> Void
@Environment(\.dismiss) private var dismiss
@FocusState private var queryFieldFocused: Bool
@ -185,23 +188,38 @@ struct QuickFileSwitcherPanel: View {
.focused($queryFieldFocused)
List(items) { item in
Button {
onSelect(item)
dismiss()
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.lineLimit(1)
Text(item.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
HStack(spacing: 10) {
Button {
onSelect(item)
dismiss()
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.lineLimit(1)
Text(item.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.accessibilityLabel(item.title)
.accessibilityValue(item.subtitle)
.accessibilityHint("Opens the selected item")
if item.canTogglePin {
Button {
onTogglePin(item)
} label: {
Image(systemName: item.isPinned ? "star.fill" : "star")
.foregroundStyle(item.isPinned ? Color.yellow : .secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(item.isPinned ? "Unpin recent file" : "Pin recent file")
.accessibilityHint("Keeps this file near the top of recent results")
}
}
.buttonStyle(.plain)
.accessibilityLabel(item.title)
.accessibilityValue(item.subtitle)
.accessibilityHint("Opens the selected item")
}
.listStyle(.plain)
.accessibilityLabel("Command Palette Results")
@ -971,6 +989,8 @@ extension Notification.Name {
static let showUpdaterRequested = Notification.Name("showUpdaterRequested")
static let showSettingsRequested = Notification.Name("showSettingsRequested")
static let closeSelectedTabRequested = Notification.Name("closeSelectedTabRequested")
static let openRecentFileRequested = Notification.Name("openRecentFileRequested")
static let recentFilesDidChange = Notification.Name("recentFilesDidChange")
}
extension NSRange {

View file

@ -0,0 +1,70 @@
import XCTest
@testable import Neon_Vision_Editor
final class RecentFilesStoreTests: XCTestCase {
private var temporaryDirectoryURL: URL!
override func setUpWithError() throws {
temporaryDirectoryURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true)
clearStore()
}
override func tearDownWithError() throws {
clearStore()
if let temporaryDirectoryURL {
try? FileManager.default.removeItem(at: temporaryDirectoryURL)
}
temporaryDirectoryURL = nil
}
func testRememberOrdersMostRecentFirst() throws {
let first = try makeFile(named: "first.txt")
let second = try makeFile(named: "second.txt")
RecentFilesStore.remember(first)
RecentFilesStore.remember(second)
XCTAssertEqual(RecentFilesStore.items(limit: 10).map(\.title), ["second.txt", "first.txt"])
}
func testPinnedItemsStayAtTop() throws {
let first = try makeFile(named: "first.txt")
let second = try makeFile(named: "second.txt")
let third = try makeFile(named: "third.txt")
RecentFilesStore.remember(first)
RecentFilesStore.remember(second)
RecentFilesStore.remember(third)
RecentFilesStore.togglePinned(first)
let items = RecentFilesStore.items(limit: 10)
XCTAssertEqual(items.map(\.title), ["first.txt", "third.txt", "second.txt"])
XCTAssertEqual(items.first?.isPinned, true)
}
func testClearUnpinnedRetainsPinnedItems() throws {
let pinned = try makeFile(named: "pinned.txt")
let unpinned = try makeFile(named: "unpinned.txt")
RecentFilesStore.remember(pinned)
RecentFilesStore.remember(unpinned)
RecentFilesStore.togglePinned(pinned)
RecentFilesStore.clearUnpinned()
XCTAssertEqual(RecentFilesStore.items(limit: 10).map(\.title), ["pinned.txt"])
}
private func makeFile(named name: String) throws -> URL {
let url = temporaryDirectoryURL.appendingPathComponent(name)
try "sample".write(to: url, atomically: true, encoding: .utf8)
return url
}
private func clearStore() {
let defaults = UserDefaults.standard
defaults.removeObject(forKey: "RecentFilesPathsV1")
defaults.removeObject(forKey: "PinnedRecentFilesPathsV1")
}
}