Repository navigation
iOS: complete chip files gallery — dedup, folders, streaming viewer, previews, gallery UX, QoL #8287
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
iOS: complete chip files gallery — dedup, folders, streaming viewer, previews, gallery UX, QoL #8287
Changes from all commits
21c826e
c4a1f16
0d3b3ce
ad349f9
771a2b7
08f2bbe
6a55ff2
d795475
6247776
95d3482
10bfcc7
14e153f
f0fc4ca
233fa7d
8e61ed4
e46ba3a
fd8e938
3dc89ef
d5febb6
af3be69
59f91a2
1683ae2
72e4a1d
c28b9bc
b9e6c53
3bafe46
a59c343
a47f696
284870b
aa5e744
3638eb7
7e755c5
20759ab
ff97474
316f15a
404c84a
a16cc28
2928f28
b0a0f64
f2f45bd
42a04b9
3d7b372
1d9a5c8
e790a3a
2593f14
e357450
db87311
c16c482
9b52805
2ea7162
c00c18e
ea92959
bf46870
65a8bcf
24ed390
024cbfd
0df1e97
063b3b8
246b090
0af7e1e
8362ddb
6188976
5a2b124
3c1414a
04cdfc1
74c7bb6
88d125c
baa852c
5be5740
5544b19
ed83c4f
191c06f
37cb16e
9c12bc2
b3044e1
7a8c57e
5d8e9b7
b46bad5
6839a26
35d18ed
0877122
fa3775c
30f7a9f
8a10687
be1bf85
9ebd2a0
82b61d5
62f57ba
fc22f84
7049e86
46db74b
ef9382b
1eec555
0573e03
8e8b80b
68048d4
813ca7a
21419ea
b778014
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,10 @@ import UniformTypeIdentifiers | |
| /// Authorization is intentionally outside this type. Callers must scope-check the | ||
| /// requested path before invoking these methods. | ||
| public struct ArtifactByteReader: Sendable { | ||
| /// Maximum immediate children returned by one directory-list request. | ||
| public static let maximumDirectoryEntryCount = 500 | ||
| private static let utf8SniffByteCount = 8 * 1024 | ||
|
|
||
| /// Filesystem/decoder failures surfaced by artifact RPC handlers. | ||
| public enum Error: Swift.Error, Sendable { | ||
| /// The scoped path no longer exists or cannot be statted. | ||
|
|
@@ -96,37 +100,45 @@ public struct ArtifactByteReader: Sendable { | |
| ) | ||
| } | ||
|
|
||
| /// Lists up to 500 immediate children for an already-authorized directory. | ||
| /// Lists up to ``maximumDirectoryEntryCount`` immediate children for an | ||
| /// already-authorized directory. | ||
| /// | ||
| /// One readdir pass collects child names; per-child filesystem metadata is | ||
| /// read only for the capped entries that the listing actually returns. | ||
| public func list(path: String) throws -> ChatArtifactDirectoryListing { | ||
| let stat = try stat(path: path) | ||
| guard stat.isDirectory else { throw Error.fileNotFound } | ||
| let url = URL(fileURLWithPath: path, isDirectory: true) | ||
| let entries = try FileManager.default.contentsOfDirectory( | ||
| at: url, | ||
| includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], | ||
| options: [] | ||
| ) | ||
| let listed = try entries | ||
| .sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } | ||
| .prefix(500) | ||
| .map { entry -> ChatArtifactDirectoryEntry in | ||
| let names = try FileManager.default.contentsOfDirectory(atPath: path) | ||
| let sortedNames = names.sorted { | ||
| $0.localizedStandardCompare($1) == .orderedAscending | ||
| } | ||
| let directoryURL = URL(fileURLWithPath: path, isDirectory: true) | ||
| let listed = try sortedNames | ||
| .prefix(Self.maximumDirectoryEntryCount) | ||
|
Comment on lines
+111
to
+117
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift Bound directory ordering before sorting. This materializes and sorts every child—O(n log n) time and O(n) memory—although only 500 names are returned. For large artifact folders, enumerate once and retain only the lexically smallest 500 entries in bounded storage. As per path instructions, flag “full-collection scans” and “repeated sort/filter/map work” over scalable user data. 🤖 Prompt for AI AgentsSources: Coding guidelines, Path instructions |
||
| .map { name -> ChatArtifactDirectoryEntry in | ||
| let entry = directoryURL.appendingPathComponent(name) | ||
| let values = try entry.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey]) | ||
| let isDirectory = values.isDirectory ?? false | ||
| return ChatArtifactDirectoryEntry( | ||
| name: entry.lastPathComponent, | ||
| name: name, | ||
| isDirectory: isDirectory, | ||
| size: Int64(values.fileSize ?? 0), | ||
| kind: kind(path: entry.path, isDirectory: isDirectory) | ||
| ) | ||
| } | ||
| return ChatArtifactDirectoryListing(entries: listed) | ||
| return ChatArtifactDirectoryListing( | ||
| entries: listed, | ||
| isTruncated: names.count > Self.maximumDirectoryEntryCount | ||
| ) | ||
| } | ||
|
|
||
| /// Infers preview category from a path extension and directory flag. | ||
| /// Infers preview category from a path extension and a bounded UTF-8 sniff. | ||
| public func kind(path: String, isDirectory: Bool) -> ChatArtifactKind { | ||
| if isDirectory { return .directory } | ||
| guard let type = UTType(filenameExtension: URL(fileURLWithPath: path).pathExtension) else { | ||
| return .binary | ||
| let fileExtension = URL(fileURLWithPath: path).pathExtension | ||
| let type = fileExtension.isEmpty ? nil : UTType(filenameExtension: fileExtension) | ||
| guard let type, !type.isDynamic else { | ||
| return isUTF8Text(path: path) ? .text : .binary | ||
| } | ||
| if type.conforms(to: .image) { return .image } | ||
| if type.conforms(to: .text) || type.conforms(to: .sourceCode) || type.conforms(to: .json) { | ||
|
|
@@ -135,6 +147,80 @@ public struct ArtifactByteReader: Sendable { | |
| return .binary | ||
| } | ||
|
|
||
| private func isUTF8Text(path: String) -> Bool { | ||
| guard let handle = FileHandle(forReadingAtPath: path) else { | ||
| return false | ||
| } | ||
| defer { try? handle.close() } | ||
| let bytes: Data | ||
| do { | ||
| bytes = try handle.read(upToCount: Self.utf8SniffByteCount + 1) ?? Data() | ||
| } catch { | ||
| return false | ||
| } | ||
| let sample = Data(bytes.prefix(Self.utf8SniffByteCount)) | ||
| if String(data: sample, encoding: .utf8) != nil { | ||
| return true | ||
| } | ||
| guard bytes.count > Self.utf8SniffByteCount else { | ||
| return false | ||
| } | ||
| return hasValidUTF8PrefixEndingInPartialScalar(sample) | ||
|
Comment on lines
+150
to
+168
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Validate the bytes following a partial UTF-8 scalar. The extra byte read on Line 157 is used only to prove the file continues. For a sample ending in 🤖 Prompt for AI Agents
Comment on lines
+157
to
+168
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Validate the lookahead byte before accepting a partial UTF-8 scalar. The extra byte read on Line 157 is discarded. For 8,191 ASCII bytes followed by 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| private func hasValidUTF8PrefixEndingInPartialScalar(_ data: Data) -> Bool { | ||
| let bytes = Array(data) | ||
| guard !bytes.isEmpty else { return false } | ||
| let earliestCandidate = max(0, bytes.count - 4) | ||
| for start in stride(from: bytes.count - 1, through: earliestCandidate, by: -1) { | ||
| guard let expectedLength = utf8ScalarLength(leadingByte: bytes[start]) else { | ||
| continue | ||
| } | ||
| let actualLength = bytes.count - start | ||
| guard actualLength < expectedLength, | ||
| utf8PartialScalarBytesAreValid(Array(bytes[start...])) else { | ||
| continue | ||
| } | ||
| let prefix = Data(bytes[..<start]) | ||
| return String(data: prefix, encoding: .utf8) != nil | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| private func utf8ScalarLength(leadingByte: UInt8) -> Int? { | ||
| switch leadingByte { | ||
| case 0xC2...0xDF: | ||
| return 2 | ||
| case 0xE0...0xEF: | ||
| return 3 | ||
| case 0xF0...0xF4: | ||
| return 4 | ||
| default: | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| private func utf8PartialScalarBytesAreValid(_ bytes: [UInt8]) -> Bool { | ||
| guard let leadingByte = bytes.first else { return false } | ||
| for byte in bytes.dropFirst() where byte & 0xC0 != 0x80 { | ||
| return false | ||
| } | ||
| guard bytes.count > 1 else { return true } | ||
| let firstContinuation = bytes[1] | ||
| switch leadingByte { | ||
| case 0xE0: | ||
| return firstContinuation >= 0xA0 | ||
| case 0xED: | ||
| return firstContinuation <= 0x9F | ||
| case 0xF0: | ||
| return firstContinuation >= 0x90 | ||
| case 0xF4: | ||
| return firstContinuation <= 0x8F | ||
| default: | ||
| return true | ||
| } | ||
| } | ||
|
|
||
| private func attributes(path: String) throws -> [FileAttributeKey: Any] { | ||
| do { | ||
| return try FileManager.default.attributesOfItem(atPath: path) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| import Foundation | ||
|
|
||
| /// Builds stat-enriched, append-only pages from one transcript index snapshot. | ||
| public struct ChatArtifactGalleryBuilder: Sendable { | ||
| /// Creates a gallery page builder. | ||
| public init() {} | ||
|
|
||
| /// Builds one sectioned or flat search page. | ||
| /// | ||
| /// - Parameters: | ||
| /// - sessionID: Session represented by the artifact index. | ||
| /// - items: De-duplicated transcript artifact references. | ||
| /// - orderedItems: Optional generation-cached stable ordering of `items`. | ||
| /// - generation: Stable snapshot generation carried by page cursors. | ||
| /// - cursor: Per-section positions after which paging continues. | ||
| /// - pageSize: Maximum entries to stat and include per section. | ||
| /// - query: Optional basename or path search. | ||
| /// - includeDirectories: Whether directory references are eligible for | ||
| /// rows. This defaults to `false` for clients without folder capability. | ||
| /// - Returns: One gallery page with filesystem metadata. | ||
| public func page( | ||
| sessionID: String, | ||
| items: [ChatArtifactIndexedReference], | ||
| orderedItems: [ChatArtifactIndexedReference]? = nil, | ||
| generation: String, | ||
| cursor: ChatArtifactGalleryCursor?, | ||
| pageSize: Int, | ||
| query: String?, | ||
| includeDirectories: Bool = false | ||
| ) -> ChatArtifactGalleryPage { | ||
| if let cursor, cursor.generation != generation { | ||
| return ChatArtifactGalleryPage( | ||
| sessionID: sessionID, | ||
| generation: generation, | ||
| requiresPagingRestart: true | ||
| ) | ||
| } | ||
| let ordering = ChatArtifactGalleryOrdering() | ||
| let stableItems = orderedItems ?? ordering.sorted(items) | ||
| let normalizedQuery = query?.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| let isSearch = normalizedQuery?.isEmpty == false | ||
| let createdCandidates: [ChatArtifactIndexedReference] | ||
| let attachedCandidates: [ChatArtifactIndexedReference] | ||
| let referencedCandidates: [ChatArtifactIndexedReference] | ||
| if let normalizedQuery, !normalizedQuery.isEmpty { | ||
| createdCandidates = [] | ||
| attachedCandidates = [] | ||
| referencedCandidates = ordering.matching(stableItems, query: normalizedQuery) | ||
| } else { | ||
| createdCandidates = stableItems.filter { $0.provenance == .created } | ||
| attachedCandidates = stableItems.filter { $0.provenance == .attached } | ||
| referencedCandidates = stableItems.filter { $0.provenance == .referenced } | ||
| } | ||
| let count = max(1, pageSize) | ||
| let starts = pageStarts( | ||
| cursor: cursor, | ||
| created: createdCandidates, | ||
| attached: attachedCandidates, | ||
| referenced: referencedCandidates, | ||
| ordering: ordering | ||
| ) | ||
| // Sequential fill: a page extends the grouped list strictly at its | ||
| // bottom (created, then attached, then referenced), so a | ||
| // scroll-triggered load can never insert rows into a group the user | ||
| // has already scrolled past. | ||
| var remaining = count | ||
| let pageCreated = Array(createdCandidates.dropFirst(starts.created).prefix(remaining)) | ||
| remaining -= pageCreated.count | ||
| let pageAttached = Array(attachedCandidates.dropFirst(starts.attached).prefix(remaining)) | ||
| remaining -= pageAttached.count | ||
| let pageReferenced = Array(referencedCandidates.dropFirst(starts.referenced).prefix(remaining)) | ||
| let nextCreatedOffset = starts.created + pageCreated.count | ||
| let nextAttachedOffset = starts.attached + pageAttached.count | ||
| let nextReferencedOffset = starts.referenced + pageReferenced.count | ||
| let nextCursor: String? | ||
| if nextCreatedOffset < createdCandidates.count | ||
| || nextAttachedOffset < attachedCandidates.count | ||
| || nextReferencedOffset < referencedCandidates.count { | ||
| let last = pageReferenced.last | ||
| nextCursor = try? ChatArtifactGalleryCursor( | ||
| generation: generation, | ||
| seq: last?.lastReferencedSeq ?? cursor?.seq ?? .max, | ||
| path: last?.path ?? cursor?.path ?? "", | ||
| createdOffset: nextCreatedOffset, | ||
| attachedOffset: nextAttachedOffset, | ||
| referencedOffset: nextReferencedOffset | ||
| ).token() | ||
| } else { | ||
| nextCursor = nil | ||
| } | ||
|
|
||
| return ChatArtifactGalleryPage( | ||
| sessionID: sessionID, | ||
| created: isSearch ? [] : statItems(pageCreated, includeDirectories: includeDirectories), | ||
| createdTotal: createdCandidates.count, | ||
| attached: isSearch ? [] : statItems(pageAttached, includeDirectories: includeDirectories), | ||
| attachedTotal: attachedCandidates.count, | ||
| referenced: statItems(pageReferenced, includeDirectories: includeDirectories), | ||
| referencedTotal: referencedCandidates.count, | ||
| nextCursor: nextCursor, | ||
|
Comment on lines
+67
to
+100
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Apply directory eligibility before deriving page boundaries and totals. Filtering directories after pagination can return an empty visible page while advancing the cursor, and page-local subtraction cannot produce stable snapshot totals.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| generation: generation | ||
| ) | ||
| } | ||
|
|
||
| private func pageStarts( | ||
| cursor: ChatArtifactGalleryCursor?, | ||
| created: [ChatArtifactIndexedReference], | ||
| attached: [ChatArtifactIndexedReference], | ||
| referenced: [ChatArtifactIndexedReference], | ||
| ordering: ChatArtifactGalleryOrdering | ||
| ) -> (created: Int, attached: Int, referenced: Int) { | ||
| guard let cursor else { return (0, 0, 0) } | ||
| if let createdOffset = cursor.createdOffset, | ||
| let attachedOffset = cursor.attachedOffset, | ||
| let referencedOffset = cursor.referencedOffset { | ||
| return ( | ||
| min(max(0, createdOffset), created.count), | ||
| min(max(0, attachedOffset), attached.count), | ||
| min(max(0, referencedOffset), referenced.count) | ||
| ) | ||
| } | ||
| let remaining = ordering.items(referenced, strictlyAfter: cursor) | ||
| return (created.count, attached.count, referenced.count - remaining.count) | ||
| } | ||
|
|
||
| /// Counts immediate children for a gallery directory row without sorting | ||
| /// or per-entry metadata, stopping at the shared listing limit so the cost | ||
| /// never scales past the cap for large folders. | ||
| private func directoryChildCount(path: String) -> (count: Int, isCapped: Bool)? { | ||
| guard let enumerator = FileManager.default.enumerator( | ||
| at: URL(fileURLWithPath: path, isDirectory: true), | ||
| includingPropertiesForKeys: [], | ||
| options: [.skipsSubdirectoryDescendants] | ||
| ) else { | ||
| return nil | ||
| } | ||
| var count = 0 | ||
| while enumerator.nextObject() != nil { | ||
| count += 1 | ||
| if count > ArtifactByteReader.maximumDirectoryEntryCount { | ||
| return (count: ArtifactByteReader.maximumDirectoryEntryCount, isCapped: true) | ||
| } | ||
| } | ||
| return (count: count, isCapped: false) | ||
| } | ||
|
|
||
| private func statItems( | ||
| _ references: [ChatArtifactIndexedReference], | ||
| includeDirectories: Bool | ||
| ) -> [ChatArtifactGalleryItem] { | ||
| let reader = ArtifactByteReader() | ||
| return references.compactMap { reference in | ||
| do { | ||
| let stat = try reader.stat(path: reference.path) | ||
| guard includeDirectories || !stat.isDirectory else { return nil } | ||
| let children = stat.isDirectory ? directoryChildCount(path: reference.path) : nil | ||
| return ChatArtifactGalleryItem( | ||
| path: reference.path, | ||
| kind: stat.kind, | ||
| displayName: URL(fileURLWithPath: reference.path).lastPathComponent, | ||
| size: stat.size, | ||
| modifiedAt: stat.modifiedAt, | ||
| exists: stat.exists, | ||
| childCount: children?.count, | ||
| childCountIsCapped: children?.isCapped ?? false, | ||
| provenance: reference.provenance | ||
| ) | ||
| } catch { | ||
| return ChatArtifactGalleryItem( | ||
| path: reference.path, | ||
| kind: reader.kind(path: reference.path, isDirectory: false), | ||
| displayName: URL(fileURLWithPath: reference.path).lastPathComponent, | ||
| exists: false, | ||
| provenance: reference.provenance | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift
Apply the directory cap before materializing and sorting every child.
contentsOfDirectoryplussortedstill allocates O(n) names and performs O(n log n) work for an unbounded directory;prefix(500)only limits metadata reads. Stream the directory and maintain the smallest 500 names in a bounded heap/buffer, tracking whether a 501st entry exists.As per coding guidelines, “avoid repeated full scans, sorting, filtering… over scalable collections” and identify the smallest source-of-truth fix. <coding_guidelines> As per path instructions, apply the algorithmic-complexity rule to scalable production data. <path_instructions>
🤖 Prompt for AI Agents
Sources: Coding guidelines, Path instructions