diff --git a/Sources/Nativ/Features/Artifacts/ArtifactPreview.swift b/Sources/Nativ/Features/Artifacts/ArtifactPreview.swift index 69d4aca5..5e9502bf 100644 --- a/Sources/Nativ/Features/Artifacts/ArtifactPreview.swift +++ b/Sources/Nativ/Features/Artifacts/ArtifactPreview.swift @@ -6,6 +6,7 @@ struct ArtifactPreview: View { let artifacts: [Artifact] @Binding var selectedID: Artifact.ID? let fileURL: (Artifact) -> URL + let displayName: (Artifact) -> String let onClose: () -> Void let onOpenChat: (Artifact) -> Void @@ -56,7 +57,7 @@ struct ArtifactPreview: View { .foregroundStyle(.white.opacity(0.7)) } VStack(alignment: .leading, spacing: 2) { - Text(artifact.filename) + Text(displayName(artifact)) .legacyTextStyle(.compactCardTitle) .foregroundStyle(.white) .lineLimit(1) diff --git a/Sources/Nativ/Features/Artifacts/ArtifactStore.swift b/Sources/Nativ/Features/Artifacts/ArtifactStore.swift index c423ccd1..dc45f0fb 100644 --- a/Sources/Nativ/Features/Artifacts/ArtifactStore.swift +++ b/Sources/Nativ/Features/Artifacts/ArtifactStore.swift @@ -126,6 +126,31 @@ final class ArtifactStore: ObservableObject { return artifact.filename } + func sortedByName(_ artifacts: [Artifact]) -> [Artifact] { + artifacts.sorted { + let comparison = displayName(for: $0).localizedCaseInsensitiveCompare(displayName(for: $1)) + return comparison == .orderedSame + ? $0.id.uuidString < $1.id.uuidString + : comparison == .orderedAscending + } + } + + func searchResults(in artifacts: [Artifact], query: String, semanticMatches: [UUID]?) -> [Artifact] { + let query = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !query.isEmpty else { return artifacts } + let directMatches = artifacts.filter { + displayName(for: $0).lowercased().contains(query) || $0.searchText.contains(query) + } + guard let semanticMatches else { return directMatches } + let directIDs = Set(directMatches.map(\.id)) + let candidates = Dictionary(artifacts.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + var seen = directIDs + return directMatches + semanticMatches.compactMap { id in + guard seen.insert(id).inserted else { return nil } + return candidates[id] + } + } + func rename(_ artifact: Artifact, to name: String) { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty || trimmed == artifact.filename { diff --git a/Sources/Nativ/Features/Artifacts/ArtifactsView.swift b/Sources/Nativ/Features/Artifacts/ArtifactsView.swift index efefc316..9de13128 100644 --- a/Sources/Nativ/Features/Artifacts/ArtifactsView.swift +++ b/Sources/Nativ/Features/Artifacts/ArtifactsView.swift @@ -150,21 +150,12 @@ struct ArtifactsView: View { if dateFilter != .all { result = result.filter { dateFilter.includes($0.createdAt) } } - let query = search.trimmingCharacters(in: .whitespacesAndNewlines) - if query.isEmpty { - return result.sorted(by: sort.comparator) - } - if let semanticMatches, smartSearchActive { - var rank: [UUID: Int] = [:] - for (position, id) in semanticMatches.enumerated() { - rank[id] = position - } - return result - .filter { rank[$0.id] != nil } - .sorted { (rank[$0.id] ?? .max) < (rank[$1.id] ?? .max) } - } - let lowered = query.lowercased() - return result.filter { $0.searchText.contains(lowered) }.sorted(by: sort.comparator) + let sorted = sort == .name ? store.sortedByName(result) : result.sorted(by: sort.comparator) + return store.searchResults( + in: sorted, + query: search, + semanticMatches: smartSearchActive ? semanticMatches : nil + ) } private var visibleArtifactIDs: Set { @@ -503,6 +494,7 @@ struct ArtifactsView: View { artifacts: filtered, selectedID: $previewID, fileURL: store.fileURL, + displayName: store.displayName, onClose: { previewID = nil }, onOpenChat: { artifact in previewID = nil @@ -1196,7 +1188,7 @@ private final class SelectionDragNSView: NSView, NSDraggingSource { struct ArtifactTile: View { let artifact: Artifact - let store: ArtifactStore + @ObservedObject var store: ArtifactStore let isSelecting: Bool let isSelected: Bool let isFavorite: Bool @@ -1291,7 +1283,7 @@ struct ArtifactTile: View { struct ArtifactRow: View { let artifact: Artifact - let store: ArtifactStore + @ObservedObject var store: ArtifactStore let isSelecting: Bool let isSelected: Bool @@ -1309,7 +1301,7 @@ struct ArtifactRow: View { .clipShape(RoundedRectangle(cornerRadius: 5)) VStack(alignment: .leading, spacing: 2) { - Text(artifact.filename) + Text(store.displayName(for: artifact)) .legacyTextStyle(.rowTitle) .lineLimit(1) Text("\(artifact.typeLabel) ยท \(artifact.source.label)") @@ -1431,7 +1423,7 @@ private struct ArtifactThumbnail: View { struct ArtifactInspector: View { let artifact: Artifact - let store: ArtifactStore + @ObservedObject var store: ArtifactStore let onOpenPreview: () -> Void let onGoToChat: () -> Void let onClose: () -> Void @@ -1460,7 +1452,7 @@ struct ArtifactInspector: View { .clipShape(RoundedRectangle(cornerRadius: 8)) VStack(alignment: .leading, spacing: 8) { - detailRow("Name", artifact.filename) + detailRow("Name", store.displayName(for: artifact)) detailRow("Type", artifact.typeLabel) detailRow("Source", artifact.source.label) detailRow("Used in", "\(Set(artifact.locations.map(\.sessionID)).count) sessions") diff --git a/Tests/NativTests/ArtifactRefreshTests.swift b/Tests/NativTests/ArtifactRefreshTests.swift index 6d99c116..99b52d82 100644 --- a/Tests/NativTests/ArtifactRefreshTests.swift +++ b/Tests/NativTests/ArtifactRefreshTests.swift @@ -94,6 +94,44 @@ final class ArtifactRefreshTests: XCTestCase { XCTAssertEqual(source.state.withLock { $0.scans }, 0) } + func testRenamePersistsAndSearchIncludesNamesOutsideSemanticResults() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let hub = PersistedDataChangeHub() + let source = Source() + let store = makeStore(directory: directory, hub: hub, source: source) + let renamed = makeArtifact() + let semantic = makeArtifact() + let excluded = makeArtifact() + let originalURL = store.fileURL(for: renamed) + let originalBytes = Data("Original file content".utf8) + try FileManager.default.createDirectory(at: originalURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try originalBytes.write(to: originalURL) + store.rename(renamed, to: " Amber cube ") + store.rename(semantic, to: "Zebra") + + let reloaded = makeStore(directory: directory, hub: hub, source: source) + XCTAssertEqual(reloaded.displayName(for: renamed), "Amber cube") + XCTAssertEqual(renamed.filename, "image.png") + XCTAssertEqual(reloaded.sortedByName([semantic, renamed]).map(\.id), [renamed.id, semantic.id]) + XCTAssertEqual(reloaded.searchResults(in: [renamed], query: "AMBER", semanticMatches: nil).map(\.id), [renamed.id]) + XCTAssertEqual(reloaded.searchResults(in: [renamed], query: "image.png", semanticMatches: nil).map(\.id), [renamed.id]) + XCTAssertEqual(reloaded.searchResults(in: [renamed, semantic], query: " ", semanticMatches: []).map(\.id), [renamed.id, semantic.id]) + XCTAssertEqual( + reloaded.searchResults( + in: [semantic, renamed], query: "amber", + semanticMatches: [excluded.id, semantic.id, renamed.id, semantic.id] + ).map(\.id), + [renamed.id, semantic.id] + ) + reloaded.rename(renamed, to: " ") + XCTAssertEqual(reloaded.displayName(for: renamed), renamed.filename) + XCTAssertTrue(reloaded.searchResults(in: [renamed], query: "amber", semanticMatches: []).isEmpty) + XCTAssertEqual(makeStore(directory: directory, hub: hub, source: source).displayName(for: renamed), renamed.filename) + XCTAssertEqual(reloaded.fileURL(for: renamed), originalURL) + XCTAssertEqual(try Data(contentsOf: originalURL), originalBytes) + } + private func makeStore(directory: URL, hub: PersistedDataChangeHub, source: Source) -> ArtifactStore { ArtifactStore( storage: .init(