diff --git a/apps/swift-ios/Features/Chat/MarkdownDocument.swift b/apps/swift-ios/Features/Chat/MarkdownDocument.swift index 596b31db3f10..a56999632629 100644 --- a/apps/swift-ios/Features/Chat/MarkdownDocument.swift +++ b/apps/swift-ios/Features/Chat/MarkdownDocument.swift @@ -26,9 +26,224 @@ indirect enum MarkdownBlock: Equatable, Sendable { case blockquote(MarkdownDocument) case table(MarkdownTable) case codeBlock(language: String?, code: String) + case image(source: String, alt: String) case thematicBreak } +/// A Markdown image reference that stands alone on its line. Foundation's inline +/// parser keeps only the alternative text, so images need their own block to be +/// rendered rather than silently flattened into the surrounding prose. +struct MarkdownImageReference: Equatable, Sendable { + let source: String + let alt: String + + static func parse(_ line: String) -> Self? { + // Every line of every message reaches this check, so reject the common + // case before materializing the line. + let trimmed = line.markdownTrimmed + guard trimmed.hasPrefix("!["), trimmed.hasSuffix(")") else { return nil } + + let characters = Array(trimmed) + guard characters.count > 4 else { return nil } + guard let altEnd = matchingDelimiter( + in: characters, + from: 1, + open: "[", + close: "]" + ) else { + return nil + } + + let destinationStart = altEnd + 1 + guard destinationStart < characters.count, + characters[destinationStart] == "(", + let destinationEnd = matchingDelimiter( + in: characters, + from: destinationStart, + open: "(", + close: ")", + ignoringQuotedText: true + ), + // Only a line that is nothing but the image becomes a block; an + // image inside a sentence stays inline text. + destinationEnd == characters.count - 1 else { + return nil + } + + let destination = String(characters[(destinationStart + 1).. String { + guard source.contains("\\") else { return source } + var result = "" + result.reserveCapacity(source.count) + var isEscaping = false + for character in source { + if isEscaping { + if !character.isASCIIPunctuation { result.append("\\") } + result.append(character) + isEscaping = false + continue + } + if character == "\\" { + isEscaping = true + continue + } + result.append(character) + } + if isEscaping { result.append("\\") } + return result + } + + /// Drops the optional title and the optional angle-bracket wrapper that + /// CommonMark allows around a link destination. + private static func source(in destination: String) -> String? { + let trimmed = destination.markdownTrimmed + let characters = Array(trimmed) + let source: String + let sourceEnd: Int + if characters.first == "<" { + // The same unescaped scan the delimiter search uses, so an escaped + // `>` inside the brackets stays part of the file name. + guard let closing = unescapedIndex(of: ">", in: characters, from: 1) else { + return nil + } + source = String(characters[1..) -> Bool { + guard !suffix.isEmpty else { return true } + guard suffix.first?.isMarkdownWhitespace == true else { return false } + + let title = String(suffix).markdownTrimmed + guard !title.isEmpty else { return true } + let characters = Array(title) + let closing: Character + switch characters.first { + case "\"": + closing = "\"" + case "'": + closing = "'" + case "(": + closing = ")" + default: + return false + } + guard characters.count >= 2, characters.last == closing else { return false } + return unescapedIndex(of: closing, in: characters, from: 1) == characters.count - 1 + } + + private static func unescapedIndex( + of target: Character, + in characters: [Character], + from start: Int + ) -> Int? { + var cursor = start + while cursor < characters.count { + if characters[cursor] == "\\" { + cursor += 2 + continue + } + if characters[cursor] == target { return cursor } + cursor += 1 + } + return nil + } + + /// Set `ignoringQuotedText` for a link destination: a quoted title may hold + /// an unbalanced parenthesis, as in `(out/plot.png "generated (final")`, and + /// counting those would hide the real closing delimiter. + private static func matchingDelimiter( + in characters: [Character], + from start: Int, + open: Character, + close: Character, + ignoringQuotedText: Bool = false + ) -> Int? { + var depth = 0 + var cursor = start + var openQuote: Character? + var followsWhitespace = false + // True until the first non-whitespace character after the opening + // delimiter, which is the only place a `<`-wrapped destination starts. + var opensDestination = true + while cursor < characters.count { + let character = characters[cursor] + if character == "\\" { + cursor += 2 + followsWhitespace = false + opensDestination = false + continue + } + if ignoringQuotedText { + if let activeQuote = openQuote { + if character == activeQuote { openQuote = nil } + cursor += 1 + followsWhitespace = false + continue + } + // A title is separated from the destination by whitespace, so + // only a quote in that position opens one. An apostrophe inside + // a file name, as in `images/team's-logo.png`, is just a + // character of the path. + if followsWhitespace, character == "\"" || character == "'" { + openQuote = character + cursor += 1 + followsWhitespace = false + continue + } + // CommonMark wraps an awkward destination in angle brackets, as + // in `()`. Everything inside is literal, so a + // parenthesis there is part of the file name. + // CommonMark also permits whitespace between the delimiter and + // the brackets, and the destination reader trims it. + if character == "<", opensDestination { + guard let closingAngle = unescapedIndex( + of: ">", + in: characters, + from: cursor + 1 + ) else { + return nil + } + cursor = closingAngle + 1 + followsWhitespace = false + opensDestination = false + continue + } + } + if character == open { + depth += 1 + } else if character == close { + depth -= 1 + if depth == 0 { return cursor } + } + followsWhitespace = character.isMarkdownWhitespace + if cursor > start, !character.isMarkdownWhitespace { + opensDestination = false + } + cursor += 1 + } + return nil + } +} + struct MarkdownTable: Equatable, Sendable { let header: [String] let alignments: [MarkdownTableAlignment] @@ -110,6 +325,12 @@ private struct MarkdownBlockParser { continue } + if let image = MarkdownImageReference.parse(lines[index]) { + blocks.append(.image(source: image.source, alt: image.alt)) + index += 1 + continue + } + blocks.append(parseParagraph()) } @@ -262,7 +483,9 @@ private struct MarkdownBlockParser { while index < lines.count, !lines[index].isMarkdownBlank { if !paragraphLines.isEmpty, - (isBlockStarter(lines[index]) || tableOpening(at: index) != nil) { + isBlockStarter(lines[index]) + || tableOpening(at: index) != nil + || MarkdownImageReference.parse(lines[index]) != nil { break } paragraphLines.append(lines[index].markdownTrimmedTrailing) @@ -622,4 +845,12 @@ private extension Character { var isMarkdownWhitespace: Bool { self == " " || self == "\t" } + + var isASCIIPunctuation: Bool { + guard let ascii = asciiValue else { return false } + return (33...47).contains(ascii) + || (58...64).contains(ascii) + || (91...96).contains(ascii) + || (123...126).contains(ascii) + } } diff --git a/apps/swift-ios/Features/Chat/MarkdownMessageView.swift b/apps/swift-ios/Features/Chat/MarkdownMessageView.swift index 9b8adca4d82b..abc21b4c944a 100644 --- a/apps/swift-ios/Features/Chat/MarkdownMessageView.swift +++ b/apps/swift-ios/Features/Chat/MarkdownMessageView.swift @@ -303,6 +303,9 @@ private struct MarkdownBlockView: View, Equatable { selectionContext: selectionContext ) + case let .image(source, alt): + MarkdownWorkspaceImageView(source: source, alt: alt) + case .thematicBreak: Rectangle() .fill(T3Colors.separator) diff --git a/apps/swift-ios/Features/Chat/MarkdownRenderCache.swift b/apps/swift-ios/Features/Chat/MarkdownRenderCache.swift index fb23316e3650..19075e6fe5fe 100644 --- a/apps/swift-ios/Features/Chat/MarkdownRenderCache.swift +++ b/apps/swift-ios/Features/Chat/MarkdownRenderCache.swift @@ -152,6 +152,7 @@ indirect enum MarkdownRenderedBlock: Equatable, @unchecked Sendable { case blockquote([MarkdownRenderedBlock]) case table(MarkdownRenderedTable) case codeBlock(language: String?, code: String, inline: MarkdownRenderedInline) + case image(source: String, alt: String) case thematicBreak } @@ -388,6 +389,12 @@ final class MarkdownRenderCache: @unchecked Sendable { guard let inline = renderInline(code, style: .code) else { return nil } rendered = .codeBlock(language: language, code: code, inline: inline) + case let .image(source, alt): + // Images carry no inline runs; the destination stays a plain + // reference so a rendered document is independent of the thread + // that later resolves it. + rendered = .image(source: source, alt: alt) + case .thematicBreak: rendered = .thematicBreak } diff --git a/apps/swift-ios/Features/Chat/MarkdownWorkspaceImageView.swift b/apps/swift-ios/Features/Chat/MarkdownWorkspaceImageView.swift new file mode 100644 index 000000000000..826d1e647600 --- /dev/null +++ b/apps/swift-ios/Features/Chat/MarkdownWorkspaceImageView.swift @@ -0,0 +1,206 @@ +import SwiftUI +import UIKit + +/// Identifies the workspace a Markdown message belongs to so its image +/// references can be resolved through the existing signed asset route. +/// +/// The resolver is a main-actor protocol and is only ever touched from view +/// code, so carrying it through the environment is safe. +struct MarkdownWorkspaceImageContext: Equatable, @unchecked Sendable { + let resolver: any FeatureWorkspaceAssetResolving + let threadID: String + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.resolver === rhs.resolver && lhs.threadID == rhs.threadID + } +} + +private struct MarkdownWorkspaceImageContextKey: EnvironmentKey { + static let defaultValue: MarkdownWorkspaceImageContext? = nil +} + +extension EnvironmentValues { + var markdownWorkspaceImageContext: MarkdownWorkspaceImageContext? { + get { self[MarkdownWorkspaceImageContextKey.self] } + set { self[MarkdownWorkspaceImageContextKey.self] = newValue } + } +} + +/// Maps a Markdown image destination onto a workspace file path. +/// +/// Only a relative path to a supported image inside the workspace can be +/// resolved: remote URLs, data URLs, absolute paths, and path escapes are not +/// workspace files, and files the app cannot decode are not images. Everything +/// this rejects keeps rendering as its alternative text. +enum MarkdownWorkspaceImageReference { + static func workspacePath(for source: String) -> String? { + let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + trimmed.range(of: #"^[A-Za-z][A-Za-z0-9+.\-]*:"#, options: .regularExpression) == nil, + !trimmed.hasPrefix("/"), + !trimmed.hasPrefix("~"), + !trimmed.hasPrefix("#") else { + return nil + } + + let decoded = trimmed.removingPercentEncoding ?? trimmed + var components: [String] = [] + for component in decoded.split(separator: "/", omittingEmptySubsequences: false) { + switch component { + case ".": + continue + case "", "..": + return nil + default: + components.append(String(component)) + } + } + + let path = components.joined(separator: "/") + guard !path.isEmpty, FeatureFilePreviewKind.infer(path: path) == .image else { + return nil + } + return path + } +} + +/// Resolves the workspace asset on every view load so an overwritten file can +/// produce a new signed URL. The shared attachment loader still deduplicates +/// downloads while that exact URL remains current. +@MainActor +enum MarkdownWorkspaceImageLoader { + static func image( + threadID: String, + path: String, + maximumPixelSize: Int, + resolver: any FeatureWorkspaceAssetResolving, + loadImage: (URL, Int) async throws -> UIImage = { url, maximumPixelSize in + try await FeatureAttachmentThumbnailLoader.image( + for: url, + maximumPixelSize: maximumPixelSize + ) + } + ) async throws -> UIImage { + let url = try await resolver.workspaceAssetURL(threadID: threadID, path: path) + return try await loadImage(url, maximumPixelSize) + } +} + +/// Renders a workspace image referenced by a Markdown message inline, reusing +/// the transcript's attachment thumbnail loader and its bounded image cache. +struct MarkdownWorkspaceImageView: View { + private struct Request: Hashable { + let threadID: String + let path: String + let maximumPixelSize: Int + } + + let source: String + let alt: String + + @SwiftUI.Environment(\.markdownWorkspaceImageContext) private var context + @SwiftUI.Environment(\.displayScale) private var displayScale + @State private var image: UIImage? + // Gated on request identity, like the sibling remote attachment thumbnail: + // hosted transcript cells reuse this state across recycling and path + // changes, so an ungated image would briefly belong to another message. + @State private var loadedRequest: Request? + @State private var failedRequest: Request? + + var body: some View { + if let context, let path = MarkdownWorkspaceImageReference.workspacePath(for: source) { + workspaceImage(path: path, context: context) + } else { + // Remote and unsupported references keep the behaviour they have + // always had: the alternative text reads as prose. + Text(alt.isEmpty ? source : alt) + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textPrimary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func workspaceImage( + path: String, + context: MarkdownWorkspaceImageContext + ) -> some View { + let request = request(path: path, threadID: context.threadID) + return Group { + if loadedRequest == request, let image { + Image(uiImage: image) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: 340, alignment: .leading) + } else if failedRequest == request { + placeholder(systemImage: "photo.badge.exclamationmark", text: "Image unavailable") + } else { + placeholder(systemImage: "photo", text: "Loading image…") + } + } + .background(T3Colors.surfaceRaised) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(T3Colors.border, lineWidth: 1) + } + .accessibilityElement() + .accessibilityLabel(accessibilityLabel(path: path, request: request)) + .accessibilityIdentifier("workspace-image-\(path)") + .task(id: request) { + await load(request, context: context) + } + } + + private func request(path: String, threadID: String) -> Request { + Request(threadID: threadID, path: path, maximumPixelSize: maximumPixelSize) + } + + /// Bounded so a large workspace render is downsampled once, off the main + /// thread, instead of holding its full-resolution bitmap in the transcript. + private var maximumPixelSize: Int { + min(1_536, max(390, Int(ceil(390 * displayScale)))) + } + + private func accessibilityLabel(path: String, request: Request) -> String { + let name = alt.trimmingCharacters(in: .whitespacesAndNewlines) + let described = name.isEmpty ? URL(fileURLWithPath: path).lastPathComponent : name + if failedRequest == request { + return "Image unavailable, \(described)" + } + return loadedRequest == request ? "Image, \(described)" : "Loading image, \(described)" + } + + private func placeholder(systemImage: String, text: String) -> some View { + VStack(spacing: 7) { + Image(systemName: systemImage) + .font(.system(size: 22, weight: .medium)) + Text(text) + .font(T3Typography.supporting) + } + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity) + .frame(height: 160) + } + + private func load(_ request: Request, context: MarkdownWorkspaceImageContext) async { + do { + let loaded = try await MarkdownWorkspaceImageLoader.image( + threadID: request.threadID, + path: request.path, + maximumPixelSize: request.maximumPixelSize, + resolver: context.resolver + ) + try Task.checkCancellation() + image = loaded + loadedRequest = request + failedRequest = nil + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled else { return } + image = nil + loadedRequest = nil + failedRequest = request + } + } +} diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 64595679a087..204ce81d3d2a 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -331,6 +331,7 @@ public struct ThreadDetailView: View { } else { FeatureTranscriptCollectionView( threadID: thread.id, + workspaceImageContext: workspaceImageContext, messages: detail.messages, renderUpdate: model.detailRenderUpdates[thread.id], dynamicTypeSize: dynamicTypeSize, @@ -377,6 +378,14 @@ public struct ThreadDetailView: View { } } + /// Present only when the connected client can resolve signed workspace + /// assets; without it Markdown image references stay alternative text. + private var workspaceImageContext: MarkdownWorkspaceImageContext? { + guard let resolver = model.client as? any FeatureWorkspaceAssetResolving else { + return nil + } + return MarkdownWorkspaceImageContext(resolver: resolver, threadID: thread.id) + } private var composerPowerFeatures: FeatureComposerPowerFeatures { let selectedProviderID = selection?.providerID ?? currentSelection?.providerID let provider = threadProviders.first { $0.id == selectedProviderID } @@ -630,6 +639,7 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { } let threadID: String + let workspaceImageContext: MarkdownWorkspaceImageContext? let messages: [FeatureMessage] let renderUpdate: FeatureDetailRenderUpdate? let dynamicTypeSize: DynamicTypeSize @@ -665,6 +675,7 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { func updateUIView(_ collectionView: UICollectionView, context: Context) { context.coordinator.update( threadID: threadID, + workspaceImageContext: workspaceImageContext, messages: messages, renderUpdate: renderUpdate, dynamicTypeSize: dynamicTypeSize, @@ -724,6 +735,7 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { private var currentIsMonitoring = false private var currentCanLoadEarlier = false private var currentIsLoadingEarlier = false + private var currentWorkspaceImageContext: MarkdownWorkspaceImageContext? private var markdownPrefetches: [String: MarkdownPrefetch] = [:] private var onLoadEarlier: (() -> Void)? private var onDismissKeyboard: (() -> Void)? @@ -768,6 +780,12 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { cell.contentConfiguration = UIHostingConfiguration { FeatureMessageView(message: message) .frame(maxWidth: .infinity, alignment: .leading) + // Hosted cells start their own SwiftUI environment, so + // the workspace an image belongs to is injected here. + .environment( + \.markdownWorkspaceImageContext, + self?.currentWorkspaceImageContext + ) } .margins(.all, 0) cell.backgroundConfiguration = UIBackgroundConfiguration.clear() @@ -789,6 +807,7 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { func update( threadID: String, + workspaceImageContext: MarkdownWorkspaceImageContext?, messages: [FeatureMessage], renderUpdate: FeatureDetailRenderUpdate?, dynamicTypeSize: DynamicTypeSize, @@ -805,6 +824,9 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { guard let dataSource else { return } self.onLoadEarlier = onLoadEarlier self.onDismissKeyboard = onDismissKeyboard + // Cells read this when they are configured. It only changes with the + // thread, which already reloads every cell below. + currentWorkspaceImageContext = workspaceImageContext let threadChanged = currentThreadID != threadID let typeSizeChanged = currentDynamicTypeSize != dynamicTypeSize @@ -1662,7 +1684,9 @@ private struct FeatureLocalAttachmentThumbnail: View { } } -private enum FeatureAttachmentThumbnailLoader { +/// Shared by transcript attachments and inline workspace images so both go +/// through one bounded cache and one downsampling path. +enum FeatureAttachmentThumbnailLoader { static func image(for url: URL, maximumPixelSize: Int) async throws -> UIImage { let cacheKey = "\(url.absoluteString)#\(maximumPixelSize)" as NSString if let cached = FeatureAttachmentThumbnailCache.shared.image(for: cacheKey) { @@ -1707,7 +1731,8 @@ private enum FeatureAttachmentThumbnailLoader { } } -private final class FeatureAttachmentThumbnailCache: @unchecked Sendable { +/// Shared by transcript attachments and inline workspace images. +final class FeatureAttachmentThumbnailCache: @unchecked Sendable { static let shared = FeatureAttachmentThumbnailCache() private let images = NSCache() diff --git a/apps/swift-ios/Tests/FeatureTests/MarkdownDocumentTests.swift b/apps/swift-ios/Tests/FeatureTests/MarkdownDocumentTests.swift index 5cc7b9ab959a..6fa1d5ce8c57 100644 --- a/apps/swift-ios/Tests/FeatureTests/MarkdownDocumentTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/MarkdownDocumentTests.swift @@ -427,6 +427,181 @@ struct MarkdownDocumentTests { ) } + @Test + func liftsStandaloneImageReferencesIntoTheirOwnBlock() { + let document = MarkdownDocument( + parsing: """ + Here is the render: + ![Generated image](out/render.png) + + ![](docs/diagram.svg "Architecture") + + - ![Icon](assets/icon.png) + """ + ) + + #expect( + document.blocks == [ + .paragraph("Here is the render:"), + .image(source: "out/render.png", alt: "Generated image"), + .image(source: "docs/diagram.svg", alt: ""), + .unorderedList([ + MarkdownListItem( + task: nil, + blocks: [.image(source: "assets/icon.png", alt: "Icon")] + ), + ]), + ] + ) + } + + @Test + func resolvesBackslashEscapesInImageReferences() { + let document = MarkdownDocument( + parsing: """ + ![Plot \\[final\\]](out/foo\\(1\\).png) + + ![C:\\\\path](out/back\\\\slash.png) + """ + ) + + #expect( + document.blocks == [ + .image(source: "out/foo(1).png", alt: "Plot [final]"), + .image(source: "out/back\\slash.png", alt: "C:\\path"), + ] + ) + } + + @Test + func readsDestinationsPastTitlesAndBalancedParentheses() { + let document = MarkdownDocument( + parsing: """ + ![plot](out/plot.png "generated (final") + + ![shot](out/foo(1).png) + + ![single](out/single.png 'a title') + + ![parenthesized](out/parenthesized.png (a title)) + """ + ) + + #expect( + document.blocks == [ + .image(source: "out/plot.png", alt: "plot"), + .image(source: "out/foo(1).png", alt: "shot"), + .image(source: "out/single.png", alt: "single"), + .image(source: "out/parenthesized.png", alt: "parenthesized"), + ] + ) + } + + @Test( + "Malformed image titles stay paragraph text", + .bug("https://github.com/pingdotgg/t3code/pull/7378#discussion_r3826785013") + ) + func rejectsMalformedImageTitles() { + let document = MarkdownDocument( + parsing: """ + ![bare](out/a.png garbage) + + ![trailing](out/b.png "title" garbage) + + ![unclosed](out/c.png "title) + + ![angle]("title") + """ + ) + + #expect( + document.blocks == [ + .paragraph("![bare](out/a.png garbage)"), + .paragraph("![trailing](out/b.png \"title\" garbage)"), + .paragraph("![unclosed](out/c.png \"title)"), + .paragraph("![angle](\"title\")"), + ] + ) + } + + @Test + func readsAngleBracketedDestinationsLiterally() { + let document = MarkdownDocument( + parsing: """ + ![plot]() + + ![spaced]( "a title") + """ + ) + + #expect( + document.blocks == [ + .image(source: "out/plot).png", alt: "plot"), + .image(source: "out/my render.png", alt: "spaced"), + ] + ) + } + + @Test + func readsAngleBracketedDestinationsPastSpacesAndEscapes() { + let document = MarkdownDocument( + parsing: """ + ![padded]( ) + + ![escaped](b.png>) + """ + ) + + #expect( + document.blocks == [ + .image(source: "out/pad).png", alt: "padded"), + .image(source: "out/a>b.png", alt: "escaped"), + ] + ) + } + + @Test + func treatsQuotesInsideFileNamesAsPathCharacters() { + let document = MarkdownDocument( + parsing: """ + ![logo](images/team's-logo.png) + + ![quote](out/say"hi".png) + + ![both](out/it's-(1).png "a (title") + """ + ) + + #expect( + document.blocks == [ + .image(source: "images/team's-logo.png", alt: "logo"), + .image(source: "out/say\"hi\".png", alt: "quote"), + .image(source: "out/it's-(1).png", alt: "both"), + ] + ) + } + + @Test + func keepsImagesInsideProseAsParagraphText() { + let document = MarkdownDocument( + parsing: """ + See ![inline](a.png) here. + + ![unterminated](a.png + + [Not an image](b.png) + """ + ) + + #expect( + document.blocks == [ + .paragraph("See ![inline](a.png) here."), + .paragraph("![unterminated](a.png"), + .paragraph("[Not an image](b.png)"), + ] + ) + } + private func index(of substring: String, in text: NSString) -> Int? { let range = text.range(of: substring) return range.location == NSNotFound ? nil : range.location diff --git a/apps/swift-ios/Tests/FeatureTests/MarkdownRenderCacheTests.swift b/apps/swift-ios/Tests/FeatureTests/MarkdownRenderCacheTests.swift index 772f860073f9..60147d0648cc 100644 --- a/apps/swift-ios/Tests/FeatureTests/MarkdownRenderCacheTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/MarkdownRenderCacheTests.swift @@ -86,6 +86,22 @@ struct MarkdownRenderCacheTests { #expect(String(firstInline.attributedText.characters) == "Shared paragraph.") } + @Test + func keepsImageReferencesInRenderedDocuments() { + let cache = MarkdownRenderCache(documentCountLimit: 8, documentCostLimit: 64_000) + let revision = MarkdownContentRevision("Result:\n![Chart](out/chart.png)") + + guard let document = cache.documentImmediately(for: revision), + document.blocks.count == 2, + case let .image(source, alt) = document.blocks[1] else { + Issue.record("Expected a rendered image block") + return + } + + #expect(source == "out/chart.png") + #expect(alt == "Chart") + } + @Test func canceledRequestDoesNotRenderOrCache() async { let cache = MarkdownRenderCache(documentCountLimit: 8, documentCostLimit: 64_000) diff --git a/apps/swift-ios/Tests/FeatureTests/MarkdownWorkspaceImageTests.swift b/apps/swift-ios/Tests/FeatureTests/MarkdownWorkspaceImageTests.swift new file mode 100644 index 000000000000..fe42666822a9 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/MarkdownWorkspaceImageTests.swift @@ -0,0 +1,127 @@ +import Testing +import UIKit +@testable import T3Code + +@Suite("Inline workspace images") +struct MarkdownWorkspaceImageTests { + @Test + func resolvesRelativeWorkspaceImagePaths() { + #expect( + MarkdownWorkspaceImageReference.workspacePath(for: "out/render.png") + == "out/render.png" + ) + #expect( + MarkdownWorkspaceImageReference.workspacePath(for: "./docs/./diagram.JPEG") + == "docs/diagram.JPEG" + ) + #expect( + MarkdownWorkspaceImageReference.workspacePath(for: "art/my%20chart.webp") + == "art/my chart.webp" + ) + #expect( + MarkdownWorkspaceImageReference.workspacePath(for: " logo.gif ") == "logo.gif" + ) + } + + @Test + func acceptsUnescapedPunctuationInFileNames() { + // The parser hands over an unescaped path, so a file whose name needs + // Markdown escaping still resolves to the file on disk. + #expect( + MarkdownWorkspaceImageReference.workspacePath(for: "out/foo(1).png") + == "out/foo(1).png" + ) + #expect( + MarkdownWorkspaceImageReference.workspacePath(for: "out/a [b].png") + == "out/a [b].png" + ) + } + + @Test + func rejectsReferencesThatAreNotWorkspaceImages() { + for source in [ + "https://example.com/render.png", + "data:image/png;base64,AAAA", + "file:///tmp/render.png", + "/tmp/render.png", + "~/render.png", + "#anchor", + "../../secrets/render.png", + "out//render.png", + "notes.txt", + "render.png/", + "", + " ", + ] { + #expect( + MarkdownWorkspaceImageReference.workspacePath(for: source) == nil, + "Expected \(source) to stay alternative text" + ) + } + } + + @Test + func matchesTheFilePreviewImageContract() { + // Inline images and the file browser must agree on what is an image. + for path in ["a.png", "a.jpg", "a.jpeg", "a.gif", "a.webp", "a.avif", "a.ico"] { + #expect(MarkdownWorkspaceImageReference.workspacePath(for: path) == path) + #expect(FeatureFilePreviewKind.infer(path: path) == .image) + } + for path in ["a.svg", "a.mp4", "a.md", "a.swift"] { + #expect(MarkdownWorkspaceImageReference.workspacePath(for: path) == nil) + #expect(FeatureFilePreviewKind.infer(path: path) != .image) + } + } + + @MainActor + @Test( + "Repeated loads re-resolve a workspace image", + .bug("https://github.com/pingdotgg/t3code/pull/7378#discussion_r3802336559") + ) + func repeatedLoadsResolveFreshAssetURLs() async throws { + let urls = [ + try #require(URL(string: "https://example.test/assets/render-v1.png")), + try #require(URL(string: "https://example.test/assets/render-v2.png")), + ] + let resolver = SequentialWorkspaceAssetResolver(urls: urls) + var loadedURLs: [URL] = [] + + for _ in urls { + _ = try await MarkdownWorkspaceImageLoader.image( + threadID: "thread-1", + path: "out/render.png", + maximumPixelSize: 780, + resolver: resolver + ) { url, _ in + loadedURLs.append(url) + return UIImage() + } + } + + #expect(resolver.requests == [ + .init(threadID: "thread-1", path: "out/render.png"), + .init(threadID: "thread-1", path: "out/render.png"), + ]) + #expect(loadedURLs == urls) + } +} + +@MainActor +private final class SequentialWorkspaceAssetResolver: FeatureWorkspaceAssetResolving { + struct Request: Equatable { + let threadID: String + let path: String + } + + private var urls: [URL] + private(set) var requests: [Request] = [] + + init(urls: [URL]) { + self.urls = urls + } + + func workspaceAssetURL(threadID: String, path: String) async throws -> URL { + requests.append(.init(threadID: threadID, path: path)) + return urls.removeFirst() + } +}