mirror of
https://github.com/h3pdesign/Neon-Vision-Editor
synced 2026-04-21 13:27:16 +00:00
Add pinned recent files workflows
This commit is contained in:
parent
776788b2ea
commit
c176c411f8
8 changed files with 384 additions and 34 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
96
Neon Vision Editor/Core/RecentFilesStore.swift
Normal file
96
Neon Vision Editor/Core/RecentFilesStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
70
Neon Vision EditorTests/RecentFilesStoreTests.swift
Normal file
70
Neon Vision EditorTests/RecentFilesStoreTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue