Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Sources/Nativ/Features/Artifacts/ArtifactPreview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions Sources/Nativ/Features/Artifacts/ArtifactStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 12 additions & 20 deletions Sources/Nativ/Features/Artifacts/ArtifactsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Artifact.ID> {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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)")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
38 changes: 38 additions & 0 deletions Tests/NativTests/ArtifactRefreshTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading