From c96142fab0aa2ad2df8cc4059d1fd6c821152069 Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Tue, 30 Dec 2025 15:26:39 -0800 Subject: [PATCH 1/8] improve stats efficiency + file hash + fix sidebar image --- abledex.xcodeproj/project.pbxproj | 4 +- .../logo.imageset/Contents.json | 11 +-- abledex/Scanner/ProjectScanner.swift | 33 +++++++- abledex/Views/Sidebar/SidebarView.swift | 1 + abledex/Views/StatisticsView.swift | 83 ++++++++++++------- 5 files changed, 88 insertions(+), 44 deletions(-) diff --git a/abledex.xcodeproj/project.pbxproj b/abledex.xcodeproj/project.pbxproj index 8297ae0..520a908 100644 --- a/abledex.xcodeproj/project.pbxproj +++ b/abledex.xcodeproj/project.pbxproj @@ -335,7 +335,7 @@ CODE_SIGN_ENTITLEMENTS = abledex/abledex.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 94XUGF9CU7; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -370,7 +370,7 @@ CODE_SIGN_ENTITLEMENTS = abledex/abledex.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 94XUGF9CU7; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; diff --git a/abledex/Assets.xcassets/logo.imageset/Contents.json b/abledex/Assets.xcassets/logo.imageset/Contents.json index 95e9744..a60d29e 100644 --- a/abledex/Assets.xcassets/logo.imageset/Contents.json +++ b/abledex/Assets.xcassets/logo.imageset/Contents.json @@ -2,16 +2,7 @@ "images" : [ { "filename" : "abledex2pink.svg", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" + "idiom" : "universal" } ], "info" : { diff --git a/abledex/Scanner/ProjectScanner.swift b/abledex/Scanner/ProjectScanner.swift index 369db2a..21f1f80 100644 --- a/abledex/Scanner/ProjectScanner.swift +++ b/abledex/Scanner/ProjectScanner.swift @@ -67,7 +67,7 @@ final class ProjectScanner: Sendable { // Parse in batches to avoid memory pressure var processed = 0 - let batchSize = 10 + let batchSize = 50 for batch in stride(from: 0, to: total, by: batchSize) { let end = min(batch + batchSize, total) @@ -160,8 +160,35 @@ final class ProjectScanner: Sendable { } private nonisolated func calculateFileHash(url: URL) -> String? { - guard let data = try? Data(contentsOf: url) else { return nil } - let digest = Insecure.MD5.hash(data: data) + // Use file size + first/last 64KB for fast hashing instead of reading entire file + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + + guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let fileSize = attrs[.size] as? UInt64 else { return nil } + + let sampleSize = min(65536, Int(fileSize)) // 64KB or less + + var hasher = Insecure.MD5() + + // Hash file size + var size = fileSize + withUnsafeBytes(of: &size) { hasher.update(bufferPointer: $0) } + + // Hash first chunk + if let firstChunk = try? handle.read(upToCount: sampleSize) { + hasher.update(data: firstChunk) + } + + // Hash last chunk if file is large enough + if fileSize > UInt64(sampleSize * 2) { + try? handle.seek(toOffset: fileSize - UInt64(sampleSize)) + if let lastChunk = try? handle.read(upToCount: sampleSize) { + hasher.update(data: lastChunk) + } + } + + let digest = hasher.finalize() return digest.map { String(format: "%02hhx", $0) }.joined() } } diff --git a/abledex/Views/Sidebar/SidebarView.swift b/abledex/Views/Sidebar/SidebarView.swift index 1fb9dba..e210d25 100644 --- a/abledex/Views/Sidebar/SidebarView.swift +++ b/abledex/Views/Sidebar/SidebarView.swift @@ -21,6 +21,7 @@ struct SidebarView: View { HStack(alignment: .center) { Image(.logo) + .renderingMode(.template) .resizable() .scaledToFit() .frame(width: 32, height: 32) diff --git a/abledex/Views/StatisticsView.swift b/abledex/Views/StatisticsView.swift index 9564dd3..6d494a9 100644 --- a/abledex/Views/StatisticsView.swift +++ b/abledex/Views/StatisticsView.swift @@ -12,6 +12,11 @@ struct StatisticsView: View { @Environment(AppState.self) private var appState @Environment(\.dismiss) private var dismiss + // Cached storage data to avoid synchronous file I/O on every render + @State private var cachedStorageByVolume: [(volume: String, size: Int64, count: Int)] = [] + @State private var cachedTotalStorage: Int64 = 0 + @State private var isLoadingStorage = true + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { @@ -113,13 +118,21 @@ struct StatisticsView: View { Text("Storage by Volume") .font(.headline) - if storageByVolume.isEmpty { + if isLoadingStorage { + HStack { + ProgressView() + .scaleEffect(0.7) + Text("Calculating storage...") + .foregroundStyle(.secondary) + .font(.caption) + } + } else if cachedStorageByVolume.isEmpty { Text("No volume data available") .foregroundStyle(.secondary) .font(.caption) } else { VStack(spacing: 8) { - ForEach(storageByVolume, id: \.volume) { item in + ForEach(cachedStorageByVolume, id: \.volume) { item in Button { appState.clearAllFilters() appState.selectedVolumeFilter = item.volume @@ -145,7 +158,7 @@ struct StatisticsView: View { HStack { Spacer() - Text("Total: \(formatBytes(totalStorageSize))") + Text("Total: \(formatBytes(cachedTotalStorage))") .font(.subheadline) .foregroundStyle(.secondary) } @@ -358,6 +371,44 @@ struct StatisticsView: View { .padding() } .frame(minWidth: 700, idealWidth: 800, minHeight: 600, idealHeight: 700) + .task { + await loadStorageData() + } + } + + // MARK: - Async Storage Loading + + private func loadStorageData() async { + let projects = appState.projects + let result = await Task.detached(priority: .userInitiated) { + var byVolume: [String: (size: Int64, count: Int)] = [:] + var total: Int64 = 0 + + for project in projects { + let url = URL(fileURLWithPath: project.alsFilePath) + if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = attrs[.size] as? UInt64 { + let sizeInt64 = Int64(size) + total += sizeInt64 + + let volume = project.sourceVolume + if let existing = byVolume[volume] { + byVolume[volume] = (existing.size + sizeInt64, existing.count + 1) + } else { + byVolume[volume] = (sizeInt64, 1) + } + } + } + + let sorted = byVolume.map { (volume: $0.key, size: $0.value.size, count: $0.value.count) } + .sorted { $0.size > $1.size } + + return (sorted, total) + }.value + + cachedStorageByVolume = result.0 + cachedTotalStorage = result.1 + isLoadingStorage = false } // MARK: - Computed Stats @@ -447,32 +498,6 @@ struct StatisticsView: View { // MARK: - Storage Stats - private var totalStorageSize: Int64 { - appState.projects.reduce(Int64(0)) { sum, project in - let url = URL(fileURLWithPath: project.alsFilePath) - if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), - let size = attrs[.size] as? UInt64 { - return sum + Int64(size) - } - return sum - } - } - - private var storageByVolume: [(volume: String, size: Int64, count: Int)] { - let grouped = Dictionary(grouping: appState.projects, by: { $0.sourceVolume }) - return grouped.map { (volume, projects) in - let size = projects.reduce(Int64(0)) { sum, project in - let url = URL(fileURLWithPath: project.alsFilePath) - if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), - let fileSize = attrs[.size] as? UInt64 { - return sum + Int64(fileSize) - } - return sum - } - return (volume, size, projects.count) - }.sorted { $0.size > $1.size } - } - private func formatBytes(_ bytes: Int64) -> String { let formatter = ByteCountFormatter() formatter.countStyle = .file From b868a813d4f4ea0062f9dd8b6e36e1e1d2c03baf Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Tue, 30 Dec 2025 15:33:10 -0800 Subject: [PATCH 2/8] update build settings --- abledex.xcodeproj/project.pbxproj | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/abledex.xcodeproj/project.pbxproj b/abledex.xcodeproj/project.pbxproj index 520a908..3fa2831 100644 --- a/abledex.xcodeproj/project.pbxproj +++ b/abledex.xcodeproj/project.pbxproj @@ -130,7 +130,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 2600; - LastUpgradeCheck = 2600; + LastUpgradeCheck = 2620; TargetAttributes = { A60284F72EEE2E9D00893D52 = { CreatedOnToolsVersion = 26.0.1; @@ -241,6 +241,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 94XUGF9CU7; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -266,6 +267,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -305,6 +307,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 94XUGF9CU7; ENABLE_NS_ASSERTIONS = NO; @@ -323,6 +326,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; }; name = Release; @@ -336,6 +340,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 3; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 94XUGF9CU7; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -371,6 +376,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 3; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 94XUGF9CU7; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -403,6 +409,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 94XUGF9CU7; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.6; @@ -424,6 +431,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 94XUGF9CU7; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.6; From 7f5a06786840de1544f2cc0efb41a47fd860f117 Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Wed, 31 Dec 2025 13:36:00 -0800 Subject: [PATCH 3/8] checkpt, improve lag --- abledex/AppState.swift | 241 ++++++++++++++++-- .../logopdf.imageset/Contents.json | 16 ++ .../logopdf.imageset/abelyedex.pdf | Bin 0 -> 44829 bytes abledex/ContentView.swift | 16 ++ abledex/Scanner/ProjectScanner.swift | 3 +- abledex/Views/Sidebar/SidebarView.swift | 19 +- 6 files changed, 257 insertions(+), 38 deletions(-) create mode 100644 abledex/Assets.xcassets/logopdf.imageset/Contents.json create mode 100644 abledex/Assets.xcassets/logopdf.imageset/abelyedex.pdf diff --git a/abledex/AppState.swift b/abledex/AppState.swift index 6c2ffbf..ec5d12b 100644 --- a/abledex/AppState.swift +++ b/abledex/AppState.swift @@ -21,33 +21,132 @@ final class AppState { // MARK: - State - var projects: [ProjectRecord] = [] + var projects: [ProjectRecord] = [] { + didSet { + recomputeCachedCounts() + recomputeFilteredProjects() + } + } var locations: [LocationRecord] = [] var selectedProjectIDs: Set = [] - var searchQuery: String = "" + var searchQuery: String = "" { + didSet { + // Debounce search to avoid filtering on every keystroke + searchDebounceTask?.cancel() + searchDebounceTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(150)) + if !Task.isCancelled { + recomputeFilteredProjects() + } + } + } + } + + // MARK: - Cached Data (for sidebar performance) + private(set) var statusCounts: [CompletionStatus: Int] = [:] + private(set) var colorLabelCounts: [ColorLabel: Int] = [:] + private(set) var volumeCounts: [String: Int] = [:] + private(set) var tagCounts: [String: Int] = [:] + private(set) var pluginCounts: [String: Int] = [:] + private(set) var keyCounts: [String: Int] = [:] + private(set) var folderCounts: [String: Int] = [:] + + // Cached unique values (avoid recomputing on every render) + private(set) var cachedUniqueVolumes: [String] = [] + private(set) var cachedUniqueTags: [String] = [] + private(set) var cachedUniquePlugins: [String] = [] + private(set) var cachedUniqueKeys: [String] = [] + private(set) var cachedUniqueFolders: [String] = [] + private(set) var cachedFoldersWithMultipleVersions: [String] = [] + private(set) var cachedProjectsByFolder: [String: [ProjectRecord]] = [:] + + private func recomputeCachedCounts() { + var newStatusCounts: [CompletionStatus: Int] = [:] + var newColorLabelCounts: [ColorLabel: Int] = [:] + var newVolumeCounts: [String: Int] = [:] + var newTagCounts: [String: Int] = [:] + var newPluginCounts: [String: Int] = [:] + var newKeyCounts: [String: Int] = [:] + var newFolderCounts: [String: Int] = [:] + + for project in projects { + // Status + newStatusCounts[project.completionStatus, default: 0] += 1 + + // Color label + newColorLabelCounts[project.colorLabel, default: 0] += 1 + + // Volume + newVolumeCounts[project.sourceVolume, default: 0] += 1 + + // Folder + newFolderCounts[project.projectFolderName, default: 0] += 1 + + // Tags + for tag in project.userTags { + newTagCounts[tag, default: 0] += 1 + } + + // Plugins + for plugin in project.plugins { + newPluginCounts[plugin, default: 0] += 1 + } + + // Keys + for key in project.musicalKeys { + newKeyCounts[key, default: 0] += 1 + } + } + + statusCounts = newStatusCounts + colorLabelCounts = newColorLabelCounts + volumeCounts = newVolumeCounts + tagCounts = newTagCounts + pluginCounts = newPluginCounts + keyCounts = newKeyCounts + folderCounts = newFolderCounts + + // Compute unique sorted arrays from the count dictionaries + cachedUniqueVolumes = newVolumeCounts.keys.sorted() + cachedUniqueTags = newTagCounts.keys.sorted() + cachedUniquePlugins = newPluginCounts.keys.sorted() + cachedUniqueKeys = newKeyCounts.keys.sorted() + cachedUniqueFolders = newFolderCounts.keys.sorted() + cachedFoldersWithMultipleVersions = newFolderCounts.filter { $0.value > 1 }.keys.sorted() + cachedProjectsByFolder = Dictionary(grouping: projects, by: { $0.projectFolderName }) + } var isScanning: Bool = false var scanProgress: ScanProgress? + var isLoading: Bool = true // Sorting - var sortColumn: SortColumn = .modifiedDate - var sortAscending: Bool = false - - // Filtering - var selectedFilter: ProjectFilter = .all - var selectedVolumeFilter: String? - var selectedStatusFilter: CompletionStatus? - var selectedColorLabelFilter: ColorLabel? - var selectedTagFilter: String? - var selectedPluginFilter: String? - var selectedKeyFilter: String? - var selectedFolderFilter: String? - var showFavoritesOnly: Bool = false - var showDuplicatesOnly: Bool = false + var sortColumn: SortColumn = .modifiedDate { didSet { recomputeFilteredProjects() } } + var sortAscending: Bool = false { didSet { recomputeFilteredProjects() } } + + // Filtering (with didSet to trigger recomputation) + var selectedFilter: ProjectFilter = .all { didSet { recomputeFilteredProjects() } } + var selectedVolumeFilter: String? { didSet { recomputeFilteredProjects() } } + var selectedStatusFilter: CompletionStatus? { didSet { recomputeFilteredProjects() } } + var selectedColorLabelFilter: ColorLabel? { didSet { recomputeFilteredProjects() } } + var selectedTagFilter: String? { didSet { recomputeFilteredProjects() } } + var selectedPluginFilter: String? { didSet { recomputeFilteredProjects() } } + var selectedKeyFilter: String? { didSet { recomputeFilteredProjects() } } + var selectedFolderFilter: String? { didSet { recomputeFilteredProjects() } } + var showFavoritesOnly: Bool = false { didSet { recomputeFilteredProjects() } } + var showDuplicatesOnly: Bool = false { didSet { recomputeFilteredProjects() } } + + // Cached filtered projects + private(set) var cachedFilteredProjects: [ProjectRecord] = [] + private var searchDebounceTask: Task? // MARK: - Computed Properties var filteredProjects: [ProjectRecord] { + cachedFilteredProjects + } + + private func recomputeFilteredProjects() { var result = projects // Apply search filter (includes name, plugins, and tags) @@ -154,7 +253,7 @@ final class AppState { return sortAscending ? comparison : !comparison } - return result + cachedFilteredProjects = result } var selectedProject: ProjectRecord? { @@ -168,27 +267,27 @@ final class AppState { } var uniqueVolumes: [String] { - Array(Set(projects.map { $0.sourceVolume })).sorted() + cachedUniqueVolumes } var uniqueTags: [String] { - Array(Set(projects.flatMap { $0.userTags })).sorted() + cachedUniqueTags } var uniquePlugins: [String] { - Array(Set(projects.flatMap { $0.plugins })).sorted() + cachedUniquePlugins } var uniqueKeys: [String] { - Array(Set(projects.flatMap { $0.musicalKeys })).sorted() + cachedUniqueKeys } var uniqueFolders: [String] { - Array(Set(projects.map { $0.projectFolderName })).sorted() + cachedUniqueFolders } var projectsByFolder: [String: [ProjectRecord]] { - Dictionary(grouping: projects, by: { $0.projectFolderName }) + cachedProjectsByFolder } func versionsInSameFolder(as project: ProjectRecord) -> [ProjectRecord] { @@ -237,9 +336,37 @@ final class AppState { // MARK: - Data Loading func loadData() async { + isLoading = true do { - projects = try await database.fetchAllProjects() - locations = try await database.fetchAllLocations() + // Fetch from database (off main thread via async) + let fetchedProjects = try await database.fetchAllProjects() + let fetchedLocations = try await database.fetchAllLocations() + + // Compute caches off main thread + let caches = await Task.detached(priority: .userInitiated) { + self.computeCachesOffMainThread(for: fetchedProjects) + }.value + + // Apply to state (on main thread, but just assignments) + locations = fetchedLocations + statusCounts = caches.statusCounts + colorLabelCounts = caches.colorLabelCounts + volumeCounts = caches.volumeCounts + tagCounts = caches.tagCounts + pluginCounts = caches.pluginCounts + keyCounts = caches.keyCounts + folderCounts = caches.folderCounts + cachedUniqueVolumes = caches.uniqueVolumes + cachedUniqueTags = caches.uniqueTags + cachedUniquePlugins = caches.uniquePlugins + cachedUniqueKeys = caches.uniqueKeys + cachedUniqueFolders = caches.uniqueFolders + cachedFoldersWithMultipleVersions = caches.foldersWithMultipleVersions + cachedProjectsByFolder = caches.projectsByFolder + cachedFilteredProjects = caches.filteredProjects + + // Set projects last (triggers didSet but caches already populated) + projects = fetchedProjects if locations.isEmpty { await initializeDefaultLocations() @@ -247,6 +374,68 @@ final class AppState { } catch { print("Failed to load data: \(error)") } + isLoading = false + } + + private struct ComputedCaches: Sendable { + var statusCounts: [CompletionStatus: Int] + var colorLabelCounts: [ColorLabel: Int] + var volumeCounts: [String: Int] + var tagCounts: [String: Int] + var pluginCounts: [String: Int] + var keyCounts: [String: Int] + var folderCounts: [String: Int] + var uniqueVolumes: [String] + var uniqueTags: [String] + var uniquePlugins: [String] + var uniqueKeys: [String] + var uniqueFolders: [String] + var foldersWithMultipleVersions: [String] + var projectsByFolder: [String: [ProjectRecord]] + var filteredProjects: [ProjectRecord] + } + + private nonisolated func computeCachesOffMainThread(for projects: [ProjectRecord]) -> ComputedCaches { + var statusCounts: [CompletionStatus: Int] = [:] + var colorLabelCounts: [ColorLabel: Int] = [:] + var volumeCounts: [String: Int] = [:] + var tagCounts: [String: Int] = [:] + var pluginCounts: [String: Int] = [:] + var keyCounts: [String: Int] = [:] + var folderCounts: [String: Int] = [:] + + for project in projects { + statusCounts[project.completionStatus, default: 0] += 1 + colorLabelCounts[project.colorLabel, default: 0] += 1 + volumeCounts[project.sourceVolume, default: 0] += 1 + folderCounts[project.projectFolderName, default: 0] += 1 + for tag in project.userTags { tagCounts[tag, default: 0] += 1 } + for plugin in project.plugins { pluginCounts[plugin, default: 0] += 1 } + for key in project.musicalKeys { keyCounts[key, default: 0] += 1 } + } + + // Sort projects by modified date descending (default sort) + let sortedProjects = projects.sorted { + ($0.modifiedDate ?? $0.filesystemModifiedDate) > ($1.modifiedDate ?? $1.filesystemModifiedDate) + } + + return ComputedCaches( + statusCounts: statusCounts, + colorLabelCounts: colorLabelCounts, + volumeCounts: volumeCounts, + tagCounts: tagCounts, + pluginCounts: pluginCounts, + keyCounts: keyCounts, + folderCounts: folderCounts, + uniqueVolumes: volumeCounts.keys.sorted(), + uniqueTags: tagCounts.keys.sorted(), + uniquePlugins: pluginCounts.keys.sorted(), + uniqueKeys: keyCounts.keys.sorted(), + uniqueFolders: folderCounts.keys.sorted(), + foldersWithMultipleVersions: folderCounts.filter { $0.value > 1 }.keys.sorted(), + projectsByFolder: Dictionary(grouping: projects, by: { $0.projectFolderName }), + filteredProjects: sortedProjects + ) } private func initializeDefaultLocations() async { @@ -492,7 +681,7 @@ final class AppState { } func colorLabelCount(for label: ColorLabel) -> Int { - projects.filter { $0.colorLabel == label }.count + colorLabelCounts[label] ?? 0 } func clearAllFilters() { diff --git a/abledex/Assets.xcassets/logopdf.imageset/Contents.json b/abledex/Assets.xcassets/logopdf.imageset/Contents.json new file mode 100644 index 0000000..8f7e3b3 --- /dev/null +++ b/abledex/Assets.xcassets/logopdf.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "abelyedex.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/abledex/Assets.xcassets/logopdf.imageset/abelyedex.pdf b/abledex/Assets.xcassets/logopdf.imageset/abelyedex.pdf new file mode 100644 index 0000000000000000000000000000000000000000..abaf0d5ba875973fed678d41afea1c3f7ac83523 GIT binary patch literal 44829 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 -Rp>$lG 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 GBL80?*6F7J literal 0 HcmV?d00001 diff --git a/abledex/ContentView.swift b/abledex/ContentView.swift index 90dfaee..655308f 100644 --- a/abledex/ContentView.swift +++ b/abledex/ContentView.swift @@ -15,6 +15,22 @@ struct ContentView: View { var body: some View { @Bindable var state = appState + if appState.isLoading { + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.5) + Text("Loading projects...") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + mainContent + } + } + + @ViewBuilder + private var mainContent: some View { + @Bindable var state = appState NavigationSplitView(columnVisibility: $columnVisibility) { SidebarView() .navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 300) diff --git a/abledex/Scanner/ProjectScanner.swift b/abledex/Scanner/ProjectScanner.swift index 21f1f80..2423686 100644 --- a/abledex/Scanner/ProjectScanner.swift +++ b/abledex/Scanner/ProjectScanner.swift @@ -97,7 +97,8 @@ final class ProjectScanner: Sendable { } processed += batchProjects.count - if let lastProject = batchProjects.last { + // Only report progress every 5 batches to reduce UI updates + if processed % (batchSize * 5) == 0 || processed == total, let lastProject = batchProjects.last { progress(.parsing(current: processed, total: total, projectName: lastProject.projectName)) } } diff --git a/abledex/Views/Sidebar/SidebarView.swift b/abledex/Views/Sidebar/SidebarView.swift index e210d25..e039d24 100644 --- a/abledex/Views/Sidebar/SidebarView.swift +++ b/abledex/Views/Sidebar/SidebarView.swift @@ -20,7 +20,7 @@ struct SidebarView: View { List(selection: $state.selectedFilter) { HStack(alignment: .center) { - Image(.logo) + Image(.logopdf) .renderingMode(.template) .resizable() .scaledToFit() @@ -532,10 +532,7 @@ struct SidebarView: View { } private var foldersWithMultipleVersions: [String] { - appState.projectsByFolder - .filter { $0.value.count > 1 } - .keys - .sorted() + appState.cachedFoldersWithMultipleVersions } // MARK: - Helper Functions @@ -617,23 +614,23 @@ struct SidebarView: View { } private func projectCount(for volume: String) -> Int { - appState.projects.filter { $0.sourceVolume == volume }.count + appState.volumeCounts[volume] ?? 0 } private func tagCount(for tag: String) -> Int { - appState.projects.filter { $0.userTags.contains(tag) }.count + appState.tagCounts[tag] ?? 0 } private func pluginCount(for plugin: String) -> Int { - appState.projects.filter { $0.plugins.contains(plugin) }.count + appState.pluginCounts[plugin] ?? 0 } private func keyCount(for key: String) -> Int { - appState.projects.filter { $0.musicalKeys.contains(key) }.count + appState.keyCounts[key] ?? 0 } private func folderCount(for folder: String) -> Int { - appState.projectsByFolder[folder]?.count ?? 0 + appState.folderCounts[folder] ?? 0 } private func displayKey(_ key: String) -> String { @@ -652,7 +649,7 @@ struct SidebarView: View { } private func statusCount(for status: CompletionStatus) -> Int { - appState.projects.filter { $0.completionStatus == status }.count + appState.statusCounts[status] ?? 0 } private func statusColor(for status: CompletionStatus) -> Color { From 9821254f32ac0cfa89eb1dcf4d8e84be61ec905d Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Thu, 1 Jan 2026 00:09:29 -0800 Subject: [PATCH 4/8] checkpt --- abledex/AppState.swift | 38 ++++++++++--- abledex/ContentView.swift | 16 ------ abledex/Database/ProjectRecord.swift | 74 ++++++++++++++++--------- abledex/Scanner/ProjectScanner.swift | 19 ++++++- abledex/Views/Sidebar/SidebarView.swift | 69 +++++++++++++++++------ 5 files changed, 149 insertions(+), 67 deletions(-) diff --git a/abledex/AppState.swift b/abledex/AppState.swift index ec5d12b..dcaabe9 100644 --- a/abledex/AppState.swift +++ b/abledex/AppState.swift @@ -23,10 +23,13 @@ final class AppState { var projects: [ProjectRecord] = [] { didSet { + // Skip if this is the initial load (caches already populated) + guard !isInitialLoad else { return } recomputeCachedCounts() recomputeFilteredProjects() } } + private var isInitialLoad = true var locations: [LocationRecord] = [] var selectedProjectIDs: Set = [] var searchQuery: String = "" { @@ -59,6 +62,8 @@ final class AppState { private(set) var cachedUniqueFolders: [String] = [] private(set) var cachedFoldersWithMultipleVersions: [String] = [] private(set) var cachedProjectsByFolder: [String: [ProjectRecord]] = [:] + private(set) var cachedDuplicateGroups: [DuplicateGroup] = [] + private(set) var cachedDuplicatesCount: Int = 0 private func recomputeCachedCounts() { var newStatusCounts: [CompletionStatus: Int] = [:] @@ -114,11 +119,21 @@ final class AppState { cachedUniqueFolders = newFolderCounts.keys.sorted() cachedFoldersWithMultipleVersions = newFolderCounts.filter { $0.value > 1 }.keys.sorted() cachedProjectsByFolder = Dictionary(grouping: projects, by: { $0.projectFolderName }) + + // Recompute duplicates in background (O(n²) operation) + let projectsCopy = projects + Task.detached(priority: .utility) { [weak self] in + let groups = DuplicateDetectionService().findDuplicates(in: projectsCopy) + let count = Set(groups.flatMap { $0.projects.map { $0.id } }).count + await MainActor.run { + self?.cachedDuplicateGroups = groups + self?.cachedDuplicatesCount = count + } + } } var isScanning: Bool = false var scanProgress: ScanProgress? - var isLoading: Bool = true // Sorting var sortColumn: SortColumn = .modifiedDate { didSet { recomputeFilteredProjects() } } @@ -296,11 +311,11 @@ final class AppState { } var duplicateGroups: [DuplicateGroup] { - duplicateService.findDuplicates(in: projects) + cachedDuplicateGroups } var duplicatesCount: Int { - Set(duplicateGroups.flatMap { $0.projects.map { $0.id } }).count + cachedDuplicatesCount } func hasDuplicates(_ project: ProjectRecord) -> Bool { @@ -336,7 +351,6 @@ final class AppState { // MARK: - Data Loading func loadData() async { - isLoading = true do { // Fetch from database (off main thread via async) let fetchedProjects = try await database.fetchAllProjects() @@ -364,9 +378,12 @@ final class AppState { cachedFoldersWithMultipleVersions = caches.foldersWithMultipleVersions cachedProjectsByFolder = caches.projectsByFolder cachedFilteredProjects = caches.filteredProjects + cachedDuplicateGroups = caches.duplicateGroups + cachedDuplicatesCount = caches.duplicatesCount - // Set projects last (triggers didSet but caches already populated) + // Set projects (didSet skipped due to isInitialLoad flag) projects = fetchedProjects + isInitialLoad = false if locations.isEmpty { await initializeDefaultLocations() @@ -374,7 +391,6 @@ final class AppState { } catch { print("Failed to load data: \(error)") } - isLoading = false } private struct ComputedCaches: Sendable { @@ -393,6 +409,8 @@ final class AppState { var foldersWithMultipleVersions: [String] var projectsByFolder: [String: [ProjectRecord]] var filteredProjects: [ProjectRecord] + var duplicateGroups: [DuplicateGroup] + var duplicatesCount: Int } private nonisolated func computeCachesOffMainThread(for projects: [ProjectRecord]) -> ComputedCaches { @@ -419,6 +437,10 @@ final class AppState { ($0.modifiedDate ?? $0.filesystemModifiedDate) > ($1.modifiedDate ?? $1.filesystemModifiedDate) } + // Compute duplicates (O(n²) but done off main thread) + let duplicateGroups = DuplicateDetectionService().findDuplicates(in: projects) + let duplicatesCount = Set(duplicateGroups.flatMap { $0.projects.map { $0.id } }).count + return ComputedCaches( statusCounts: statusCounts, colorLabelCounts: colorLabelCounts, @@ -434,7 +456,9 @@ final class AppState { uniqueFolders: folderCounts.keys.sorted(), foldersWithMultipleVersions: folderCounts.filter { $0.value > 1 }.keys.sorted(), projectsByFolder: Dictionary(grouping: projects, by: { $0.projectFolderName }), - filteredProjects: sortedProjects + filteredProjects: sortedProjects, + duplicateGroups: duplicateGroups, + duplicatesCount: duplicatesCount ) } diff --git a/abledex/ContentView.swift b/abledex/ContentView.swift index 655308f..90dfaee 100644 --- a/abledex/ContentView.swift +++ b/abledex/ContentView.swift @@ -15,22 +15,6 @@ struct ContentView: View { var body: some View { @Bindable var state = appState - if appState.isLoading { - VStack(spacing: 16) { - ProgressView() - .scaleEffect(1.5) - Text("Loading projects...") - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - mainContent - } - } - - @ViewBuilder - private var mainContent: some View { - @Bindable var state = appState NavigationSplitView(columnVisibility: $columnVisibility) { SidebarView() .navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 300) diff --git a/abledex/Database/ProjectRecord.swift b/abledex/Database/ProjectRecord.swift index e3234bd..a8b7821 100644 --- a/abledex/Database/ProjectRecord.swift +++ b/abledex/Database/ProjectRecord.swift @@ -185,17 +185,52 @@ struct ProjectRecord: Codable, Sendable, Identifiable, FetchableRecord, Persista } } +// MARK: - JSON Decoding Cache + +/// Thread-safe cache for decoded JSON arrays to avoid repeated decoding +private final class JSONDecodeCache: @unchecked Sendable { + static let shared = JSONDecodeCache() + + private var cache: [String: [String]] = [:] + private let lock = NSLock() + + func get(_ json: String?) -> [String]? { + guard let json = json else { return nil } + lock.lock() + defer { lock.unlock() } + return cache[json] + } + + func set(_ json: String, value: [String]) { + lock.lock() + defer { lock.unlock() } + cache[json] = value + } + + func decode(_ json: String?) -> [String] { + guard let json = json else { return [] } + + // Check cache first + if let cached = get(json) { + return cached + } + + // Decode and cache + guard let data = json.data(using: .utf8), + let decoded = try? JSONDecoder().decode([String].self, from: data) else { + return [] + } + set(json, value: decoded) + return decoded + } +} + // MARK: - Convenience accessors for JSON fields extension ProjectRecord { var samplePaths: [String] { get { - guard let json = samplePathsJSON, - let data = json.data(using: .utf8), - let paths = try? JSONDecoder().decode([String].self, from: data) else { - return [] - } - return paths + JSONDecodeCache.shared.decode(samplePathsJSON) } set { if let data = try? JSONEncoder().encode(newValue), @@ -207,12 +242,7 @@ extension ProjectRecord { var plugins: [String] { get { - guard let json = pluginsJSON, - let data = json.data(using: .utf8), - let plugins = try? JSONDecoder().decode([String].self, from: data) else { - return [] - } - return plugins + JSONDecodeCache.shared.decode(pluginsJSON) } set { if let data = try? JSONEncoder().encode(newValue), @@ -224,12 +254,7 @@ extension ProjectRecord { var userTags: [String] { get { - guard let json = userTagsJSON, - let data = json.data(using: .utf8), - let tags = try? JSONDecoder().decode([String].self, from: data) else { - return [] - } - return tags + JSONDecodeCache.shared.decode(userTagsJSON) } set { if let data = try? JSONEncoder().encode(newValue), @@ -241,12 +266,7 @@ extension ProjectRecord { var musicalKeys: [String] { get { - guard let json = musicalKeysJSON, - let data = json.data(using: .utf8), - let keys = try? JSONDecoder().decode([String].self, from: data) else { - return [] - } - return keys + JSONDecodeCache.shared.decode(musicalKeysJSON) } set { if let data = try? JSONEncoder().encode(newValue), @@ -264,7 +284,11 @@ extension ProjectRecord { } var projectFolderName: String { - URL(fileURLWithPath: folderPath).lastPathComponent + // Use string manipulation instead of URL for performance + if let lastSlash = folderPath.lastIndex(of: "/") { + return String(folderPath[folderPath.index(after: lastSlash)...]) + } + return folderPath } var formattedDuration: String? { diff --git a/abledex/Scanner/ProjectScanner.swift b/abledex/Scanner/ProjectScanner.swift index 2423686..58a95db 100644 --- a/abledex/Scanner/ProjectScanner.swift +++ b/abledex/Scanner/ProjectScanner.swift @@ -14,6 +14,21 @@ enum ScanProgress: Sendable { case parsing(current: Int, total: Int, projectName: String) case completed(projectCount: Int, duration: TimeInterval) case failed(Error) + + var description: String { + switch self { + case .starting: + return "starting" + case .discovering(let location): + return "discovering-\(location)" + case .parsing(let current, _, let name): + return "parsing-\(current)-\(name)" + case .completed(let count, _): + return "completed-\(count)" + case .failed: + return "failed" + } + } } final class ProjectScanner: Sendable { @@ -97,8 +112,8 @@ final class ProjectScanner: Sendable { } processed += batchProjects.count - // Only report progress every 5 batches to reduce UI updates - if processed % (batchSize * 5) == 0 || processed == total, let lastProject = batchProjects.last { + // Report progress every batch for responsive UI + if let lastProject = batchProjects.last { progress(.parsing(current: processed, total: total, projectName: lastProject.projectName)) } } diff --git a/abledex/Views/Sidebar/SidebarView.swift b/abledex/Views/Sidebar/SidebarView.swift index e039d24..3b63113 100644 --- a/abledex/Views/Sidebar/SidebarView.swift +++ b/abledex/Views/Sidebar/SidebarView.swift @@ -553,32 +553,67 @@ struct SidebarView: View { @ViewBuilder private var scanProgressView: some View { if let progress = appState.scanProgress { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 6) { switch progress { case .starting: - Text("Starting scan...") - .font(.caption) + HStack(spacing: 6) { + ProgressView() + .scaleEffect(0.7) + Text("Preparing scan...") + .font(.caption) + } case .discovering(let location): - Text("Discovering in \(location)...") - .font(.caption) + HStack(spacing: 6) { + ProgressView() + .scaleEffect(0.7) + Text("Finding projects in \(location)...") + .font(.caption) + .lineLimit(1) + } case .parsing(let current, let total, let name): - Text("Parsing: \(name)") - .font(.caption) - .lineLimit(1) - ProgressView(value: Double(current), total: Double(total)) - Text("\(current) of \(total)") - .font(.caption2) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: "doc.text.magnifyingglass") + .foregroundStyle(.secondary) + .font(.caption) + Text(name) + .font(.caption) + .fontWeight(.medium) + .lineLimit(1) + } + ProgressView(value: Double(current), total: Double(total)) + .animation(.easeInOut(duration: 0.2), value: current) + HStack { + Text("\(current) of \(total) projects") + .font(.caption2) + .foregroundStyle(.secondary) + Spacer() + Text("\(Int((Double(current) / Double(total)) * 100))%") + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.secondary) + } + } case .completed(let count, let duration): - Text("Found \(count) projects in \(String(format: "%.1f", duration))s") - .font(.caption) + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("Found \(count) projects in \(String(format: "%.1f", duration))s") + .font(.caption) + } case .failed(let error): - Text("Error: \(error.localizedDescription)") - .font(.caption) - .foregroundStyle(.red) + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text(error.localizedDescription) + .font(.caption) + .foregroundStyle(.red) + .lineLimit(2) + } } } .padding(.horizontal) + .animation(.easeInOut(duration: 0.15), value: appState.scanProgress?.description) } } From ae19cc00776bc28bc1a3da4b9fb750e5c1dcb91c Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Mon, 26 Jan 2026 21:17:58 -0800 Subject: [PATCH 5/8] imp parser --- abledex/AppState.swift | 42 ++++++++++++++++++++++--------- abledex/Scanner/ALSParser.swift | 44 +++++++++++++++++---------------- 2 files changed, 54 insertions(+), 32 deletions(-) diff --git a/abledex/AppState.swift b/abledex/AppState.swift index dcaabe9..a7d6788 100644 --- a/abledex/AppState.swift +++ b/abledex/AppState.swift @@ -121,13 +121,19 @@ final class AppState { cachedProjectsByFolder = Dictionary(grouping: projects, by: { $0.projectFolderName }) // Recompute duplicates in background (O(n²) operation) - let projectsCopy = projects - Task.detached(priority: .utility) { [weak self] in - let groups = DuplicateDetectionService().findDuplicates(in: projectsCopy) + let projectsSnapshot = self.projects + Task.detached(priority: .utility) { + let groups = await DuplicateDetectionService().findDuplicates(in: projectsSnapshot) let count = Set(groups.flatMap { $0.projects.map { $0.id } }).count - await MainActor.run { - self?.cachedDuplicateGroups = groups - self?.cachedDuplicatesCount = count + await MainActor.run { [groups, count] in + // Update state on the main actor + // Accessing self here is safe as this closure executes on MainActor + // and we didn't capture self in the detached task + let update: @MainActor () -> Void = { + self.cachedDuplicateGroups = groups + self.cachedDuplicatesCount = count + } + update() } } } @@ -484,14 +490,28 @@ final class AppState { isScanning = true scanProgress = .starting - do { - _ = try await scanner.scanAllLocations { [weak self] progress in - Task { @MainActor [weak self] in - self?.scanProgress = progress + // Run scan in detached task to avoid blocking MainActor + let scanner = self.scanner + let result: Result = await Task.detached(priority: .userInitiated) { + do { + let count = try await scanner.scanAllLocations { progress in + Task { @MainActor in + // Use weak reference pattern inline + await MainActor.run { [weak self] in + self?.scanProgress = progress + } + } } + return .success(count) + } catch { + return .failure(error) } + }.value + + switch result { + case .success: await loadData() - } catch { + case .failure(let error): print("Scan failed: \(error)") scanProgress = .failed(error) } diff --git a/abledex/Scanner/ALSParser.swift b/abledex/Scanner/ALSParser.swift index a250ea1..e162eab 100644 --- a/abledex/Scanner/ALSParser.swift +++ b/abledex/Scanner/ALSParser.swift @@ -161,11 +161,9 @@ struct ALSParser: Sendable { // Parse Ableton version - look in first 2000 chars for efficiency let headerSection = String(xmlString.prefix(2000)) - if let range = headerSection.range(of: #"Creator="Ableton Live ([^"]+)""#, options: .regularExpression) { - let match = headerSection[range] - if let versionRange = match.range(of: #"(?<=Creator="Ableton Live )[^"]+"#, options: .regularExpression) { - result.abletonVersion = String(match[versionRange]) - } + if let creatorStart = headerSection.range(of: "Creator=\"Ableton Live "), + let creatorEnd = headerSection.range(of: "\"", range: creatorStart.upperBound..[^<]*<[^>]*Numerator Value="(\d+)""#) ?? 4 result.timeSignatureDenominator = extractFirstInt(from: xmlString, pattern: #"[^<]*<[^>]*Denominator Value="(\d+)""#) ?? 4 - // Count tracks - use simple string counting for speed - result.audioTrackCount = xmlString.components(separatedBy: " Int { + var count = 0 + var searchRange = haystack.startIndex.. Double? { // Find the Tempo block and extract the Manual value // The structure is: ...... @@ -206,22 +215,15 @@ struct ALSParser: Sendable { return nil } - let tempoBlock = String(xmlString[tempoStart.lowerBound.. within the Tempo block - let pattern = #" 1, - let valueRange = Range(match.range(at: 1), in: tempoBlock) else { + // Fast string-based extraction without regex + guard let manualStart = tempoBlock.range(of: " Double? { From 9c45b37b171810dafd597b95059409406823736e Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Wed, 28 Jan 2026 13:51:17 -0800 Subject: [PATCH 6/8] imp --- abledex/Scanner/ALSParser.swift | 16 +++ .../Views/ProjectList/ProjectDetailView.swift | 124 ++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/abledex/Scanner/ALSParser.swift b/abledex/Scanner/ALSParser.swift index e162eab..244df5c 100644 --- a/abledex/Scanner/ALSParser.swift +++ b/abledex/Scanner/ALSParser.swift @@ -89,6 +89,22 @@ struct ALSParser: Sendable { return parseXML(xmlString) } + /// Returns the raw decompressed XML string from an ALS file + nonisolated func getRawXML(alsFilePath: URL) throws -> String { + guard FileManager.default.fileExists(atPath: alsFilePath.path) else { + throw ALSParserError.fileNotFound + } + + let compressedData = try Data(contentsOf: alsFilePath) + let xmlData = try decompressGzip(data: compressedData) + + guard let xmlString = String(data: xmlData, encoding: .utf8) else { + throw ALSParserError.invalidXML + } + + return xmlString + } + private nonisolated func decompressGzip(data: Data) throws -> Data { guard data.count > 10 else { throw ALSParserError.decompressionFailed diff --git a/abledex/Views/ProjectList/ProjectDetailView.swift b/abledex/Views/ProjectList/ProjectDetailView.swift index e031b34..9e8c919 100644 --- a/abledex/Views/ProjectList/ProjectDetailView.swift +++ b/abledex/Views/ProjectList/ProjectDetailView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import AppKit struct ProjectDetailView: View { let project: ProjectRecord @@ -17,6 +18,7 @@ struct ProjectDetailView: View { @State private var isEditingNotes: Bool = false @State private var previewableAudio: [AudioPreviewService.PreviewableAudio] = [] @State private var sectionOrder: [DetailSection] = DetailOrderStorage.order + @State private var showXMLViewer: Bool = false var body: some View { ScrollView { @@ -172,6 +174,16 @@ struct ProjectDetailView: View { } .buttonStyle(.bordered) + Button { + showXMLViewer = true + } label: { + Label("XML", systemImage: "doc.text") + } + .buttonStyle(.bordered) + .sheet(isPresented: $showXMLViewer) { + XMLViewerSheet(project: project) + } + Spacer() } } @@ -721,7 +733,119 @@ struct ProjectDetailView: View { // MARK: - Supporting Views +struct XMLViewerSheet: View { + let project: ProjectRecord + @Environment(\.dismiss) private var dismiss + @State private var xmlContent: String = "" + @State private var isLoading: Bool = true + @State private var errorMessage: String? + @State private var xmlSize: Int = 0 + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("XML: \(project.name)") + .font(.headline) + if xmlSize > 0 { + Text("(\(ByteCountFormatter.string(fromByteCount: Int64(xmlSize), countStyle: .file)))") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(xmlContent, forType: .string) + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + .buttonStyle(.bordered) + .disabled(xmlContent.isEmpty) + + Button("Done") { + dismiss() + } + .buttonStyle(.borderedProminent) + } + .padding() + + Divider() + + // Content + if isLoading { + Spacer() + ProgressView("Loading XML...") + Spacer() + } else if let error = errorMessage { + Spacer() + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundStyle(.orange) + Text(error) + .foregroundStyle(.secondary) + } + Spacer() + } else { + XMLTextView(text: xmlContent) + } + } + .frame(minWidth: 700, minHeight: 500) + .task { + await loadXML() + } + } + + private func loadXML() async { + let filePath = URL(fileURLWithPath: project.alsFilePath) + + // Run on background thread with low priority to avoid blocking scan + let result: Result = await Task.detached(priority: .utility) { + let parser = ALSParser() + return Result { try parser.getRawXML(alsFilePath: filePath) } + }.value + switch result { + case .success(let xml): + xmlContent = xml + xmlSize = xml.utf8.count + isLoading = false + case .failure(let error): + errorMessage = error.localizedDescription + isLoading = false + } + } +} + +/// NSTextView wrapper for performant large text display +struct XMLTextView: NSViewRepresentable { + let text: String + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSTextView.scrollableTextView() + let textView = scrollView.documentView as! NSTextView + + textView.isEditable = false + textView.isSelectable = true + textView.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + textView.backgroundColor = NSColor.textBackgroundColor + textView.textContainerInset = NSSize(width: 8, height: 8) + + // Disable word wrap for XML (horizontal scroll) + textView.isHorizontallyResizable = true + textView.textContainer?.widthTracksTextView = false + textView.textContainer?.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + let textView = scrollView.documentView as! NSTextView + if textView.string != text { + textView.string = text + } + } +} struct FlowLayout: Layout { var spacing: CGFloat = 8 From 3a84b0d8d5bc62b27aa5e0fed679de0cb13ed43f Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Wed, 28 Jan 2026 14:11:00 -0800 Subject: [PATCH 7/8] index improvements --- abledex/Scanner/ProjectScanner.swift | 41 ++++++++++++++---- .../Views/ProjectList/ProjectDetailView.swift | 43 ++++++++++++++++--- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/abledex/Scanner/ProjectScanner.swift b/abledex/Scanner/ProjectScanner.swift index 58a95db..3bb893d 100644 --- a/abledex/Scanner/ProjectScanner.swift +++ b/abledex/Scanner/ProjectScanner.swift @@ -46,11 +46,20 @@ final class ProjectScanner: Sendable { progress(.starting) let locations = try await database.fetchEnabledLocations() - var totalProjects = 0 - for location in locations { - let count = try await scanLocation(location, progress: progress) - totalProjects += count + // Scan all locations in parallel + let totalProjects = try await withThrowingTaskGroup(of: Int.self) { group in + for location in locations { + group.addTask { + try await self.scanLocation(location, progress: progress) + } + } + + var total = 0 + for try await count in group { + total += count + } + return total } let duration = Date().timeIntervalSince(startTime) @@ -83,15 +92,23 @@ final class ProjectScanner: Sendable { // Parse in batches to avoid memory pressure var processed = 0 let batchSize = 50 + var pendingSave: Task? = nil for batch in stride(from: 0, to: total, by: batchSize) { let end = min(batch + batchSize, total) let batchProjects = Array(discoveredProjects[batch.. = await Task.detached(priority: .utility) { + // Run on background thread with low priority to avoid competing with scan I/O + let result: Result = await Task.detached(priority: .background) { let parser = ALSParser() return Result { try parser.getRawXML(alsFilePath: filePath) } }.value @@ -817,10 +817,15 @@ struct XMLViewerSheet: View { } } -/// NSTextView wrapper for performant large text display +/// NSTextView wrapper for performant large text display. +/// Uses Coordinator to set text off the main layout pass to avoid UI stalls. struct XMLTextView: NSViewRepresentable { let text: String + func makeCoordinator() -> Coordinator { + Coordinator() + } + func makeNSView(context: Context) -> NSScrollView { let scrollView = NSTextView.scrollableTextView() let textView = scrollView.documentView as! NSTextView @@ -830,20 +835,44 @@ struct XMLTextView: NSViewRepresentable { textView.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) textView.backgroundColor = NSColor.textBackgroundColor textView.textContainerInset = NSSize(width: 8, height: 8) + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false // Disable word wrap for XML (horizontal scroll) textView.isHorizontallyResizable = true textView.textContainer?.widthTracksTextView = false textView.textContainer?.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + // Disable layout during initial load + textView.layoutManager?.allowsNonContiguousLayout = true + + context.coordinator.textView = textView + return scrollView } func updateNSView(_ scrollView: NSScrollView, context: Context) { - let textView = scrollView.documentView as! NSTextView - if textView.string != text { - textView.string = text - } + guard context.coordinator.currentText != text else { return } + context.coordinator.currentText = text + + let textView = context.coordinator.textView! + // Set text content with layout temporarily disabled to avoid stalling + textView.textStorage?.beginEditing() + textView.textStorage?.setAttributedString(NSAttributedString( + string: text, + attributes: [ + .font: NSFont.monospacedSystemFont(ofSize: 11, weight: .regular), + .foregroundColor: NSColor.textColor + ] + )) + textView.textStorage?.endEditing() + } + + class Coordinator { + var textView: NSTextView? + var currentText: String = "" } } From 825e829adccd68e49c745111ccceffbc8bc244db Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Wed, 28 Jan 2026 14:40:49 -0800 Subject: [PATCH 8/8] update release action --- .github/workflows/release.yml | 111 ++++++++++++++++++++++++++++++---- 1 file changed, 98 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7ce809..f11b193 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,9 +12,51 @@ jobs: release: runs-on: macos-26 + env: + KEYCHAIN_PATH: ${{ runner.temp }}/build.keychain-db + KEYCHAIN_PASSWORD: ${{ github.run_id }} + steps: - uses: actions/checkout@v4 + - name: Install certificates + env: + DEVELOPER_ID_APPLICATION_P12: ${{ secrets.DEVELOPER_ID_APPLICATION_P12 }} + DEVELOPER_ID_INSTALLER_P12: ${{ secrets.DEVELOPER_ID_INSTALLER_P12 }} + P12_PASSWORD: ${{ secrets.P12_PASSWORD }} + run: | + # Create temporary keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Import Developer ID Application certificate (for app signing) + echo "$DEVELOPER_ID_APPLICATION_P12" | base64 --decode > /tmp/app_cert.p12 + security import /tmp/app_cert.p12 \ + -P "$P12_PASSWORD" \ + -A \ + -t cert \ + -f pkcs12 \ + -k "$KEYCHAIN_PATH" + + # Import Developer ID Installer certificate (for pkg signing) + echo "$DEVELOPER_ID_INSTALLER_P12" | base64 --decode > /tmp/installer_cert.p12 + security import /tmp/installer_cert.p12 \ + -P "$P12_PASSWORD" \ + -A \ + -t cert \ + -f pkcs12 \ + -k "$KEYCHAIN_PATH" + + # Add keychain to search list + security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"') + security default-keychain -s "$KEYCHAIN_PATH" + + # Allow codesign to access keychain without UI prompt + security set-key-partition-list -S apple-tool:,apple:,codesign:,productsign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Clean up cert files + rm -f /tmp/app_cert.p12 /tmp/installer_cert.p12 - name: Archive App run: | @@ -22,23 +64,24 @@ jobs: -archivePath $PWD/build/abledex.xcarchive \ -configuration Release \ archive \ - CODE_SIGN_IDENTITY="-" \ - CODE_SIGNING_REQUIRED=NO + CODE_SIGN_IDENTITY="Developer ID Application: Brett Henderson (94XUGF9CU7)" \ + OTHER_CODE_SIGN_FLAGS="--keychain $KEYCHAIN_PATH" - name: Export App run: | - # Creates a simple export options plist cat < ExportOptions.plist method - mac-application - destination - export + developer-id signingStyle manual + signingCertificate + Developer ID Application: Brett Henderson (94XUGF9CU7) + teamID + 94XUGF9CU7 EOF @@ -46,17 +89,59 @@ jobs: xcodebuild -exportArchive \ -archivePath $PWD/build/abledex.xcarchive \ -exportOptionsPlist ExportOptions.plist \ - -exportPath $PWD/build \ - CODE_SIGN_IDENTITY="-" \ - CODE_SIGNING_REQUIRED=NO + -exportPath $PWD/build + + - name: Extract version from tag + id: version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" - - name: Zip Application + - name: Store notarization credentials + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} run: | - cd build - zip -r abledex.zip abledex.app + xcrun notarytool store-credentials "notary-profile" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_ID_PASSWORD" \ + --team-id "94XUGF9CU7" \ + --keychain "$KEYCHAIN_PATH" + + - name: Build, sign, notarize, and staple pkg + run: | + VERSION="${{ steps.version.outputs.VERSION }}" + + # Build unsigned pkg + pkgbuild \ + --component build/abledex.app \ + --install-location /Applications \ + --identifier computerdata.abledex \ + --version "$VERSION" \ + build/abledex-unsigned.pkg + + # Sign pkg with Developer ID Installer certificate + productsign \ + --sign "Developer ID Installer: Brett Henderson (94XUGF9CU7)" \ + --keychain "$KEYCHAIN_PATH" \ + build/abledex-unsigned.pkg \ + build/abledex.pkg + + rm build/abledex-unsigned.pkg + + # Notarize using stored credentials (avoids secrets in process args) + xcrun notarytool submit build/abledex.pkg \ + --keychain-profile "notary-profile" \ + --keychain "$KEYCHAIN_PATH" \ + --wait + + # Staple the notarization ticket + xcrun stapler staple build/abledex.pkg - name: Release uses: softprops/action-gh-release@v1 with: - files: build/abledex.zip + files: build/abledex.pkg generate_release_notes: true + + - name: Cleanup keychain + if: always() + run: security delete-keychain "$KEYCHAIN_PATH"