From bcaa29d59025cb9872ea7b98fa88a8fe9a37e1f7 Mon Sep 17 00:00:00 2001 From: David Hoang Date: Tue, 28 Jul 2026 12:06:45 -0700 Subject: [PATCH 1/2] Polish navigation so sidebars and detail feel native. Remove programmatic sidebar auto-collapse, add a sidebar toggle, push detail via NavigationStack, persist inspector visibility, and drop custom inspector chrome. Co-authored-by: Cursor --- Microfiche/ContentView.swift | 50 +++++++++++++----------------- Microfiche/Views/SidebarView.swift | 3 +- Microfiche/Views/WidthReader.swift | 22 ------------- design.md | 6 ++-- 4 files changed, 25 insertions(+), 56 deletions(-) delete mode 100644 Microfiche/Views/WidthReader.swift diff --git a/Microfiche/ContentView.swift b/Microfiche/ContentView.swift index de8fdb4..72486c7 100644 --- a/Microfiche/ContentView.swift +++ b/Microfiche/ContentView.swift @@ -69,9 +69,11 @@ struct ContentView: View { static let minimumWidth: CGFloat = 240 static let idealWidth: CGFloat = 280 static let maximumWidth: CGFloat = 360 - static let autoCollapseWidth: CGFloat = 220 } + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.toggleSidebar) private var toggleSidebar + @State private var selection: Selection? @State private var imageFiles: [ImageFile] = [] @State private var libraryLoadGeneration = UUID() @@ -88,7 +90,7 @@ struct ContentView: View { @State private var scrollToID: UUID? @State private var gridColumnCount: Int = 1 @State private var detailViewFile: ImageFile? - @State private var isMetadataInspectorPresented = true + @AppStorage("isMetadataInspectorPresented") private var isMetadataInspectorPresented = true @State private var splitViewVisibility: NavigationSplitViewVisibility = .all @State private var externalDriveNotice: String? @AppStorage("lastSelectedLibraryFolderID") private var lastSelectedLibraryFolderID = "" @@ -110,7 +112,6 @@ struct ContentView: View { externalVolumes: libraryStorage.rememberedExternalVolumes, contactSheets: contactSheetStorage.contactSheets, selection: selection, - onWidthChange: handleSidebarWidthChange, onLinkFolder: linkFolder, onSelect: { newSelection in selection = newSelection @@ -140,7 +141,7 @@ struct ContentView: View { max: SidebarLayout.maximumWidth ) } detail: { - ZStack { + NavigationStack { MainContentView( imageFiles: imageFiles, unavailableLocation: unavailableSelectedFolder, @@ -157,20 +158,14 @@ struct ContentView: View { contactSheets: contactSheetStorage.contactSheets, onAddToContactSheet: handleAddToContactSheet ) - .opacity(detailViewFile == nil ? 1 : 0) - .allowsHitTesting(detailViewFile == nil) - .accessibilityHidden(detailViewFile != nil) - - if let detailFile = detailViewFile { + .navigationDestination(item: $detailViewFile) { file in ImageDetailView( - file: detailFile, + file: file, isInspectorPresented: $isMetadataInspectorPresented, onBack: closeImageDetail ) - .transition(.opacity) } } - .animation(MicroficheMotion.transition, value: detailViewFile?.id) .inspector(isPresented: $isMetadataInspectorPresented) { Group { if let focusedImageFile { @@ -185,13 +180,22 @@ struct ContentView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .microficheSidebarChrome() .inspectorColumnWidth(min: 280, ideal: 320, max: 420) } } .navigationTitle("") .toolbar { if detailViewFile == nil { + ToolbarItem { + Button(action: toggleSidebar) { + Image(systemName: "sidebar.left") + } + .help("Toggle Sidebar") + .accessibilityLabel("Toggle sidebar") + .accessibilityIdentifier("sidebar.toggle") + } + .hideSharedBackgroundIfAvailable() + ToolbarItem(placement: .principal) { if viewMode == .grid { HStack(spacing: 6) { @@ -229,6 +233,8 @@ struct ContentView: View { Image(systemName: "sidebar.right") } .help(isMetadataInspectorPresented ? "Hide Info" : "Show Info") + .accessibilityLabel(isMetadataInspectorPresented ? "Hide inspector" : "Show inspector") + .accessibilityIdentifier("inspector.toggle") } .hideSharedBackgroundIfAvailable() } @@ -455,16 +461,6 @@ struct ContentView: View { } } - private func handleSidebarWidthChange(_ width: CGFloat) { - guard splitViewVisibility != .detailOnly else { return } - - if width < SidebarLayout.autoCollapseWidth { - withAnimation(MicroficheMotion.transition) { - splitViewVisibility = .detailOnly - } - } - } - // MARK: - Contact Sheets private func handleDropToContactSheet(sheetID: UUID, urls: [URL]) { @@ -554,16 +550,12 @@ struct ContentView: View { isQuickPreviewPresented = false selectedImageFileIDs = [fileID] focusedImageFileID = fileID - withAnimation(MicroficheMotion.transition) { - detailViewFile = file - // Keep the metadata inspector available for editing Finder labels/tags. - isMetadataInspectorPresented = true - } + detailViewFile = file } } private func closeImageDetail() { - withAnimation(MicroficheMotion.transition) { + withAnimation(MicroficheMotion.transition(reducedMotion: reduceMotion)) { detailViewFile = nil } requestScrollToFocusedImage() diff --git a/Microfiche/Views/SidebarView.swift b/Microfiche/Views/SidebarView.swift index 1ff417f..001c6e8 100644 --- a/Microfiche/Views/SidebarView.swift +++ b/Microfiche/Views/SidebarView.swift @@ -15,7 +15,6 @@ struct SidebarView: View { let externalVolumes: [RememberedExternalVolume] let contactSheets: [ContactSheet] let selection: Selection? - let onWidthChange: (CGFloat) -> Void let onLinkFolder: () -> Void let onSelect: (Selection) -> Void let onRemoveFolder: (UUID) -> Void @@ -115,7 +114,7 @@ struct SidebarView: View { } .scrollIndicators(.hidden) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background(WidthReader(onChange: onWidthChange)) + .accessibilityIdentifier("library.sidebar") } private var folderSectionDetail: String { diff --git a/Microfiche/Views/WidthReader.swift b/Microfiche/Views/WidthReader.swift deleted file mode 100644 index 59726d2..0000000 --- a/Microfiche/Views/WidthReader.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// WidthReader.swift -// Microfiche -// -// Created by David Hoang on 6/8/25. -// - -import SwiftUI - -struct WidthReader: View { - let onChange: (CGFloat) -> Void - - var body: some View { - GeometryReader { geo in - Color.clear - .onAppear { onChange(geo.size.width) } - .onChange(of: geo.size.width) { _, newWidth in - onChange(newWidth) - } - } - } -} diff --git a/design.md b/design.md index efc2f13..d923969 100644 --- a/design.md +++ b/design.md @@ -53,9 +53,9 @@ Rules derived from `ContentView`, `SidebarView`, and `ImageDetailView`. 1. **Two-column shell + inspector** — Use `NavigationSplitView` (sidebar + detail). Metadata lives in a detail-attached `.inspector`, not a third permanent split column. 2. **Sidebar owns library location** — Selection is a single enum: All Images, Folder, or Contact Sheet. External drive rows are status only — not navigation targets. 3. **Location change clears browsing state** — On library selection change, clear image selection, focus, quick preview, and detail view. -4. **Detail is a canvas overlay** — Double-click opens `ImageDetailView` over the library detail column (opacity / hit-testing), not a pushed navigation destination or new column. +4. **Detail pushes on the detail stack** — Double-click opens `ImageDetailView` via `NavigationStack` + `navigationDestination`, not an opacity overlay or new split column. 5. **Inspector stays with detail** — Entering detail keeps the metadata inspector available so Finder labels, tags, and comments can be edited while viewing. -6. **Sidebar is collapsible** — Width 240–360 (ideal 280). Auto-collapse to `.detailOnly` below 220 pt measured width. +6. **Sidebar is collapsible** — Width 240–360 (ideal 280). Use the system split-view divider and sidebar toggle; do not programmatically override `columnVisibility` during resize. 7. **Unified chrome** — Window uses unified toolbar with no title. Keep `.navigationTitle("")` unless a mode truly needs a title. 8. **Window scale** — Minimum about 1100×700; default launch size stays large enough for sidebar + grid + inspector. @@ -190,7 +190,7 @@ Disable animations while the grid size slider is dragging. Prefer `MicroficheMot ### Sidebar - **File:** `Microfiche/Views/SidebarView.swift` -- Custom scroll sections; width reported via `WidthReader` +- Custom scroll sections; collapsible via the system split-view divider and sidebar toggle ### Library canvas - **File:** `Microfiche/Views/MainContentView.swift` From 1a5c09663cba6ec35d00553acadc621278fbc1ec Mon Sep 17 00:00:00 2001 From: David Hoang Date: Wed, 29 Jul 2026 06:58:10 -0700 Subject: [PATCH 2/2] Add library search and metadata filtering. Toolbar search matches filenames and local metadata, with file type and tag filters, filtered empty states, and UI test coverage for the new controls. Co-authored-by: Cursor --- Microfiche/ContentView.swift | 108 +++++++++++++++--- Microfiche/Models/LibraryLocation.swift | 10 ++ Microfiche/Services/ImageMetadataStore.swift | 13 +++ Microfiche/Services/LibraryFiltering.swift | 43 +++++++ Microfiche/Views/MainContentView.swift | 44 ++++++- Microfiche/Views/SidebarView.swift | 3 + MicroficheTests/MicroficheTests.swift | 28 +++++ MicroficheUITests/MicroficheUITests.swift | 53 +++++++-- .../MicroficheUITestsLaunchTests.swift | 1 + 9 files changed, 273 insertions(+), 30 deletions(-) create mode 100644 Microfiche/Services/LibraryFiltering.swift diff --git a/Microfiche/ContentView.swift b/Microfiche/ContentView.swift index 72486c7..242a366 100644 --- a/Microfiche/ContentView.swift +++ b/Microfiche/ContentView.swift @@ -93,10 +93,14 @@ struct ContentView: View { @AppStorage("isMetadataInspectorPresented") private var isMetadataInspectorPresented = true @State private var splitViewVisibility: NavigationSplitViewVisibility = .all @State private var externalDriveNotice: String? + @State private var searchText = "" + @State private var selectedFileType = "" + @State private var selectedTag = "" @AppStorage("lastSelectedLibraryFolderID") private var lastSelectedLibraryFolderID = "" @StateObject private var libraryStorage = LibraryStorage.shared @StateObject private var contactSheetStorage = ContactSheetStorage.shared @StateObject private var userPreferences = UserPreferences.shared + @StateObject private var metadataStore = ImageMetadataStore.shared let supportedExtensions = ["jpg", "jpeg", "png", "pdf", "svg", "gif", "tiff"] @@ -143,8 +147,12 @@ struct ContentView: View { } detail: { NavigationStack { MainContentView( - imageFiles: imageFiles, + imageFiles: displayedImageFiles, unavailableLocation: unavailableSelectedFolder, + isFiltering: hasActiveFilter, + onRetryUnavailableLocation: { + libraryStorage.refreshLocations(saveAfterRefresh: true) + }, showsToolbar: detailViewFile == nil, viewMode: $viewMode, gridThumbnailSize: displayedGridThumbnailSize, @@ -237,8 +245,14 @@ struct ContentView: View { .accessibilityIdentifier("inspector.toggle") } .hideSharedBackgroundIfAvailable() + + ToolbarItem { + filterMenu + } + .hideSharedBackgroundIfAvailable() } } + .searchable(text: $searchText, placement: .toolbar, prompt: "Search library") .onChange(of: selection) { _, newValue in switch newValue { case .all: @@ -356,14 +370,79 @@ struct ContentView: View { .animation(MicroficheMotion.snap, value: isQuickPreviewPresented) .animation(MicroficheMotion.transition, value: userPreferences.isPresentingOnboarding) .task { - restoreLibrarySelection() + if !ProcessInfo.processInfo.arguments.contains("--ui-testing") { + restoreLibrarySelection() + } userPreferences.evaluateLaunchPresentation() } } private var focusedImageFile: ImageFile? { guard let focusedImageFileID else { return nil } - return imageFiles.first { $0.id == focusedImageFileID } + return displayedImageFiles.first { $0.id == focusedImageFileID } + ?? imageFiles.first { $0.id == focusedImageFileID } + } + + private var hasActiveFilter: Bool { + !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !selectedFileType.isEmpty + || !selectedTag.isEmpty + } + + private var displayedImageFiles: [ImageFile] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + return imageFiles.filter { file in + let metadata = metadataStore.metadata(for: file.url) + return LibraryFiltering.matches( + file: file, + metadata: metadata, + query: query, + fileType: selectedFileType, + tag: selectedTag + ) + } + } + + private var availableFileTypes: [String] { + Set(imageFiles.map { $0.url.pathExtension.lowercased() }) + .filter { !$0.isEmpty } + .sorted() + } + + private var availableTags: [String] { + metadataStore.allTags(for: imageFiles.map(\.url)) + } + + private var filterMenu: some View { + Menu { + Picker("File Type", selection: $selectedFileType) { + Text("All File Types").tag("") + ForEach(availableFileTypes, id: \.self) { fileType in + Text(fileType.uppercased()).tag(fileType) + } + } + + Picker("Tag", selection: $selectedTag) { + Text("All Tags").tag("") + ForEach(availableTags, id: \.self) { tag in + Text(tag).tag(tag) + } + } + + Divider() + Button("Clear Filters") { + selectedFileType = "" + selectedTag = "" + searchText = "" + } + .disabled(!hasActiveFilter) + } label: { + Image(systemName: hasActiveFilter ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") + } + .help("Filter Library") + .accessibilityLabel("Filter library") + .accessibilityValue(hasActiveFilter ? "Filters active" : "No filters") + .accessibilityIdentifier("library.filter") } private var unavailableSelectedFolder: LinkedLibraryFolder? { @@ -522,14 +601,14 @@ struct ContentView: View { if NSApp.currentEvent?.modifierFlags.contains(.shift) == true, let lastID = focusedImageFileID, - let lastIndex = imageFiles.firstIndex(where: { $0.id == lastID }), - let currentIndex = imageFiles.firstIndex(where: { $0.id == fileID }) { + let lastIndex = displayedImageFiles.firstIndex(where: { $0.id == lastID }), + let currentIndex = displayedImageFiles.firstIndex(where: { $0.id == fileID }) { let range = min(lastIndex, currentIndex)...max(lastIndex, currentIndex) - selectedImageFileIDs = Set(imageFiles[range].map { $0.id }) + selectedImageFileIDs = Set(displayedImageFiles[range].map { $0.id }) } else if NSApp.currentEvent?.modifierFlags.contains(.command) == true { if selectedImageFileIDs.contains(fileID) { selectedImageFileIDs.remove(fileID) - nextFocusedID = imageFiles.first { + nextFocusedID = displayedImageFiles.first { selectedImageFileIDs.contains($0.id) }?.id } else { @@ -540,13 +619,13 @@ struct ContentView: View { } focusedImageFileID = nextFocusedID - if let file = imageFiles.first(where: { $0.id == fileID }) { + if let file = displayedImageFiles.first(where: { $0.id == fileID }) { PreviewImageCache.shared.preloadImage(for: file.url) } } private func handleDoubleClickImage(for fileID: UUID) { - if let file = imageFiles.first(where: { $0.id == fileID }) { + if let file = displayedImageFiles.first(where: { $0.id == fileID }) { isQuickPreviewPresented = false selectedImageFileIDs = [fileID] focusedImageFileID = fileID @@ -661,11 +740,12 @@ struct ContentView: View { // MARK: - Navigation private func handleArrowKey(_ direction: ArrowDirection) { - guard !imageFiles.isEmpty else { return } + let navigableFiles = displayedImageFiles + guard !navigableFiles.isEmpty else { return } guard let currentFocusedID = focusedImageFileID, - let currentIndex = imageFiles.firstIndex(where: { $0.id == currentFocusedID }) else { - if let firstFile = imageFiles.first { + let currentIndex = navigableFiles.firstIndex(where: { $0.id == currentFocusedID }) else { + if let firstFile = navigableFiles.first { selectedImageFileIDs = [firstFile.id] self.focusedImageFileID = firstFile.id scrollToID = firstFile.id @@ -675,7 +755,7 @@ struct ContentView: View { guard let nextIndex = nextImageIndex(from: currentIndex, direction: direction) else { return } - let nextFile = imageFiles[nextIndex] + let nextFile = navigableFiles[nextIndex] if isQuickPreviewPresented || detailViewFile != nil { selectedImageFileIDs = [nextFile.id] } else if NSApp.currentEvent?.modifierFlags.contains(.shift) == true { @@ -694,7 +774,7 @@ struct ContentView: View { private func nextImageIndex(from currentIndex: Int, direction: ArrowDirection) -> Int? { ImageNavigation.nextIndex( from: currentIndex, - itemCount: imageFiles.count, + itemCount: displayedImageFiles.count, direction: direction, viewMode: viewMode, gridColumnCount: gridColumnCount diff --git a/Microfiche/Models/LibraryLocation.swift b/Microfiche/Models/LibraryLocation.swift index 8755738..eba09d2 100644 --- a/Microfiche/Models/LibraryLocation.swift +++ b/Microfiche/Models/LibraryLocation.swift @@ -23,6 +23,12 @@ struct LinkedLibraryFolder: Identifiable, Equatable { fallback: name ) } + + var isICloudDrive: Bool { + LibraryLocationPresentation.isICloudDrivePath( + resolvedURL ?? URL(fileURLWithPath: originalPath) + ) + } } struct RememberedExternalVolume: Identifiable, Codable, Equatable { @@ -67,4 +73,8 @@ enum LibraryLocationPresentation { let relativeComponents = pathComponents.dropFirst(iCloudDriveIndex + 1) return relativeComponents.last ?? "iCloud Drive" } + + static func isICloudDrivePath(_ url: URL) -> Bool { + url.standardizedFileURL.pathComponents.contains(iCloudDriveDirectoryName) + } } diff --git a/Microfiche/Services/ImageMetadataStore.swift b/Microfiche/Services/ImageMetadataStore.swift index 279e791..c8758ba 100644 --- a/Microfiche/Services/ImageMetadataStore.swift +++ b/Microfiche/Services/ImageMetadataStore.swift @@ -102,6 +102,19 @@ final class ImageMetadataStore { } } + func allTags(for urls: [URL]) -> [String] { + var seen = Set() + var tags: [String] = [] + for url in urls { + for tag in metadata(for: url).tags { + guard !seen.contains(tag) else { continue } + seen.insert(tag) + tags.append(tag) + } + } + return tags.sorted { $0.localizedStandardCompare($1) == .orderedAscending } + } + // MARK: - Persistence private func load() { diff --git a/Microfiche/Services/LibraryFiltering.swift b/Microfiche/Services/LibraryFiltering.swift new file mode 100644 index 0000000..21fe9ba --- /dev/null +++ b/Microfiche/Services/LibraryFiltering.swift @@ -0,0 +1,43 @@ +// +// LibraryFiltering.swift +// Microfiche +// + +import Foundation + +enum LibraryFiltering { + static func matches( + file: ImageFile, + metadata: ImageMetadata, + query: String, + fileType: String, + tag: String + ) -> Bool { + if !fileType.isEmpty, + file.url.pathExtension.lowercased() != fileType.lowercased() { + return false + } + + if !tag.isEmpty { + let normalizedTag = tag.lowercased() + let hasTag = metadata.tags.contains { $0.lowercased() == normalizedTag } + if !hasTag { return false } + } + + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedQuery.isEmpty else { return true } + + let normalizedQuery = trimmedQuery.lowercased() + if file.name.localizedStandardContains(normalizedQuery) { return true } + if metadata.comments.localizedStandardContains(normalizedQuery) { return true } + if metadata.whereFrom.localizedStandardContains(normalizedQuery) { return true } + if metadata.tags.contains(where: { $0.localizedStandardContains(normalizedQuery) }) { + return true + } + if metadata.labels.contains(where: { $0.localizedStandardContains(normalizedQuery) }) { + return true + } + + return false + } +} diff --git a/Microfiche/Views/MainContentView.swift b/Microfiche/Views/MainContentView.swift index de01514..9aa701b 100644 --- a/Microfiche/Views/MainContentView.swift +++ b/Microfiche/Views/MainContentView.swift @@ -13,6 +13,8 @@ import UniformTypeIdentifiers struct MainContentView: View { let imageFiles: [ImageFile] let unavailableLocation: LinkedLibraryFolder? + let isFiltering: Bool + let onRetryUnavailableLocation: () -> Void let showsToolbar: Bool @Binding var viewMode: ViewMode let gridThumbnailSize: CGFloat @@ -37,7 +39,11 @@ struct MainContentView: View { VStack { if imageFiles.isEmpty { Spacer(minLength: 24) - EmptyLibraryStateView(unavailableLocation: unavailableLocation) + EmptyLibraryStateView( + unavailableLocation: unavailableLocation, + isFiltering: isFiltering, + onRetryUnavailableLocation: onRetryUnavailableLocation + ) Spacer(minLength: 24) } else { if viewMode == .grid { @@ -118,6 +124,9 @@ private struct FloatingViewModeControl: View { } } .accessibilityValue(selection == mode ? "Selected" : "") + .accessibilityAddTraits(selection == mode ? [.isSelected] : []) + .accessibilityIdentifier("viewMode.\(mode.rawValue.lowercased())") + .keyboardShortcut(mode == .grid ? "1" : "2", modifiers: .command) } } .padding(3) @@ -146,17 +155,19 @@ private extension View { private struct EmptyLibraryStateView: View { let unavailableLocation: LinkedLibraryFolder? + let isFiltering: Bool + let onRetryUnavailableLocation: () -> Void var body: some View { VStack(spacing: 14) { - Image(systemName: unavailableLocation == nil ? "photo.on.rectangle.angled" : "externaldrive.badge.xmark") + Image(systemName: emptyStateIcon) .font(.system(size: 36, weight: .medium)) .foregroundStyle(.secondary) .symbolRenderingMode(.hierarchical) .frame(width: 56, height: 56) VStack(spacing: 6) { - Text(unavailableLocation == nil ? "No images yet" : "Reconnect the drive") + Text(emptyStateTitle) .font(.system(size: 22, weight: .semibold)) Text(emptyStateMessage) @@ -165,17 +176,41 @@ private struct EmptyLibraryStateView: View { .multilineTextAlignment(.center) .frame(maxWidth: 360) } + + if unavailableLocation != nil, !isFiltering { + Button("Try Again", action: onRetryUnavailableLocation) + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + } } .padding(.horizontal, 24) } private var emptyStateMessage: String { + if isFiltering { + return "Try a different name, tag, file type, or clear the active filters." + } guard let unavailableLocation else { return "Link a folder or drop images into a contact sheet to start building a library." } + if unavailableLocation.isICloudDrive { + return "Check your network connection and iCloud Drive status, then try again." + } let driveName = unavailableLocation.volumeName ?? unavailableLocation.name - return "Reconnect \(driveName) to restore \(unavailableLocation.name) automatically." + return "Reconnect \(driveName) to restore \(unavailableLocation.displayName) automatically." + } + + private var emptyStateIcon: String { + if isFiltering { return "magnifyingglass" } + guard let unavailableLocation else { return "photo.on.rectangle.angled" } + return unavailableLocation.isICloudDrive ? "icloud.slash" : "externaldrive.badge.xmark" + } + + private var emptyStateTitle: String { + if isFiltering { return "No matches" } + guard let unavailableLocation else { return "No images yet" } + return unavailableLocation.isICloudDrive ? "iCloud Drive unavailable" : "Reconnect the drive" } } @@ -286,6 +321,7 @@ struct ImageGridView: View { } } .animation(isResizing ? nil : MicroficheMotion.snap, value: thumbnailSize) + .accessibilityIdentifier("image.grid") } private func updateColumnCount(for width: CGFloat) { diff --git a/Microfiche/Views/SidebarView.swift b/Microfiche/Views/SidebarView.swift index 001c6e8..ff072df 100644 --- a/Microfiche/Views/SidebarView.swift +++ b/Microfiche/Views/SidebarView.swift @@ -134,6 +134,9 @@ struct SidebarView: View { private func folderSubtitle(_ folder: LinkedLibraryFolder) -> String? { if !folder.isAvailable { + if folder.isICloudDrive { + return "iCloud unavailable" + } return folder.isExternal ? "\(folder.volumeName ?? "External drive") • Offline" : "Unavailable" diff --git a/MicroficheTests/MicroficheTests.swift b/MicroficheTests/MicroficheTests.swift index 0be053f..c8a53cb 100644 --- a/MicroficheTests/MicroficheTests.swift +++ b/MicroficheTests/MicroficheTests.swift @@ -267,6 +267,34 @@ final class MicroficheTests: XCTestCase { ) } + func testLibraryFilteringMatchesNamesTypesAndMetadata() { + let file = ImageFile(url: URL(fileURLWithPath: "/Photos/sunset.JPG")) + let metadata = ImageMetadata( + tags: ["Travel"], + labels: ["Favorite"], + comments: "Golden hour", + whereFrom: "Seattle" + ) + + XCTAssertTrue(LibraryFiltering.matches( + file: file, metadata: metadata, query: "golden", fileType: "jpg", tag: "travel" + )) + XCTAssertFalse(LibraryFiltering.matches( + file: file, metadata: metadata, query: "golden", fileType: "png", tag: "travel" + )) + XCTAssertFalse(LibraryFiltering.matches( + file: file, metadata: metadata, query: "desert", fileType: "jpg", tag: "" + )) + } + + func testLibraryLocationPresentationRecognizesICloudDrivePaths() { + let url = URL(fileURLWithPath: "/Users/test/Library/Mobile Documents/com~apple~CloudDocs/Photos") + XCTAssertTrue(LibraryLocationPresentation.isICloudDrivePath(url)) + XCTAssertFalse(LibraryLocationPresentation.isICloudDrivePath( + URL(fileURLWithPath: "/Users/test/Pictures") + )) + } + func testICloudDriveRootUsesFriendlyDisplayName() { let folder = LinkedLibraryFolder( id: UUID(), diff --git a/MicroficheUITests/MicroficheUITests.swift b/MicroficheUITests/MicroficheUITests.swift index 2b2da3e..52a4955 100644 --- a/MicroficheUITests/MicroficheUITests.swift +++ b/MicroficheUITests/MicroficheUITests.swift @@ -9,34 +9,63 @@ import XCTest final class MicroficheUITests: XCTestCase { - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. + private func makeApp() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments.append("--ui-testing") + return app + } - // In UI tests it is usually best to stop immediately when a failure occurs. - continueAfterFailure = false + private func element(_ identifier: String, in app: XCUIApplication) -> XCUIElement { + app.descendants(matching: .any).matching(identifier: identifier).firstMatch + } - // In UI tests it's important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + override func setUpWithError() throws { + continueAfterFailure = false } override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. } @MainActor - func testExample() throws { - // UI tests must launch the application that they test. - let app = XCUIApplication() + func testPrimaryNavigationControls() throws { + let app = makeApp() + app.launch() + + XCTAssertTrue(element("library.sidebar", in: app).waitForExistence(timeout: 3)) + XCTAssertTrue(element("sidebar.toggle", in: app).waitForExistence(timeout: 3)) + + let gridButton = element("viewMode.grid", in: app) + let listButton = element("viewMode.list", in: app) + XCTAssertTrue(gridButton.waitForExistence(timeout: 3)) + XCTAssertTrue(listButton.exists) + XCTAssertTrue(element("library.filter", in: app).exists) + XCTAssertTrue(element("inspector.toggle", in: app).exists) + + listButton.click() + XCTAssertTrue(listButton.isSelected) + + gridButton.click() + XCTAssertTrue(gridButton.isSelected) + } + + @MainActor + func testInspectorCanBeToggled() throws { + let app = makeApp() app.launch() - // Use XCTAssert and related functions to verify your tests produce the correct results. + let inspectorButton = element("inspector.toggle", in: app) + XCTAssertTrue(inspectorButton.waitForExistence(timeout: 3)) + inspectorButton.click() + XCTAssertTrue(inspectorButton.waitForExistence(timeout: 2)) + inspectorButton.click() + XCTAssertTrue(inspectorButton.exists) } @MainActor func testLaunchPerformance() throws { if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { - // This measures how long it takes to launch your application. measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() + makeApp().launch() } } } diff --git a/MicroficheUITests/MicroficheUITestsLaunchTests.swift b/MicroficheUITests/MicroficheUITestsLaunchTests.swift index cdc455e..08f7504 100644 --- a/MicroficheUITests/MicroficheUITestsLaunchTests.swift +++ b/MicroficheUITests/MicroficheUITestsLaunchTests.swift @@ -20,6 +20,7 @@ final class MicroficheUITestsLaunchTests: XCTestCase { @MainActor func testLaunch() throws { let app = XCUIApplication() + app.launchArguments.append("--ui-testing") app.launch() // Insert steps here to perform after app launch but before taking a screenshot,