diff --git a/apps/swift-ios/Features/Chat/MarkdownDocument.swift b/apps/swift-ios/Features/Chat/MarkdownDocument.swift index 596b31db3f10..b360afc7ad03 100644 --- a/apps/swift-ios/Features/Chat/MarkdownDocument.swift +++ b/apps/swift-ios/Features/Chat/MarkdownDocument.swift @@ -20,6 +20,7 @@ struct MarkdownDocument: Equatable, Sendable { indirect enum MarkdownBlock: Equatable, Sendable { case paragraph(String) + case image(MarkdownImageReference) case heading(level: Int, text: String) case unorderedList([MarkdownListItem]) case orderedList(start: Int, items: [MarkdownListItem]) @@ -29,6 +30,11 @@ indirect enum MarkdownBlock: Equatable, Sendable { case thematicBreak } +struct MarkdownImageReference: Equatable, Hashable, Sendable { + let source: String + let alt: String? +} + struct MarkdownTable: Equatable, Sendable { let header: [String] let alignments: [MarkdownTableAlignment] @@ -110,12 +116,104 @@ private struct MarkdownBlockParser { continue } - blocks.append(parseParagraph()) + let paragraph = parseParagraph() + if case let .paragraph(source) = paragraph, + let image = Self.imageReference(in: source) { + blocks.append(.image(image)) + } else { + blocks.append(paragraph) + } } return blocks } + /// Images need their own block because Foundation's attributed-string + /// renderer flattens image Markdown into text. Keep mixed prose inline. + static func imageReference(in source: String) -> MarkdownImageReference? { + let value = source.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.contains("\n"), value.hasPrefix("!["), value.hasSuffix(")"), + let labelEnd = Self.firstUnescapedLabelEnd(in: value) else { return nil } + + let altStart = value.index(value.startIndex, offsetBy: 2) + let alt = Self.removingMarkdownBackslashEscapes( + from: String(value[altStart..") { + let pathStart = destination.index(after: destination.startIndex) + let remainder = destination[destination.index(after: closing)...] + .trimmingCharacters(in: .whitespacesAndNewlines) + guard remainder.isEmpty || Self.isQuotedTitle(String(remainder)) else { return nil } + destination = String(destination[pathStart.. Bool { + guard value.count >= 2, let first = value.first, let last = value.last else { + return false + } + return (first == "\"" && last == "\"") + || (first == "'" && last == "'") + || (first == "(" && last == ")") + } + + private static func firstUnescapedLabelEnd(in value: String) -> Range? { + var index = value.index(value.startIndex, offsetBy: 2) + while index < value.endIndex { + let next = value.index(after: index) + if value[index] == "]", next < value.endIndex, value[next] == "(", + !isEscaped(index, in: value) { + return index.. Bool { + var cursor = index + var slashCount = 0 + while cursor > value.startIndex { + let preceding = value.index(before: cursor) + guard value[preceding] == "\\" else { break } + slashCount += 1 + cursor = preceding + } + return slashCount.isMultiple(of: 2) == false + } + + private static func removingMarkdownBackslashEscapes(from value: String) -> String { + let escapable = CharacterSet(charactersIn: "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~") + var result = "" + var index = value.startIndex + while index < value.endIndex { + let character = value[index] + let next = value.index(after: index) + if character == "\\", next < value.endIndex, + value[next].unicodeScalars.allSatisfy(escapable.contains) { + result.append(value[next]) + index = value.index(after: next) + } else { + result.append(character) + index = next + } + } + return result + } + private mutating func parseCodeBlock(opening: FenceMarker) -> MarkdownBlock { index += 1 var codeLines: [String] = [] diff --git a/apps/swift-ios/Features/Chat/MarkdownMessageView.swift b/apps/swift-ios/Features/Chat/MarkdownMessageView.swift index b6bde4e48c2b..7521354cdc45 100644 --- a/apps/swift-ios/Features/Chat/MarkdownMessageView.swift +++ b/apps/swift-ios/Features/Chat/MarkdownMessageView.swift @@ -1,6 +1,17 @@ import SwiftUI import UIKit +private struct MarkdownTextSelectionEnabledKey: EnvironmentKey { + static let defaultValue = false +} + +private extension EnvironmentValues { + var markdownTextSelectionEnabled: Bool { + get { self[MarkdownTextSelectionEnabledKey.self] } + set { self[MarkdownTextSelectionEnabledKey.self] = newValue } + } +} + /// Native chat Markdown with block-aware layout and Foundation inline parsing. struct MarkdownMessageView: View { private struct RenderRequest: Hashable { @@ -11,13 +22,22 @@ struct MarkdownMessageView: View { private let source: String private let revision: MarkdownContentRevision private let isStreaming: Bool + private let onOpenURL: ((URL) -> Bool)? + private let imageContext: MarkdownImageContext? @State private var renderedDocument: MarkdownRenderedDocument? @State private var streamingRenderer = StreamingMarkdownRenderer() @State private var isSelectingText = false - init(_ source: String, isStreaming: Bool = false) { + init( + _ source: String, + isStreaming: Bool = false, + onOpenURL: ((URL) -> Bool)? = nil, + imageContext: MarkdownImageContext? = nil + ) { self.source = source self.isStreaming = isStreaming + self.onOpenURL = onOpenURL + self.imageContext = imageContext let revision = MarkdownContentRevision(source) self.revision = revision let initialDocument = if isStreaming { @@ -33,7 +53,7 @@ struct MarkdownMessageView: View { var body: some View { Group { if let displayDocument { - MarkdownBlocksView(blocks: displayDocument.blocks) + MarkdownBlocksView(blocks: displayDocument.blocks, imageContext: imageContext) } else { // Parsing waits briefly so token-by-token streaming cancels stale revisions // instead of scheduling work for content the user will never see. @@ -44,6 +64,7 @@ struct MarkdownMessageView: View { } } .modifier(MarkdownTextSelectionModifier(isEnabled: isSelectingText)) + .environment(\.markdownTextSelectionEnabled, isSelectingText) .contextMenu { Button { isSelectingText.toggle() @@ -62,6 +83,10 @@ struct MarkdownMessageView: View { .accessibilityAction(named: "Copy message") { UIPasteboard.general.string = source } + .environment(\.openURL, OpenURLAction { url in + if onOpenURL?(url) == true { return .handled } + return .systemAction + }) .task(id: RenderRequest(revision: revision, isStreaming: isStreaming)) { if !isStreaming { streamingRenderer.cancel() @@ -193,6 +218,7 @@ private struct MarkdownTextSelectionModifier: ViewModifier { private struct MarkdownBlocksView: View { let blocks: [MarkdownRenderedBlock] + var imageContext: MarkdownImageContext? var spacing: CGFloat = 12 var body: some View { @@ -201,7 +227,7 @@ private struct MarkdownBlocksView: View { // Unchanged blocks share inline runs by reference across // streaming revisions, so equatable comparison skips their // body and layout entirely; only the changed tail re-renders. - MarkdownBlockView(block: blocks[index]) + MarkdownBlockView(block: blocks[index], imageContext: imageContext) .equatable() } } @@ -210,26 +236,46 @@ private struct MarkdownBlocksView: View { private struct MarkdownBlockView: View, Equatable { let block: MarkdownRenderedBlock + let imageContext: MarkdownImageContext? + nonisolated let imageContextID: MarkdownImageContext.ID? + + init(block: MarkdownRenderedBlock, imageContext: MarkdownImageContext?) { + self.block = block + self.imageContext = imageContext + imageContextID = imageContext?.id + } + + nonisolated static func == (lhs: Self, rhs: Self) -> Bool { + lhs.block == rhs.block && lhs.imageContextID == rhs.imageContextID + } @ViewBuilder var body: some View { switch block { case let .paragraph(inline): MarkdownInlineText(inline) - .lineSpacing(4) + + case let .image(reference): + if let imageContext { + MarkdownWorkspaceImageView(reference: reference, context: imageContext) + } else { + Text(reference.alt ?? reference.source) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } case let .heading(level, inline): MarkdownInlineText(inline) .padding(.top, level <= 2 ? 3 : 1) case let .unorderedList(items): - MarkdownListView(items: items, start: nil) + MarkdownListView(items: items, start: nil, imageContext: imageContext) case let .orderedList(start, items): - MarkdownListView(items: items, start: start) + MarkdownListView(items: items, start: start, imageContext: imageContext) case let .blockquote(blocks): - MarkdownBlocksView(blocks: blocks, spacing: 9) + MarkdownBlocksView(blocks: blocks, imageContext: imageContext, spacing: 9) .foregroundStyle(T3Colors.textSecondary) .padding(.leading, 14) .overlay(alignment: .leading) { @@ -290,7 +336,6 @@ private struct MarkdownTableView: View { GridRow(alignment: .top) { ForEach(cells.indices, id: \.self) { columnIndex in MarkdownInlineText(cells[columnIndex]) - .lineSpacing(3) .frame( width: columnWidths[columnIndex], alignment: alignment(for: columnIndex) @@ -334,6 +379,7 @@ private struct MarkdownTableView: View { private struct MarkdownListView: View { let items: [MarkdownRenderedListItem] let start: Int? + let imageContext: MarkdownImageContext? var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -342,7 +388,11 @@ private struct MarkdownListView: View { HStack(alignment: .top, spacing: 8) { marker(for: item, offset: offset) .frame(width: 24, height: 24, alignment: .trailing) - MarkdownBlocksView(blocks: item.blocks, spacing: 7) + MarkdownBlocksView( + blocks: item.blocks, + imageContext: imageContext, + spacing: 7 + ) } .accessibilityElement(children: .contain) } @@ -470,18 +520,199 @@ enum MarkdownCodeBlockWrapping { } private struct MarkdownInlineText: View { + @SwiftUI.Environment(\.dynamicTypeSize) private var dynamicTypeSize + @SwiftUI.Environment(\.legibilityWeight) private var legibilityWeight + @SwiftUI.Environment(\.markdownTextSelectionEnabled) private var isTextSelectionEnabled + private let attributedText: AttributedString private let font: Font + private let style: MarkdownInlineStyle + private let hasLinks: Bool init(_ rendered: MarkdownRenderedInline) { attributedText = rendered.attributedText font = rendered.style.font + style = rendered.style + hasLinks = rendered.attributedText.runs.contains { $0.link != nil } } var body: some View { Text(attributedText) .font(font) + .lineSpacing(style.lineSpacing) .fixedSize(horizontal: false, vertical: true) + .overlay { + if hasLinks { + MarkdownLinkInteractionOverlay( + attributedText: attributedText, + baseFont: style.uiFont( + dynamicTypeSize: dynamicTypeSize, + legibilityWeight: legibilityWeight + ), + lineSpacing: style.lineSpacing + ) + .allowsHitTesting(!isTextSelectionEnabled) + } + } + } +} + +@MainActor +final class MarkdownLinkActionRelay { + var handler: (URL) -> Void = { _ in } + + func open(_ url: URL) { + handler(url) + } +} + +private struct MarkdownLinkInteractionOverlay: UIViewRepresentable { + @SwiftUI.Environment(\.openURL) private var openURL + + let attributedText: AttributedString + let baseFont: UIFont + let lineSpacing: CGFloat + + func makeCoordinator() -> MarkdownLinkActionRelay { + MarkdownLinkActionRelay() + } + + func makeUIView(context: Context) -> MarkdownLinkInteractionView { + let relay = context.coordinator + let view = MarkdownLinkInteractionView() + view.onOpenURL = { relay.open($0) } + return view + } + + func updateUIView(_ view: MarkdownLinkInteractionView, context: Context) { + // UIHostingConfiguration recycles transcript cells. Updating this relay + // keeps a reused link wired to the current SwiftUI environment. + context.coordinator.handler = { url in openURL(url) } + view.render(attributedText, baseFont: baseFont, lineSpacing: lineSpacing) + } +} + +private final class MarkdownLinkInteractionView: UIView { + var onOpenURL: ((URL) -> Void)? + + private let textStorage = NSTextStorage() + private let layoutManager = NSLayoutManager() + private let textContainer = NSTextContainer(size: .zero) + private var initialTouch: (point: CGPoint, url: URL, timestamp: TimeInterval)? + private var renderedText: AttributedString? + private var renderedFont: UIFont? + private var renderedLineSpacing: CGFloat? + + init() { + super.init(frame: .zero) + backgroundColor = .clear + isOpaque = false + isUserInteractionEnabled = true + isAccessibilityElement = false + accessibilityElementsHidden = true + textContainer.lineFragmentPadding = 0 + layoutManager.addTextContainer(textContainer) + textStorage.addLayoutManager(layoutManager) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func render(_ text: AttributedString, baseFont: UIFont, lineSpacing: CGFloat) { + guard renderedText != text + || renderedFont?.isEqual(baseFont) != true + || renderedLineSpacing != lineSpacing else { return } + renderedText = text + renderedFont = baseFont + renderedLineSpacing = lineSpacing + + let rendered = NSMutableAttributedString() + for run in text.runs { + let string = String(text[run.range].characters) + var attributes: [NSAttributedString.Key: Any] = [ + .font: font(for: run.inlinePresentationIntent, baseFont: baseFont), + .foregroundColor: UIColor.clear, + ] + if let link = run.link { attributes[.link] = link } + rendered.append(NSAttributedString(string: string, attributes: attributes)) + } + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = lineSpacing + rendered.addAttribute( + .paragraphStyle, + value: paragraph, + range: NSRange(location: 0, length: rendered.length) + ) + textStorage.setAttributedString(rendered) + setNeedsLayout() + } + + override func layoutSubviews() { + super.layoutSubviews() + textContainer.size = bounds.size + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + link(at: point) != nil + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first, + let url = link(at: touch.location(in: self)) else { + initialTouch = nil + super.touchesBegan(touches, with: event) + return + } + initialTouch = (touch.location(in: self), url, touch.timestamp) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first, let initialTouch, + initialTouch.url == link(at: touch.location(in: self)), + hypot( + touch.location(in: self).x - initialTouch.point.x, + touch.location(in: self).y - initialTouch.point.y + ) <= 10, + touch.timestamp - initialTouch.timestamp <= 0.5 else { + self.initialTouch = nil + super.touchesEnded(touches, with: event) + return + } + self.initialTouch = nil + onOpenURL?(initialTouch.url) + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + initialTouch = nil + super.touchesCancelled(touches, with: event) + } + + private func link(at point: CGPoint) -> URL? { + guard textStorage.length > 0, bounds.contains(point) else { return nil } + let glyph = layoutManager.glyphIndex(for: point, in: textContainer) + guard glyph < layoutManager.numberOfGlyphs else { return nil } + let glyphRect = layoutManager.boundingRect( + forGlyphRange: NSRange(location: glyph, length: 1), + in: textContainer + ) + guard glyphRect.insetBy(dx: -6, dy: -4).contains(point) else { return nil } + let character = layoutManager.characterIndexForGlyph(at: glyph) + guard character < textStorage.length else { return nil } + return textStorage.attribute(.link, at: character, effectiveRange: nil) as? URL + } + + private func font(for intent: InlinePresentationIntent?, baseFont: UIFont) -> UIFont { + var descriptor = baseFont.fontDescriptor + if intent?.contains(.code) == true { + descriptor = descriptor.withDesign(.monospaced) ?? descriptor + } + var traits = descriptor.symbolicTraits + if intent?.contains(.stronglyEmphasized) == true { traits.insert(.traitBold) } + if intent?.contains(.emphasized) == true { traits.insert(.traitItalic) } + guard let descriptor = descriptor.withSymbolicTraits(traits) else { return baseFont } + return UIFont(descriptor: descriptor, size: baseFont.pointSize) } } diff --git a/apps/swift-ios/Features/Chat/MarkdownRenderCache.swift b/apps/swift-ios/Features/Chat/MarkdownRenderCache.swift index 62dff05350bf..eb01bbc00fe5 100644 --- a/apps/swift-ios/Features/Chat/MarkdownRenderCache.swift +++ b/apps/swift-ios/Features/Chat/MarkdownRenderCache.swift @@ -54,6 +54,52 @@ enum MarkdownInlineStyle: String, Hashable, Sendable { } } + func uiFont( + dynamicTypeSize: DynamicTypeSize, + legibilityWeight: LegibilityWeight? + ) -> UIFont { + let textStyle: UIFont.TextStyle + let weight: UIFont.Weight + switch self { + case .body, .tableCell: + textStyle = .body + weight = .regular + case .heading1: + textStyle = .title2 + weight = .bold + case .heading2: + textStyle = .title3 + weight = .bold + case .heading3: + textStyle = .headline + weight = .bold + case .heading4, .tableHeader: + textStyle = .body + weight = .semibold + } + let traits = UITraitCollection(traitsFrom: [ + UITraitCollection( + preferredContentSizeCategory: UIContentSizeCategory(dynamicTypeSize) + ), + UITraitCollection( + legibilityWeight: legibilityWeight == .bold ? .bold : .unspecified + ), + ]) + let preferred = UIFont.preferredFont(forTextStyle: textStyle, compatibleWith: traits) + let effectiveWeight: UIFont.Weight = legibilityWeight == .bold + ? max(weight, .semibold) + : weight + return UIFont.systemFont(ofSize: preferred.pointSize, weight: effectiveWeight) + } + + var lineSpacing: CGFloat { + switch self { + case .body: 4 + case .tableHeader, .tableCell: 3 + case .heading1, .heading2, .heading3, .heading4: 0 + } + } + static func heading(level: Int) -> Self { switch level { case 1: .heading1 @@ -122,6 +168,7 @@ struct MarkdownRenderedTable: Equatable, @unchecked Sendable { indirect enum MarkdownRenderedBlock: Equatable, @unchecked Sendable { case paragraph(MarkdownRenderedInline) + case image(MarkdownImageReference) case heading(level: Int, inline: MarkdownRenderedInline) case unorderedList([MarkdownRenderedListItem]) case orderedList(start: Int, items: [MarkdownRenderedListItem]) @@ -335,6 +382,9 @@ final class MarkdownRenderCache: @unchecked Sendable { guard let inline = renderInline(source, style: .body) else { return nil } rendered = .paragraph(inline) + case let .image(reference): + rendered = .image(reference) + case let .heading(level, source): guard let inline = renderInline(source, style: .heading(level: level)) else { return nil diff --git a/apps/swift-ios/Features/Chat/MarkdownWorkspaceImageView.swift b/apps/swift-ios/Features/Chat/MarkdownWorkspaceImageView.swift new file mode 100644 index 000000000000..12a28820e889 --- /dev/null +++ b/apps/swift-ios/Features/Chat/MarkdownWorkspaceImageView.swift @@ -0,0 +1,144 @@ +import Foundation +import SwiftUI + +struct MarkdownImageContext { + struct ID: Hashable, Sendable { + let resolverID: ObjectIdentifier? + let threadID: String + let workspaceRoot: String? + let relativeDirectory: String? + } + + let assetResolver: (any FeatureWorkspaceAssetResolving)? + let threadID: String + let workspaceRoot: String? + let relativeDirectory: String? + + var id: ID { + ID( + resolverID: assetResolver.map(ObjectIdentifier.init), + threadID: threadID, + workspaceRoot: workspaceRoot, + relativeDirectory: relativeDirectory + ) + } + + init( + client: any FeatureClient, + threadID: String, + workspaceRoot: String?, + relativeDirectory: String? = nil + ) { + assetResolver = client as? any FeatureWorkspaceAssetResolving + self.threadID = threadID + self.workspaceRoot = workspaceRoot + self.relativeDirectory = relativeDirectory + } +} + +struct MarkdownWorkspaceImage: Equatable { + let previewURL: URL + let link: FeatureWorkspaceFileLink + + init?(source: String, workspaceRoot: String?, relativeTo basePath: String? = nil) { + let encodedSource = source.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) + guard let workspaceRoot, + let sourceURL = URL(string: source) ?? encodedSource.flatMap(URL.init(string:)), + let link = FeatureWorkspaceFileLink( + url: sourceURL, + workspaceRoot: workspaceRoot, + relativeTo: basePath + ), + FeatureFilePreviewKind.infer(path: link.path) == .image else { return nil } + let resolvedPath = (workspaceRoot as NSString).appendingPathComponent(link.path) + previewURL = URL(fileURLWithPath: resolvedPath) + self.link = link + } +} + +struct MarkdownWorkspaceImageView: View { + private struct Request: Hashable { + let reference: MarkdownImageReference + let contextID: MarkdownImageContext.ID + } + + let reference: MarkdownImageReference + let context: MarkdownImageContext + + @SwiftUI.Environment(\.openURL) private var openURL + @State private var resolvedURL: URL? + @State private var failed = false + + private var image: MarkdownWorkspaceImage? { + MarkdownWorkspaceImage( + source: reference.source, + workspaceRoot: context.workspaceRoot, + relativeTo: context.relativeDirectory + ) + } + + private var name: String { + let alt = reference.alt?.trimmingCharacters(in: .whitespacesAndNewlines) + if let alt, !alt.isEmpty { return alt } + return image?.link.entry.name ?? reference.source + } + + var body: some View { + Group { + if image == nil { + Text(name) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } else if failed { + Label("Image unavailable: \(name)", systemImage: "exclamationmark.triangle") + .font(T3Typography.supporting.monospaced()) + .foregroundStyle(T3Colors.textSecondary) + .padding(9) + .overlay { RoundedRectangle(cornerRadius: 8).stroke(T3Colors.border) } + } else if let resolvedURL, let image { + Button { openURL(image.previewURL) } label: { + FeatureRemoteAttachmentThumbnail( + url: resolvedURL, + maximumDownloadBytes: 16 * 1_024 * 1_024, + onFailure: { failed = true } + ) + .frame(maxWidth: .infinity) + .frame(height: 240) + .background(T3Colors.surfaceRaised) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(.plain) + .accessibilityLabel("Open image \(name)") + .accessibilityIdentifier("workspace-inline-image") + } else { + ProgressView(name) + .frame(maxWidth: .infinity) + .frame(height: 160) + .background(T3Colors.surfaceRaised) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .task(id: Request(reference: reference, contextID: context.id)) { await resolve() } + } + + private func resolve() async { + resolvedURL = nil + failed = false + guard let image else { return } + do { + guard let assetResolver = context.assetResolver else { + throw FeatureCapabilityUnavailable("Inline workspace images") + } + resolvedURL = try await assetResolver.workspaceAssetURL( + threadID: context.threadID, + path: image.link.path + ) + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled else { return } + failed = true + } + } +} diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 44647e039629..85991a4d6782 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -21,6 +21,8 @@ public struct ThreadDetailView: View { @State private var didRestoreDraft = false @State private var draftSaveTask: Task? @State private var toolSurface: FeatureThreadToolSurface? + @State private var linkedFile: FeatureWorkspaceFileLink? + @State private var fileLinkFailureMessage: String? @FocusState private var composerFocused: Bool public init( @@ -82,7 +84,11 @@ public struct ThreadDetailView: View { NavigationStack { switch surface { case .files: - FeatureFilesView(client: model.client, threadID: thread.id) + FeatureFilesView( + client: model.client, + threadID: thread.id, + workspaceRoot: workspaceRoot + ) case .review: FeatureReviewView(client: model.client, threadID: thread.id) case .sourceControl: @@ -101,11 +107,33 @@ public struct ThreadDetailView: View { .presentationDetents([.large]) .presentationDragIndicator(.visible) } + .sheet(item: $linkedFile) { link in + FeatureLinkedFileSheet( + client: model.client, + threadID: thread.id, + workspaceRoot: workspaceRoot, + entry: link.entry, + onDone: { linkedFile = nil } + ) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } .alert("Message not sent", isPresented: $sendFailed) { Button("OK") {} } message: { Text("Your draft is still here. Check your connection and try again.") } + .alert( + "File link unavailable", + isPresented: Binding( + get: { fileLinkFailureMessage != nil }, + set: { if !$0 { fileLinkFailureMessage = nil } } + ) + ) { + Button("OK") { fileLinkFailureMessage = nil } + } message: { + Text(fileLinkFailureMessage ?? "This file is not inside the active workspace.") + } .simultaneousGesture(edgeBackGesture) } @@ -130,6 +158,11 @@ public struct ThreadDetailView: View { detail?.thread ?? thread } + private var workspaceRoot: String? { + currentThread.worktreePath + ?? model.snapshot.projects.first { $0.id == currentThread.projectID }?.path + } + private var currentSelection: FeatureSelection? { guard let providerID = detail?.thread.providerID ?? thread.providerID, let modelID = detail?.thread.modelID ?? thread.modelID else { return nil } @@ -322,6 +355,11 @@ public struct ThreadDetailView: View { } else { FeatureTranscriptCollectionView( threadID: thread.id, + imageContext: MarkdownImageContext( + client: model.client, + threadID: thread.id, + workspaceRoot: workspaceRoot + ), messages: detail.messages, renderUpdate: model.detailRenderUpdates[thread.id], dynamicTypeSize: dynamicTypeSize, @@ -334,6 +372,7 @@ public struct ThreadDetailView: View { onLoadEarlier: { Task { await model.loadEarlierTurns(for: thread.id) } }, + onOpenURL: openURL, onDismissKeyboard: dismissKeyboard ) } @@ -368,6 +407,18 @@ public struct ThreadDetailView: View { } } + private func openURL(_ url: URL) -> Bool { + guard let link = FeatureWorkspaceFileLink(url: url, workspaceRoot: workspaceRoot) else { + guard FeatureWorkspaceFileLink.isWorkspaceDestination(url) else { return false } + fileLinkFailureMessage = workspaceRoot == nil + ? "The active workspace is not available for this thread." + : "This file is not inside the active workspace." + return true + } + linkedFile = link + return true + } + private var composerKeyboardDismissGesture: some Gesture { DragGesture(minimumDistance: 6, coordinateSpace: .local) .onChanged { value in @@ -543,6 +594,44 @@ public struct ThreadDetailView: View { } +private struct FeatureLinkedFileSheet: View { + let client: any FeatureClient + let threadID: String + let workspaceRoot: String? + let entry: FeatureFileEntry + let onDone: () -> Void + + @State private var showsContainingDirectory = false + + var body: some View { + NavigationStack { + Group { + if showsContainingDirectory { + FeatureFilesView( + client: client, + threadID: threadID, + workspaceRoot: workspaceRoot, + initialPath: entry.containingDirectoryPath + ) + } else { + FeatureFilePreviewView( + client: client, + threadID: threadID, + workspaceRoot: workspaceRoot, + entry: entry, + onShowInFiles: { showsContainingDirectory = true } + ) + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done", action: onDone) + } + } + } + } +} + private struct FeatureThreadOpeningView: View { let isRefreshing: Bool @@ -634,6 +723,7 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { } let threadID: String + let imageContext: MarkdownImageContext let messages: [FeatureMessage] let renderUpdate: FeatureDetailRenderUpdate? let dynamicTypeSize: DynamicTypeSize @@ -644,6 +734,7 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { let canLoadEarlier: Bool let isLoadingEarlier: Bool let onLoadEarlier: () -> Void + let onOpenURL: (URL) -> Bool let onDismissKeyboard: () -> Void func makeCoordinator() -> Coordinator { @@ -669,6 +760,7 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { func updateUIView(_ collectionView: UICollectionView, context: Context) { context.coordinator.update( threadID: threadID, + imageContext: imageContext, messages: messages, renderUpdate: renderUpdate, dynamicTypeSize: dynamicTypeSize, @@ -679,6 +771,7 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { canLoadEarlier: canLoadEarlier, isLoadingEarlier: isLoadingEarlier, onLoadEarlier: onLoadEarlier, + onOpenURL: onOpenURL, onDismissKeyboard: onDismissKeyboard, in: collectionView ) @@ -720,6 +813,7 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { private var messagesByID: [String: FeatureMessage] = [:] private var orderedIDs: [String] = [] private var currentThreadID: String? + private var currentImageContext: MarkdownImageContext? private var currentDetailRevision: UInt64? private var currentDynamicTypeSize: DynamicTypeSize? private var currentIsWorking = false @@ -730,6 +824,7 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { private var currentIsLoadingEarlier = false private var markdownPrefetches: [String: MarkdownPrefetch] = [:] private var onLoadEarlier: (() -> Void)? + private var onOpenURL: ((URL) -> Bool)? private var onDismissKeyboard: (() -> Void)? deinit { @@ -770,7 +865,13 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { } cell.contentConfiguration = UIHostingConfiguration { - FeatureMessageView(message: message) + FeatureMessageView( + message: message, + onOpenURL: { [weak self] url in + self?.onOpenURL?(url) == true + }, + imageContext: self?.currentImageContext + ) .frame(maxWidth: .infinity, alignment: .leading) } .margins(.all, 0) @@ -793,6 +894,7 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { func update( threadID: String, + imageContext: MarkdownImageContext, messages: [FeatureMessage], renderUpdate: FeatureDetailRenderUpdate?, dynamicTypeSize: DynamicTypeSize, @@ -803,14 +905,17 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { canLoadEarlier: Bool, isLoadingEarlier: Bool, onLoadEarlier: @escaping () -> Void, + onOpenURL: @escaping (URL) -> Bool, onDismissKeyboard: @escaping () -> Void, in collectionView: UICollectionView ) { guard let dataSource else { return } self.onLoadEarlier = onLoadEarlier + self.onOpenURL = onOpenURL self.onDismissKeyboard = onDismissKeyboard let threadChanged = currentThreadID != threadID + let imageContextChanged = currentImageContext?.id != imageContext.id let typeSizeChanged = currentDynamicTypeSize != dynamicTypeSize let revisionChanged = currentDetailRevision != renderUpdate?.revision let workingChanged = currentIsWorking != isWorking @@ -819,7 +924,7 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { || currentIsMonitoring != isMonitoring let loadEarlierChanged = currentCanLoadEarlier != canLoadEarlier || currentIsLoadingEarlier != isLoadingEarlier - guard threadChanged || typeSizeChanged || revisionChanged || workingChanged + guard threadChanged || imageContextChanged || typeSizeChanged || revisionChanged || workingChanged || workingDetailChanged || loadEarlierChanged else { return } let incremental = !threadChanged @@ -828,10 +933,11 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { let state = incremental ?? fullState(messages: messages) let newIDs = state.ids let idsChanged = state.idsChanged - let changedIDs = typeSizeChanged + let changedIDs = typeSizeChanged || imageContextChanged ? newIDs : state.changedIDs + currentImageContext = imageContext currentDetailRevision = renderUpdate?.revision currentDynamicTypeSize = dynamicTypeSize currentIsWorking = isWorking @@ -840,7 +946,7 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { currentIsMonitoring = isMonitoring currentCanLoadEarlier = canLoadEarlier currentIsLoadingEarlier = isLoadingEarlier - guard threadChanged || idsChanged || !changedIDs.isEmpty || workingChanged + guard threadChanged || imageContextChanged || idsChanged || !changedIDs.isEmpty || workingChanged || workingDetailChanged || loadEarlierChanged else { return } if threadChanged { @@ -1317,10 +1423,11 @@ private final class BottomAnchoredTranscriptCollectionView: UICollectionView { } } -private struct FeatureRemoteAttachmentThumbnail: View { +struct FeatureRemoteAttachmentThumbnail: View { private struct Request: Hashable { let url: URL let maximumPixelSize: Int + let maximumDownloadBytes: Int } @SwiftUI.Environment(\.displayScale) private var displayScale @@ -1329,6 +1436,8 @@ private struct FeatureRemoteAttachmentThumbnail: View { @State private var failedRequest: Request? let url: URL + var maximumDownloadBytes = 64 * 1_024 * 1_024 + var onFailure: (() -> Void)? = nil var body: some View { Group { @@ -1348,7 +1457,8 @@ private struct FeatureRemoteAttachmentThumbnail: View { do { let image = try await FeatureAttachmentThumbnailLoader.image( for: activeRequest.url, - maximumPixelSize: activeRequest.maximumPixelSize + maximumPixelSize: activeRequest.maximumPixelSize, + maximumDownloadBytes: activeRequest.maximumDownloadBytes ) try Task.checkCancellation() self.image = image @@ -1361,6 +1471,7 @@ private struct FeatureRemoteAttachmentThumbnail: View { image = nil loadedRequest = nil failedRequest = activeRequest + onFailure?() } } } @@ -1368,7 +1479,8 @@ private struct FeatureRemoteAttachmentThumbnail: View { private var request: Request { Request( url: url, - maximumPixelSize: min(768, max(190, Int(ceil(190 * displayScale)))) + maximumPixelSize: min(768, max(190, Int(ceil(190 * displayScale)))), + maximumDownloadBytes: maximumDownloadBytes ) } @@ -1430,19 +1542,25 @@ private struct FeatureLocalAttachmentThumbnail: View { } private enum FeatureAttachmentThumbnailLoader { - static func image(for url: URL, maximumPixelSize: Int) async throws -> UIImage { - let cacheKey = "\(url.absoluteString)#\(maximumPixelSize)" as NSString + static func image( + for url: URL, + maximumPixelSize: Int, + maximumDownloadBytes: Int + ) async throws -> UIImage { + let cacheKey = "\(url.absoluteString)#\(maximumPixelSize)#\(maximumDownloadBytes)" as NSString if let cached = FeatureAttachmentThumbnailCache.shared.image(for: cacheKey) { return cached } - let (data, response) = try await URLSession.shared.data(from: url) + let (data, response) = try await FeatureBoundedDataLoader.data( + from: url, + maximumBytes: maximumDownloadBytes + ) try Task.checkCancellation() if let response = response as? HTTPURLResponse, !(200...299).contains(response.statusCode) { throw FeatureAttachmentThumbnailError.invalidResponse } - let image = try await Task.detached(priority: .utility) { try downsample(data: data, maximumPixelSize: maximumPixelSize) }.value @@ -1497,10 +1615,13 @@ private final class FeatureAttachmentThumbnailCache: @unchecked Sendable { private enum FeatureAttachmentThumbnailError: Error { case invalidResponse case decodingFailed + case tooLarge } struct FeatureMessageView: View { let message: FeatureMessage + let onOpenURL: (URL) -> Bool + var imageContext: MarkdownImageContext? var body: some View { switch message.role { @@ -1512,7 +1633,9 @@ struct FeatureMessageView: View { if !message.text.isEmpty { MarkdownMessageView( message.text, - isStreaming: message.state == .streaming + isStreaming: message.state == .streaming, + onOpenURL: onOpenURL, + imageContext: imageContext ) } } @@ -1546,7 +1669,9 @@ struct FeatureMessageView: View { if !message.text.isEmpty { MarkdownMessageView( message.text, - isStreaming: message.state == .streaming + isStreaming: message.state == .streaming, + onOpenURL: onOpenURL, + imageContext: imageContext ) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/apps/swift-ios/Features/Files/FeatureFilesView.swift b/apps/swift-ios/Features/Files/FeatureFilesView.swift index c0a9bd7b8980..438543fdae37 100644 --- a/apps/swift-ios/Features/Files/FeatureFilesView.swift +++ b/apps/swift-ios/Features/Files/FeatureFilesView.swift @@ -5,14 +5,29 @@ import UIKit public struct FeatureFilesView: View { let client: any FeatureClient let threadID: String - - public init(client: any FeatureClient, threadID: String) { + let workspaceRoot: String? + let initialPath: String? + + public init( + client: any FeatureClient, + threadID: String, + workspaceRoot: String? = nil, + initialPath: String? = nil + ) { self.client = client self.threadID = threadID + self.workspaceRoot = workspaceRoot + self.initialPath = initialPath } public var body: some View { - FeatureFileDirectoryView(client: client, threadID: threadID, path: nil, title: "Files") + FeatureFileDirectoryView( + client: client, + threadID: threadID, + workspaceRoot: workspaceRoot, + path: initialPath, + title: initialPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "Files" + ) .background(T3Colors.background) } } @@ -20,6 +35,7 @@ public struct FeatureFilesView: View { private struct FeatureFileDirectoryView: View { let client: any FeatureClient let threadID: String + let workspaceRoot: String? let path: String? let title: String @@ -87,11 +103,17 @@ private struct FeatureFileDirectoryView: View { FeatureFileDirectoryView( client: client, threadID: threadID, + workspaceRoot: workspaceRoot, path: entry.path, title: entry.name ) } else { - FeatureFilePreviewView(client: client, threadID: threadID, entry: entry) + FeatureFilePreviewView( + client: client, + threadID: threadID, + workspaceRoot: workspaceRoot, + entry: entry + ) } } @@ -149,10 +171,12 @@ private struct FeatureFileRow: View { } } -private struct FeatureFilePreviewView: View { +struct FeatureFilePreviewView: View { let client: any FeatureClient let threadID: String + let workspaceRoot: String? let entry: FeatureFileEntry + var onShowInFiles: (() -> Void)? = nil @State private var content: FeatureFileContent? @State private var sourceLines: [FeatureSourceLine] = [] @@ -160,6 +184,9 @@ private struct FeatureFilePreviewView: View { @State private var assetURL: URL? @State private var errorMessage: String? @State private var isLoading = true + @State private var linkedFile: FeatureWorkspaceFileLink? + @State private var linkFailureMessage: String? + @State private var showsInfo = false private var previewKind: FeatureFilePreviewKind { FeatureFilePreviewKind.infer(path: entry.path, language: content?.language) @@ -187,7 +214,16 @@ private struct FeatureFilePreviewView: View { switch previewKind { case .markdown: ScrollView { - MarkdownMessageView(content.text) + MarkdownMessageView( + content.text, + onOpenURL: openURL, + imageContext: MarkdownImageContext( + client: client, + threadID: threadID, + workspaceRoot: workspaceRoot, + relativeDirectory: entry.containingDirectoryPath + ) + ) .frame(maxWidth: T3Metrics.readingWidth, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 18) @@ -211,7 +247,33 @@ private struct FeatureFilePreviewView: View { .background(T3Colors.background) .navigationTitle(entry.name) .navigationBarTitleDisplayMode(.inline) + .accessibilityIdentifier("workspace-file-preview") + .navigationDestination(item: $linkedFile) { link in + FeatureFilePreviewView( + client: client, + threadID: threadID, + workspaceRoot: workspaceRoot, + entry: link.entry + ) + } .toolbar { + if let onShowInFiles { + ToolbarItem(placement: .topBarLeading) { + Button(action: onShowInFiles) { + Label("Files", systemImage: "chevron.backward") + } + .accessibilityIdentifier("workspace-file-show-in-files") + } + } + ToolbarItem(placement: .topBarTrailing) { + Button { + showsInfo = true + } label: { + Image(systemName: "info.circle") + } + .accessibilityLabel("File info") + .accessibilityIdentifier("workspace-file-info") + } if let assetURL { ToolbarItem(placement: .topBarTrailing) { ShareLink(item: assetURL) { @@ -228,9 +290,48 @@ private struct FeatureFilePreviewView: View { } } } + .sheet(isPresented: $showsInfo) { + NavigationStack { + FeatureFileInfoView(entry: entry, content: content) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showsInfo = false } + } + } + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + .alert( + "File link unavailable", + isPresented: Binding( + get: { linkFailureMessage != nil }, + set: { if !$0 { linkFailureMessage = nil } } + ) + ) { + Button("OK") { linkFailureMessage = nil } + } message: { + Text(linkFailureMessage ?? "This file is not inside the active workspace.") + } .task { await load() } } + private func openURL(_ url: URL) -> Bool { + guard let link = FeatureWorkspaceFileLink( + url: url, + workspaceRoot: workspaceRoot, + relativeTo: entry.containingDirectoryPath + ) else { + guard FeatureWorkspaceFileLink.isWorkspaceDestination(url) else { return false } + linkFailureMessage = workspaceRoot == nil + ? "The active workspace is not available for this thread." + : "This file is not inside the active workspace." + return true + } + linkedFile = link + return true + } + private func load() async { isLoading = true defer { isLoading = false } @@ -302,6 +403,48 @@ private struct FeatureFilePreviewView: View { } } +private struct FeatureFileInfoView: View { + let entry: FeatureFileEntry + let content: FeatureFileContent? + + var body: some View { + List { + LabeledContent("Name", value: entry.name) + VStack(alignment: .leading, spacing: 6) { + Text("Path") + .foregroundStyle(T3Colors.textSecondary) + Text(entry.path) + .font(T3Typography.code) + .textSelection(.enabled) + } + LabeledContent("Kind", value: kindLabel) + if let byteCount = content?.totalBytes ?? entry.sizeBytes { + LabeledContent( + "Size", + value: ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file) + ) + } + if let language = content?.language, !language.isEmpty { + LabeledContent("Language", value: language) + } + if content?.isTruncated == true { + LabeledContent("Preview", value: "Partial") + } + } + .navigationTitle("File Info") + .navigationBarTitleDisplayMode(.inline) + .accessibilityIdentifier("workspace-file-info-view") + } + + private var kindLabel: String { + switch entry.kind { + case .file: "File" + case .directory: "Folder" + case .symbolicLink: "Symbolic Link" + } + } +} + private struct FeatureSourceTextView: View { let lines: [FeatureSourceLine] diff --git a/apps/swift-ios/Features/Files/FeatureWorkspacePathResolver.swift b/apps/swift-ios/Features/Files/FeatureWorkspacePathResolver.swift new file mode 100644 index 000000000000..7babd835253d --- /dev/null +++ b/apps/swift-ios/Features/Files/FeatureWorkspacePathResolver.swift @@ -0,0 +1,150 @@ +import Foundation + +/// Resolves transcript destinations to paths accepted by the workspace file APIs. +/// The server expects workspace-relative paths, so absolute destinations must remain +/// inside the active workspace and relative destinations must not escape it. +public struct FeatureWorkspaceFileLink: Identifiable, Sendable, Equatable, Hashable { + private static let conventionalFileNames: Set = [ + "AUTHORS", "Brewfile", "BUILD", "Caddyfile", "CHANGELOG", "CODEOWNERS", + "Containerfile", "CONTRIBUTORS", "COPYING", "Dockerfile", "Fastfile", + "Gemfile", "GNUmakefile", "Jenkinsfile", "Justfile", "LICENSE", "LICENCE", + "Makefile", "NOTICE", "Podfile", "Procfile", "README", "Rakefile", + "Vagrantfile", "WORKSPACE", "justfile", "makefile", + ] + + private static let positionedPathExtensions: Set = [ + "c", "cc", "conf", "cpp", "cs", "css", "csv", "env", "go", "h", "hpp", + "html", "ini", "java", "js", "json", "jsx", "kt", "kts", "m", "md", + "mdx", "mm", "php", "plist", "py", "rb", "rs", "scss", "sh", "sql", + "swift", "text", "toml", "ts", "tsx", "txt", "vue", "xml", "yaml", + "yml", "zsh", + ] + + public let path: String + public var id: String { path } + + public var entry: FeatureFileEntry { + FeatureFileEntry( + path: path, + name: URL(fileURLWithPath: path).lastPathComponent, + kind: .file + ) + } + + public init?(url: URL, workspaceRoot: String?, relativeTo basePath: String? = nil) { + guard let workspaceRoot, !workspaceRoot.isEmpty, + url.host == nil, url.user == nil, url.password == nil, url.port == nil else { + return nil + } + + let destination: ParsedDestination + switch url.scheme?.lowercased() { + case nil: + destination = Self.parsePosition(from: url.path) + case "file": + destination = Self.parsePosition(from: url.path) + default: + guard let positioned = Self.positionedRelativeDestination(from: url) else { + return nil + } + destination = positioned + } + + guard !destination.path.isEmpty, + !destination.path.contains(":"), + !destination.path.unicodeScalars.contains(where: { $0.value == 0 }) else { + return nil + } + + if !destination.path.hasPrefix("/"), !Self.isRecognizableFilePath(destination.path) { + return nil + } + + let root = (workspaceRoot as NSString).standardizingPath + let base: String + if let basePath, !basePath.isEmpty { + base = ((root as NSString).appendingPathComponent(basePath) as NSString) + .standardizingPath + guard Self.contains(base, in: root) else { return nil } + } else { + base = root + } + let absolute: String + if destination.path.hasPrefix("/") { + absolute = (destination.path as NSString).standardizingPath + } else { + absolute = ((base as NSString).appendingPathComponent(destination.path) as NSString) + .standardizingPath + } + + guard let relative = Self.relativePath(for: absolute, in: root) else { return nil } + path = relative + } + + public static func isWorkspaceDestination(_ url: URL) -> Bool { + guard url.host == nil, url.user == nil, url.password == nil, url.port == nil else { + return false + } + if url.scheme?.lowercased() == "file" { return true } + if url.scheme == nil { + return url.path.hasPrefix("/") + || isRecognizableFilePath(parsePosition(from: url.path).path) + } + return positionedRelativeDestination(from: url) != nil + } + + private struct ParsedDestination { + var path: String + var line: Int? + var column: Int? + } + + private static func positionedRelativeDestination(from url: URL) -> ParsedDestination? { + let raw = url.absoluteString + .split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)[0] + .split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false)[0] + let decoded = String(raw).removingPercentEncoding ?? String(raw) + let destination = parsePosition(from: decoded) + guard destination.line != nil, isRecognizableFilePath(destination.path) else { return nil } + return destination + } + + private static func parsePosition(from rawPath: String) -> ParsedDestination { + var path = rawPath + var trailingNumbers: [Int] = [] + while trailingNumbers.count < 2, let separator = path.lastIndex(of: ":") { + let suffix = path[path.index(after: separator)...] + guard !suffix.isEmpty, suffix.allSatisfy(\.isNumber), let value = Int(suffix) else { + break + } + trailingNumbers.append(value) + path.removeSubrange(separator...) + } + return ParsedDestination( + path: path, + line: trailingNumbers.last, + column: trailingNumbers.count == 2 ? trailingNumbers.first : nil + ) + } + + private static func isRecognizableFilePath(_ path: String) -> Bool { + let name = (path as NSString).lastPathComponent + if conventionalFileNames.contains(name) { return true } + if FeatureFilePreviewKind.infer(path: name) == .image { return true } + let fileExtension = (name as NSString).pathExtension.lowercased() + return positionedPathExtensions.contains(fileExtension) + } + + private static func relativePath(for absolutePath: String, in root: String) -> String? { + guard absolutePath != root else { return nil } + let prefix = root == "/" ? root : root + "/" + guard absolutePath.hasPrefix(prefix) else { return nil } + let relative = String(absolutePath.dropFirst(prefix.count)) + guard !relative.isEmpty, relative != ".", !relative.hasPrefix("../") else { return nil } + return relative + } + + private static func contains(_ absolutePath: String, in root: String) -> Bool { + absolutePath == root || absolutePath.hasPrefix(root == "/" ? root : root + "/") + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureBoundedDataLoader.swift b/apps/swift-ios/Features/Shared/FeatureBoundedDataLoader.swift new file mode 100644 index 000000000000..c0ad32831c78 --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureBoundedDataLoader.swift @@ -0,0 +1,159 @@ +import Foundation + +enum FeatureBoundedDataLoaderError: Error, Equatable { + case tooLarge +} + +/// Bridges URLSession's incremental delegate callbacks into one bounded async load. +/// All mutable delegate state is protected by `lock`, including terminal completion. +final class FeatureBoundedDataLoader: NSObject, URLSessionDataDelegate, @unchecked Sendable { + private struct Output { + let data: Data + let response: URLResponse + } + + private let maximumBytes: Int + private let configuration: URLSessionConfiguration + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var task: URLSessionDataTask? + private var response: URLResponse? + private var buffer = Data() + private var cancellationRequested = false + + private lazy var session = URLSession( + configuration: configuration, + delegate: self, + delegateQueue: nil + ) + + private init(maximumBytes: Int, configuration: URLSessionConfiguration) { + self.maximumBytes = maximumBytes + self.configuration = configuration + } + + static func data( + from url: URL, + maximumBytes: Int, + configuration: URLSessionConfiguration = .ephemeral + ) async throws -> (Data, URLResponse) { + guard maximumBytes >= 0 else { throw FeatureBoundedDataLoaderError.tooLarge } + let loader = FeatureBoundedDataLoader( + maximumBytes: maximumBytes, + configuration: configuration + ) + let output = try await loader.load(from: url) + return (output.data, output.response) + } + + private func load(from url: URL) async throws -> Output { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + start(url: url, continuation: continuation) + } + } onCancel: { + cancel() + } + } + + private func start( + url: URL, + continuation: CheckedContinuation + ) { + lock.lock() + if cancellationRequested { + lock.unlock() + continuation.resume(throwing: CancellationError()) + return + } + self.continuation = continuation + let task = session.dataTask(with: url) + self.task = task + lock.unlock() + task.resume() + } + + private func cancel() { + lock.lock() + cancellationRequested = true + lock.unlock() + finish(.failure(CancellationError()), cancellingTask: true) + } + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void + ) { + if response.expectedContentLength > Int64(maximumBytes) { + completionHandler(.cancel) + finish(.failure(FeatureBoundedDataLoaderError.tooLarge)) + return + } + + lock.lock() + let isActive = continuation != nil + if isActive { self.response = response } + lock.unlock() + completionHandler(isActive ? .allow : .cancel) + } + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive data: Data + ) { + lock.lock() + guard continuation != nil else { + lock.unlock() + return + } + guard data.count <= maximumBytes - buffer.count else { + lock.unlock() + finish(.failure(FeatureBoundedDataLoaderError.tooLarge), cancellingTask: true) + return + } + buffer.append(data) + lock.unlock() + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: (any Error)? + ) { + if let error { + finish(.failure(error)) + return + } + + lock.lock() + let output = response.map { Output(data: buffer, response: $0) } + lock.unlock() + if let output { + finish(.success(output)) + } else { + finish(.failure(URLError(.badServerResponse))) + } + } + + private func finish( + _ result: Result, + cancellingTask: Bool = false + ) { + lock.lock() + guard let continuation else { + lock.unlock() + return + } + self.continuation = nil + let task = self.task + self.task = nil + lock.unlock() + + if cancellingTask { task?.cancel() } + session.finishTasksAndInvalidate() + continuation.resume(with: result) + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureToolModels.swift b/apps/swift-ios/Features/Shared/FeatureToolModels.swift index fc03f6f00b48..022c376e6005 100644 --- a/apps/swift-ios/Features/Shared/FeatureToolModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureToolModels.swift @@ -48,6 +48,14 @@ public struct FeatureFileEntry: Identifiable, Sendable, Equatable, Hashable, Cod } } +public extension FeatureFileEntry { + var containingDirectoryPath: String? { + guard let separator = path.lastIndex(of: "/") else { return nil } + let directory = path[.. [FeatureFileEntry] { let visible = includesHidden ? self : filter { !$0.isHidden } diff --git a/apps/swift-ios/Features/Workspace/DailyUXModels.swift b/apps/swift-ios/Features/Workspace/DailyUXModels.swift index 9fb6ba598164..9830bf685b3a 100644 --- a/apps/swift-ios/Features/Workspace/DailyUXModels.swift +++ b/apps/swift-ios/Features/Workspace/DailyUXModels.swift @@ -635,10 +635,14 @@ extension FeatureThread { .first(where: { $0.id == projectID })? .environmentID let resolvedEnvironmentID = environmentID ?? projectEnvironmentID - let providers = resolvedEnvironmentID.flatMap { + let environmentProviders = resolvedEnvironmentID.flatMap { snapshot.providersByEnvironment?[$0] - } ?? [] - return providers.first(where: { $0.id == providerID })?.name ?? providerID + } + let configuredProvider = environmentProviders?.first(where: { $0.id == providerID }) + ?? (snapshot.providersByEnvironment == nil + ? snapshot.providers.first(where: { $0.id == providerID }) + : nil) + return configuredProvider?.name ?? providerID } var needsAttention: Bool { diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift index 34c82e049c2c..a4c18df2355e 100644 --- a/apps/swift-ios/Features/Workspace/WorkspaceView.swift +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -780,9 +780,13 @@ struct HomeThreadRowContext: Equatable { let explicitProvider = thread.providerName? .trimmingCharacters(in: .whitespacesAndNewlines) let configuredProvider = thread.providerID.flatMap { providerID in - environmentID.flatMap { + let environmentProvider = environmentID.flatMap { snapshot.providersByEnvironment?[$0]?.first(where: { $0.id == providerID }) } + return environmentProvider + ?? (snapshot.providersByEnvironment == nil + ? snapshot.providers.first(where: { $0.id == providerID }) + : nil) } let providerName = (explicitProvider?.isEmpty == false ? explicitProvider : nil) ?? configuredProvider?.name diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureBoundedDataLoaderTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureBoundedDataLoaderTests.swift new file mode 100644 index 000000000000..416756620701 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/FeatureBoundedDataLoaderTests.swift @@ -0,0 +1,58 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Bounded workspace image downloads") +struct FeatureBoundedDataLoaderTests { + @Test + func returnsDataWithinTheLimit() async throws { + let output = try await FeatureBoundedDataLoader.data( + from: try #require(URL(string: "https://example.test/small")), + maximumBytes: 8, + configuration: Self.configuration + ) + #expect(output.0 == Data("small".utf8)) + } + + @Test + func stopsWhenIncrementalDataExceedsTheLimit() async throws { + await #expect(throws: FeatureBoundedDataLoaderError.tooLarge) { + try await FeatureBoundedDataLoader.data( + from: try #require(URL(string: "https://example.test/large")), + maximumBytes: 8, + configuration: Self.configuration + ) + } + } + + private static var configuration: URLSessionConfiguration { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [BoundedDataURLProtocol.self] + return configuration + } +} + +private final class BoundedDataURLProtocol: URLProtocol, @unchecked Sendable { + override class func canInit(with request: URLRequest) -> Bool { true } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let payload = request.url?.path == "/small" + ? Data("small".utf8) + : Data("larger-than-eight".utf8) + let response = URLResponse( + url: request.url!, + mimeType: "application/octet-stream", + expectedContentLength: -1, + textEncodingName: nil + ) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + let midpoint = payload.count / 2 + client?.urlProtocol(self, didLoad: payload[.. ![Quoted preview](images/quote.png) + """ + ) + + guard case let .unorderedList(items) = document.blocks.first else { + Issue.record("Expected image in a list") + return + } + #expect(items.first?.blocks == [ + .image(MarkdownImageReference(source: "images/list.png", alt: "List preview")), + ]) + + guard case let .blockquote(quote) = document.blocks.last else { + Issue.record("Expected image in a quote") + return + } + #expect(quote.blocks == [ + .image(MarkdownImageReference(source: "images/quote.png", alt: "Quoted preview")), + ]) + } + @Test func separatesHeadingsParagraphsAndListKinds() { let document = MarkdownDocument( @@ -263,4 +345,19 @@ struct MarkdownDocumentTests { #expect(runs.contains { $0.inlinePresentationIntent?.contains(.code) == true }) #expect(runs.contains { $0.link == URL(string: "https://example.com") }) } + + @Test @MainActor + func recycledMarkdownLinkRelayUsesItsLatestHandler() throws { + let relay = MarkdownLinkActionRelay() + let url = try #require(URL(string: "Sources/App.swift")) + var firstHandlerCount = 0 + var currentHandlerCount = 0 + + relay.handler = { _ in firstHandlerCount += 1 } + relay.handler = { _ in currentHandlerCount += 1 } + relay.open(url) + + #expect(firstHandlerCount == 0) + #expect(currentHandlerCount == 1) + } } diff --git a/apps/swift-ios/Tests/FeatureTests/MarkdownWorkspaceImageTests.swift b/apps/swift-ios/Tests/FeatureTests/MarkdownWorkspaceImageTests.swift new file mode 100644 index 000000000000..301e08cb18a0 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/MarkdownWorkspaceImageTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Markdown workspace images") +struct MarkdownWorkspaceImageTests { + @Test + func resolvesImageSourcesThroughWorkspaceFileLinks() throws { + let relative = try #require(MarkdownWorkspaceImage( + source: "./artifacts/demo%20image.png?download=1", + workspaceRoot: "/repo" + )) + #expect(relative.link.path == "artifacts/demo image.png") + + let absolute = try #require(MarkdownWorkspaceImage( + source: "/repo/artifacts/demo.png", + workspaceRoot: "/repo" + )) + #expect(absolute.link.path == "artifacts/demo.png") + + let root = try #require(MarkdownWorkspaceImage( + source: "screenshot.png", + workspaceRoot: "/repo" + )) + #expect(root.link.path == "screenshot.png") + + let nested = try #require(MarkdownWorkspaceImage( + source: "images/diagram.png", + workspaceRoot: "/repo", + relativeTo: "docs" + )) + #expect(nested.link.path == "docs/images/diagram.png") + #expect(nested.previewURL.path == "/repo/docs/images/diagram.png") + } + + @Test + func rejectsExternalEscapingAndNonImageSources() { + #expect(MarkdownWorkspaceImage( + source: "https://example.com/demo.png", + workspaceRoot: "/repo" + ) == nil) + #expect(MarkdownWorkspaceImage( + source: "../outside.png", + workspaceRoot: "/repo" + ) == nil) + #expect(MarkdownWorkspaceImage( + source: "recordings/demo.mp4", + workspaceRoot: "/repo" + ) == nil) + #expect(MarkdownWorkspaceImage( + source: "images/demo.png", + workspaceRoot: nil + ) == nil) + } +}