diff --git a/AGENTS.md b/AGENTS.md index 127435f0..09a10609 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ Project notes: - Swift tools version: 6.2 - Main app bundle ID: `com.rxlab.RxCode` - App-level dependencies: SwiftTerm and Sparkle -- Package dependencies: ViewInspector, Textual, and MarkdownUI +- Package dependencies: ViewInspector ## Repository Layout diff --git a/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.pbxproj b/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.pbxproj index 6c8aa8b0..877be04a 100644 --- a/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.pbxproj +++ b/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ @@ -100,7 +100,7 @@ ); mainGroup = 111111111111111111111401; packageReferences = ( - 111111111111111111111302 /* XCLocalSwiftPackageReference "../../" */, + 111111111111111111111302 /* XCLocalSwiftPackageReference "../.." */, ); productRefGroup = 111111111111111111111403 /* Products */; projectDirPath = ""; @@ -256,6 +256,9 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -270,7 +273,8 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.rxlab.MessageListSimulatorApp; PRODUCT_NAME = "$(TARGET_NAME)"; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + REGISTER_APP_GROUPS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; @@ -300,7 +304,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.rxlab.MessageListSimulatorApp; PRODUCT_NAME = "$(TARGET_NAME)"; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; @@ -333,7 +337,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 111111111111111111111302 /* XCLocalSwiftPackageReference "../../" */ = { + 111111111111111111111302 /* XCLocalSwiftPackageReference "../.." */ = { isa = XCLocalSwiftPackageReference; relativePath = ../..; }; @@ -342,7 +346,7 @@ /* Begin XCSwiftPackageProductDependency section */ 111111111111111111111301 /* MessageList */ = { isa = XCSwiftPackageProductDependency; - package = 111111111111111111111302 /* XCLocalSwiftPackageReference "../../" */; + package = 111111111111111111111302 /* XCLocalSwiftPackageReference "../.." */; productName = MessageList; }; /* End XCSwiftPackageProductDependency section */ diff --git a/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 647395b0..a35a744c 100644 --- a/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,60 +1,6 @@ { "originHash" : "0742dbd4b5422e8d476168a334a6c2788dd1c5ed06c9e5ca65cf7f02bdf63de4", "pins" : [ - { - "identity" : "networkimage", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/NetworkImage", - "state" : { - "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", - "version" : "6.0.1" - } - }, - { - "identity" : "swift-cmark", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-cmark", - "state" : { - "revision" : "924936d0427cb25a61169739a7660230bffa6ea6", - "version" : "0.8.0" - } - }, - { - "identity" : "swift-concurrency-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras", - "state" : { - "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", - "version" : "1.3.2" - } - }, - { - "identity" : "swift-markdown-ui", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/swift-markdown-ui", - "state" : { - "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", - "version" : "2.4.1" - } - }, - { - "identity" : "swiftui-math", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/swiftui-math", - "state" : { - "revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71", - "version" : "0.1.0" - } - }, - { - "identity" : "textual", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/textual", - "state" : { - "revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38", - "version" : "0.3.1" - } - } ], "version" : 3 } diff --git a/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp/MessageListSimulatorApp.swift b/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp/MessageListSimulatorApp.swift index 30e9118c..3822477e 100644 --- a/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp/MessageListSimulatorApp.swift +++ b/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp/MessageListSimulatorApp.swift @@ -35,7 +35,11 @@ private struct MessageListManualTestView: View { .padding(.horizontal, 16) .padding(.vertical, 6) } + #if os(iOS) .background(Color(uiColor: .systemBackground)) + #else + .background(Color(nsColor: .windowBackgroundColor)) + #endif } } @@ -73,7 +77,11 @@ private struct MessageListManualTestView: View { .foregroundStyle(.secondary) } .padding(12) + #if os(iOS) .background(Color(uiColor: .secondarySystemBackground)) + #else + .background(Color(nsColor: .controlBackgroundColor)) + #endif } private func appendUserMessage() { @@ -143,7 +151,11 @@ private struct DemoMessageRow: View { .padding(.vertical, 9) .background { RoundedRectangle(cornerRadius: 8) + #if os(iOS) .fill(message.isUserMessage ? Color.accentColor : Color(uiColor: .secondarySystemBackground)) + #else + .fill(message.isUserMessage ? Color.accentColor : Color(nsColor: .controlBackgroundColor)) + #endif } .accessibilityIdentifier("message-\(message.text)") diff --git a/Packages/Package.resolved b/Packages/Package.resolved index 2b055bdd..557968cf 100644 --- a/Packages/Package.resolved +++ b/Packages/Package.resolved @@ -1,60 +1,6 @@ { - "originHash" : "b5e2db87d744b0386db13e49f7a31484bb89dcefe6309652625575513faae4ab", + "originHash" : "3127591c474948afb96f0c81df8be151fe447da11fef447bbb933b0712661346", "pins" : [ - { - "identity" : "networkimage", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/NetworkImage", - "state" : { - "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", - "version" : "6.0.1" - } - }, - { - "identity" : "swift-cmark", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-cmark", - "state" : { - "revision" : "924936d0427cb25a61169739a7660230bffa6ea6", - "version" : "0.8.0" - } - }, - { - "identity" : "swift-concurrency-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras", - "state" : { - "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", - "version" : "1.3.2" - } - }, - { - "identity" : "swift-markdown-ui", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/swift-markdown-ui", - "state" : { - "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", - "version" : "2.4.1" - } - }, - { - "identity" : "swiftui-math", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/swiftui-math", - "state" : { - "revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71", - "version" : "0.1.0" - } - }, - { - "identity" : "textual", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/textual", - "state" : { - "revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38", - "version" : "0.3.1" - } - }, { "identity" : "viewinspector", "kind" : "remoteSourceControl", diff --git a/Packages/Package.swift b/Packages/Package.swift index 0101f25a..8c8633b7 100644 --- a/Packages/Package.swift +++ b/Packages/Package.swift @@ -9,13 +9,12 @@ let package = Package( .library(name: "MessageList", targets: ["MessageList"]), .library(name: "RxCodeCore", targets: ["RxCodeCore"]), .library(name: "RxCodeChatKit", targets: ["RxCodeChatKit"]), + .library(name: "RxCodeMarkdown", targets: ["RxCodeMarkdown"]), .library(name: "RxCodeSync", targets: ["RxCodeSync"]), .library(name: "DiffView", targets: ["DiffView"]), ], dependencies: [ .package(url: "https://github.com/nalexn/ViewInspector", from: "0.10.0"), - .package(url: "https://github.com/gonzalezreal/textual", from: "0.3.1"), - .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.1"), ], targets: [ .target( @@ -29,14 +28,17 @@ let package = Package( name: "RxCodeCore", path: "Sources/RxCodeCore" ), + .target( + name: "RxCodeMarkdown", + path: "Sources/RxCodeMarkdown" + ), .target( name: "RxCodeChatKit", dependencies: [ "DiffView", "MessageList", "RxCodeCore", - .product(name: "Textual", package: "textual"), - .product(name: "MarkdownUI", package: "swift-markdown-ui"), + "RxCodeMarkdown", ], path: "Sources/RxCodeChatKit", resources: [ @@ -86,6 +88,11 @@ let package = Package( ], path: "Tests/RxCodeChatKitTests" ), + .testTarget( + name: "RxCodeMarkdownTests", + dependencies: ["RxCodeMarkdown"], + path: "Tests/RxCodeMarkdownTests" + ), .testTarget( name: "RxCodeSyncTests", dependencies: ["RxCodeSync"], diff --git a/Packages/Sources/MessageList/MessageList.swift b/Packages/Sources/MessageList/MessageList.swift index 1c955b97..00428cbc 100644 --- a/Packages/Sources/MessageList/MessageList.swift +++ b/Packages/Sources/MessageList/MessageList.swift @@ -89,6 +89,13 @@ public struct MessageList: View { tailMarker bottomLoadTrigger + // The tail spacer is sized so that `turnHeight + spacer == viewport` + // (see `pinTailSpacerHeight`). The bottom anchor therefore sits BELOW + // the spacer: scrolling to it places the spacer's end at the viewport + // bottom, which is exactly the position where the latest user message + // rests at the top with the reserved space filling the rest. As the + // turn grows the spacer shrinks toward zero, at which point the same + // anchor naturally follows the streaming response. pinTailSpacer bottomAnchor } @@ -99,7 +106,6 @@ public struct MessageList: View { geometry.size.height } action: { height in scrollViewHeight = height - updateActiveTurnMaxMeasuredHeight() } .onScrollGeometryChange(for: MessageListScrollMetrics.self) { geometry in MessageListScrollMetrics( @@ -205,7 +211,14 @@ public struct MessageList: View { } private var pinTailSpacerHeight: CGFloat { - guard pinning.isPinningUserMessage, scrollViewHeight > 0 else { return 0 } + // Persistent reservation: as long as there is a latest user message, reserve + // `viewport - turnHeight` at the bottom so the turn (latest user message → + // end of content) can rest at the top of the viewport. This is keyed off the + // tracked user message — NOT the transient `isPinningUserMessage` flag — so the + // reserved space survives scrolling and the pin "releasing"; it only collapses + // naturally as the turn grows to fill the viewport, or when the latest user + // message changes (which resets the measurement to the new turn). + guard pinning.pinnedUserMessageID != nil, scrollViewHeight > 0 else { return 0 } return max(0, scrollViewHeight - activeTurnHeight - MessageListConstants.minimumPinnedTailSpacing) } @@ -214,7 +227,10 @@ public struct MessageList: View { } private var activeTurnHeight: CGFloat { - max(activeTurnMaxMeasuredHeight, rawActiveTurnMeasuredHeight) + // Use only the settled, ratcheted height (committed from `handleScrollMetrics`). + // Mixing in the live `rawActiveTurnMeasuredHeight` here would let a mid-frame + // desync between the two geometry anchors momentarily shrink the spacer. + activeTurnMaxMeasuredHeight } private var pinnedTurnFillsViewport: Bool { @@ -265,6 +281,16 @@ public struct MessageList: View { ) updateIsAtBottomBinding(anchor.isNearBottom) + // Commit the active-turn height here rather than from the per-row geometry + // callbacks. This callback fires once the scroll view's geometry has settled + // for the frame, so `latestUserMinY` and `tailMarkerMinY` are guaranteed to + // reflect the same layout pass. Reading them from the individual row + // callbacks could capture a transient state where one anchor moved (e.g. a + // lazy row above the turn was just realized while scrolling) but the other + // had not — which would ratchet a bogus height and permanently collapse the + // reserved tail spacer. + updateActiveTurnMaxMeasuredHeight() + if shouldReleasePinnedUserMessageForFilledTurn, !isUserDrivenScroll { releasePinnedUserMessage(proxy: proxy) } @@ -295,6 +321,15 @@ public struct MessageList: View { newToken: MessageListChangeToken, proxy: ScrollViewProxy ) { + // Drop a stale pin if its message is no longer present (e.g. switching + // sessions or deleting messages). The persistent tail spacer is keyed off + // `pinnedUserMessageID`, so a dangling id would otherwise reserve space for a + // message that no longer exists. + if let pinnedID = pinning.pinnedUserMessageID, + !messages.contains(where: { $0.id == pinnedID }) { + clearPinnedUserMessage() + } + let latestContentItem = latestContentItem if oldToken.latestUserMessageID != newToken.latestUserMessageID, let latestUserMessageID = newToken.latestUserMessageID, @@ -324,6 +359,13 @@ public struct MessageList: View { } private func scrollToBottom(proxy: ScrollViewProxy, animated: Bool) { + // While reserved spacing exists below the latest turn, the content already fits + // in the viewport — a follow/auto scroll would only pull the empty reserved + // space into view and shove the turn around. Only scroll once the turn has + // outgrown the viewport (no spacing left). The one scroll that is allowed to + // move into the reserved area is the initial turn placement, which goes through + // `scrollLatestTurnIntoView` (a direct `proxy.scrollTo`), not this path. + guard pinTailSpacerHeight <= 0 else { return } if animated { withAnimation(.easeInOut(duration: MessageListConstants.scrollAnimationSeconds)) { proxy.scrollTo(MessageListConstants.bottomAnchorID, anchor: .bottom) @@ -359,7 +401,15 @@ public struct MessageList: View { return UInt64(delay * 1_000_000_000) } - private func pinUserMessageToTop(_ id: Message.ID, proxy: ScrollViewProxy, animated: Bool) { + /// Positions the latest user turn by scrolling to the bottom anchor — NOT by + /// scrolling the user message to the top. Because the tail spacer is sized so that + /// `turnHeight + spacer == viewport`, scrolling to the bottom anchor lands the + /// latest user message at the top with the reserved space filling the rest. Using + /// the bottom anchor here (the same target the auto-scroll uses) means the two + /// never fight: a separate `scrollTo(userMessage, .top)` would disagree with the + /// auto-scroll whenever the spacer hadn't settled yet, which caused the visible + /// jump when a new message was added. + private func scrollLatestTurnIntoView(proxy: ScrollViewProxy, animated: Bool) { bottomScrollTask?.cancel() bottomScrollTask = nil pinTask?.cancel() @@ -371,17 +421,20 @@ public struct MessageList: View { if animated { withAnimation(.easeInOut(duration: MessageListConstants.pinAnimationSeconds)) { - proxy.scrollTo(id, anchor: .top) + proxy.scrollTo(MessageListConstants.bottomAnchorID, anchor: .bottom) } try? await Task.sleep(for: MessageListConstants.pinAnimationDuration) } + // Re-assert across several frames so the position tracks the tail spacer + // as it settles to its final size (the turn height is measured a frame or + // two after the freshly-added content lays out). for _ in 0..<8 { guard !Task.isCancelled else { return } var transaction = Transaction() transaction.animation = nil withTransaction(transaction) { - proxy.scrollTo(id, anchor: .top) + proxy.scrollTo(MessageListConstants.bottomAnchorID, anchor: .bottom) } try? await Task.sleep(for: .milliseconds(16)) } @@ -418,12 +471,17 @@ public struct MessageList: View { break case .clearPin: clearPinnedUserMessage() - case .pinUserMessageToTop(let id): + case .pinUserMessageToTop: resetPinnedTurnMeasurements() canReleasePinnedUserMessageByScroll = false - pinUserMessageToTop(id, proxy: proxy, animated: true) - case .repinUserMessageToTop(let id): - pinUserMessageToTop(id, proxy: proxy, animated: false) + scrollLatestTurnIntoView(proxy: proxy, animated: true) + case .repinUserMessageToTop: + // New streaming content arrived. While the reserved spacing still absorbs + // the growth, the turn stays put on its own — re-asserting the scroll would + // just cause an unnecessary jump. Only re-position once the spacing is gone. + if pinTailSpacerHeight <= 0 { + scrollLatestTurnIntoView(proxy: proxy, animated: false) + } case .releasePinAndScrollToBottom: releasePinnedUserMessage(proxy: proxy) case .scrollToBottom: @@ -444,7 +502,6 @@ public struct MessageList: View { withTransaction(transaction) { latestUserMinY = value } - updateActiveTurnMaxMeasuredHeight() } private func updateTailMarkerMinY(_ value: CGFloat) { @@ -454,11 +511,12 @@ public struct MessageList: View { withTransaction(transaction) { tailMarkerMinY = value } - updateActiveTurnMaxMeasuredHeight() } private func updateActiveTurnMaxMeasuredHeight() { - guard pinning.isPinningUserMessage else { return } + // Keep measuring the turn height while a latest user message is tracked, even + // after the pin "releases", so the persistent tail spacer stays correctly sized. + guard pinning.pinnedUserMessageID != nil else { return } let measured = rawActiveTurnMeasuredHeight guard measured > activeTurnMaxMeasuredHeight + 0.5 else { return } var transaction = Transaction() diff --git a/Packages/Sources/RxCodeChatKit/MarkdownView.swift b/Packages/Sources/RxCodeChatKit/MarkdownView.swift index b306322e..7391528f 100644 --- a/Packages/Sources/RxCodeChatKit/MarkdownView.swift +++ b/Packages/Sources/RxCodeChatKit/MarkdownView.swift @@ -1,506 +1,88 @@ import SwiftUI -import Foundation -import MarkdownUI import RxCodeCore -import Textual -#if canImport(AppKit) -import AppKit -#elseif canImport(UIKit) -import UIKit -#endif - -// MARK: - Markdown Content View +import RxCodeMarkdown + +extension MarkdownStyle { + static var rxCodeChat: MarkdownStyle { + MarkdownStyle( + bodyFontSize: ClaudeTheme.messageSize(15), + bodyColor: ClaudeTheme.textPrimary, + secondaryColor: ClaudeTheme.textSecondary, + accentColor: ClaudeTheme.accent, + codeTextColor: ClaudeTheme.textPrimary, + codeBackground: ClaudeTheme.codeBackground, + codeHeaderBackground: ClaudeTheme.codeHeaderBackground, + borderColor: ClaudeTheme.border, + tableHeaderBackground: ClaudeTheme.surfaceSecondary, + lineSpacing: 3, + blockSpacing: 8, + cornerRadius: ClaudeTheme.cornerRadiusSmall + ) + } +} -/// Renders markdown text with the renderer selected in `Settings.swift`. +/// Renders markdown text through the pure SwiftUI markdown package while +/// preserving the historical ChatKit entry point. public struct MarkdownContentView: View { let text: String let showsTrailingCursor: Bool let isCursorVisible: Bool let baseURL: URL? + let fadeNewText: Bool + let onOpenLink: MarkdownView.LinkHandler? public init( text: String, showsTrailingCursor: Bool = false, isCursorVisible: Bool = true, - baseURL: URL? = nil + baseURL: URL? = nil, + fadeNewText: Bool = false, + onOpenLink: MarkdownView.LinkHandler? = nil ) { self.text = text self.showsTrailingCursor = showsTrailingCursor self.isCursorVisible = isCursorVisible self.baseURL = baseURL + self.fadeNewText = fadeNewText + self.onOpenLink = onOpenLink } public var body: some View { - switch RxCodeChatKitSettings.markdownRenderer { - case .textual: - TextualMarkdownContentView( - text: text, - showsTrailingCursor: showsTrailingCursor, - isCursorVisible: isCursorVisible - ) - case .markdownUI: - MarkdownUIMarkdownContentView( - text: text, - showsTrailingCursor: showsTrailingCursor, - isCursorVisible: isCursorVisible, - baseURL: baseURL - ) - } - } -} - -// MARK: - Textual Renderer - -private struct TextualMarkdownContentView: View { - let text: String - let showsTrailingCursor: Bool - let isCursorVisible: Bool - - var body: some View { - StructuredText(markdown: renderedMarkdown) - .id(renderedMarkdown) - .font(.system(size: 15)) - .tint(ClaudeTheme.accent) - .textual.inlineStyle( - InlineStyle() - .code( - .monospaced, - .fontScale(0.93), - .backgroundColor(ClaudeTheme.surfaceTertiary), - .foregroundColor(ClaudeTheme.textPrimary) - ) - ) - .textual.headingStyle(RxCodeHeadingStyle()) - .textual.codeBlockStyle(RxCodeBlockStyle()) - .textual.textSelection(.disabled) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var renderedMarkdown: String { - let processed = preprocessMarkdown(text) - if showsTrailingCursor && isCursorVisible { - return processed + "\u{2009}\u{25CF}" - } - return processed - } -} - -// MARK: - MarkdownUI Renderer - -private struct MarkdownUIMarkdownContentView: View { - let text: String - let showsTrailingCursor: Bool - let isCursorVisible: Bool - let baseURL: URL? - - var body: some View { - Markdown(renderedMarkdown, baseURL: baseURL, imageBaseURL: baseURL) - .id(renderedMarkdown) - .markdownTheme(.rxCodeChat) - .markdownImageProvider(LocalFileImageProvider()) - .markdownTextStyle(\.code) { - FontFamilyVariant(.monospaced) - FontSize(.em(0.93)) - ForegroundColor(ClaudeTheme.textPrimary) - BackgroundColor(ClaudeTheme.surfaceTertiary) - } - .markdownBlockStyle(\.codeBlock) { configuration in - MarkdownUICodeBlock( - language: configuration.language, - content: configuration.content - ) { - configuration.label - } - } - .tint(ClaudeTheme.accent) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var renderedMarkdown: String { - let processed = preprocessMarkdown(text) - if showsTrailingCursor && isCursorVisible { - return processed + "\u{2009}\u{25CF}" - } - return processed - } -} - -private struct MarkdownUICodeBlock: View { - let language: String? - let content: String - @ViewBuilder let label: () -> Label - @State private var isCopied = false - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack { - if let language, !language.isEmpty { - Text(language) - .font(.system(size: ClaudeTheme.messageSize(11), weight: .medium, design: .monospaced)) - .foregroundStyle(ClaudeTheme.textTertiary) - } - Spacer() - Button { - copyToClipboard(content, feedback: $isCopied) - } label: { - HStack(spacing: 4) { - Image(systemName: isCopied ? "checkmark" : "doc.on.doc") - .font(.caption2) - Text(isCopied ? String(localized: "Copied", bundle: .module) : String(localized: "Copy", bundle: .module)) - .font(.caption2) - } - .foregroundStyle(isCopied ? ClaudeTheme.statusSuccess : ClaudeTheme.textTertiary) - } - .buttonStyle(.plain) - } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(ClaudeTheme.codeHeaderBackground) - - Rectangle() - .fill(ClaudeTheme.border) - .frame(height: 0.5) - - ScrollView(.horizontal, showsIndicators: false) { - label() - .markdownTextStyle { - FontFamilyVariant(.monospaced) - FontSize(.em(0.88)) - ForegroundColor(ClaudeTheme.textPrimary) - BackgroundColor(nil) - } - .fixedSize() - .padding(12) - } - .frame(maxWidth: .infinity, alignment: .leading) - .background(ClaudeTheme.codeBackground) - } - .clipShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) - .overlay( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) - .strokeBorder(ClaudeTheme.border, lineWidth: 0.5) + MarkdownView( + text: text, + showsTrailingCursor: showsTrailingCursor, + isCursorVisible: isCursorVisible, + baseURL: baseURL, + style: .rxCodeChat, + fadeNewText: fadeNewText, + onOpenLink: onOpenLink ) - .markdownMargin(top: .em(0.88), bottom: .em(0.4)) - } -} - -private extension Theme { - static let rxCodeChat = Theme.gitHub - .text { - FontSize(ClaudeTheme.messageSize(15)) - ForegroundColor(ClaudeTheme.textPrimary) - } - .link { - ForegroundColor(ClaudeTheme.accent) - } - .heading1 { configuration in - configuration.label - .markdownTextStyle { - FontSize(.em(1.33)) - FontWeight(.bold) - ForegroundColor(ClaudeTheme.textPrimary) - } - .markdownMargin(top: .em(1.2), bottom: .em(0.4)) - } - .heading2 { configuration in - configuration.label - .markdownTextStyle { - FontSize(.em(1.2)) - FontWeight(.bold) - ForegroundColor(ClaudeTheme.textPrimary) - } - .markdownMargin(top: .em(1.2), bottom: .em(0.4)) - } - .heading3 { configuration in - configuration.label - .markdownTextStyle { - FontSize(.em(1.07)) - FontWeight(.semibold) - ForegroundColor(ClaudeTheme.textPrimary) - } - .markdownMargin(top: .em(1.2), bottom: .em(0.4)) - } - .blockquote { configuration in - configuration.label - .markdownTextStyle { - ForegroundColor(ClaudeTheme.textSecondary) - } - .padding(.leading, 12) - .overlay(alignment: .leading) { - Rectangle() - .fill(ClaudeTheme.accent) - .frame(width: 3) - } - .markdownMargin(top: .em(0.8), bottom: .em(0.4)) - } - .paragraph { configuration in - configuration.label - .relativeLineSpacing(.em(0.2)) - .markdownMargin(top: .em(0), bottom: .em(0.5)) - } - .listItem { configuration in - configuration.label - .markdownMargin(top: .em(0.15), bottom: .em(0.15)) - } -} - -// MARK: - Markdown Preprocessing - -private func preprocessMarkdown(_ text: String) -> String { - var lines: [String] = [] - var inFence = false - for line in text.components(separatedBy: "\n") { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("```") || trimmed.hasPrefix("~~~") { - inFence.toggle() - lines.append(line) - } else if inFence { - lines.append(line) - } else { - lines.append(autoLinkURLs(sanitizeMarkdownLinkURLs(convertHTMLImages(line)))) - } + .tint(ClaudeTheme.accent) } - return lines.joined(separator: "\n") } -/// Rewrites HTML `...` tags into markdown image syntax so -/// MarkdownUI renders them instead of treating them as raw HTML. -func convertHTMLImages(_ text: String) -> String { - let pattern = #"]*?)/?>"# - guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { - return text - } - let range = NSRange(text.startIndex..., in: text) - var result = text - for match in regex.matches(in: text, range: range).reversed() { - guard let fullRange = Range(match.range, in: result), - let attrsRange = Range(match.range(at: 1), in: result) else { continue } - let attrs = String(result[attrsRange]) - guard let src = htmlAttribute("src", in: attrs) else { continue } - let alt = htmlAttribute("alt", in: attrs) ?? "" - result.replaceSubrange(fullRange, with: "![\(alt)](\(src))") - } - return result -} - -private func htmlAttribute(_ name: String, in attrs: String) -> String? { - let pattern = "\(name)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s>]+))" - guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]), - let match = regex.firstMatch(in: attrs, range: NSRange(attrs.startIndex..., in: attrs)) else { - return nil - } - for index in 1.. some View { - let level = min(max(configuration.headingLevel, 1), 6) - configuration.label - .textual.fontScale(Self.fontScales[level - 1]) - .fontWeight(Self.fontWeights[level - 1]) - .textual.blockSpacing(.fontScaled(top: 1.2, bottom: 0.4)) - } -} - -private struct RxCodeBlockStyle: StructuredText.CodeBlockStyle { - func makeBody(configuration: Configuration) -> some View { - RxCodeBlockBody(configuration: configuration) - } -} - -private struct RxCodeBlockBody: View { - let configuration: RxCodeBlockStyle.Configuration - @State private var isCopied = false - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack { - if let language = configuration.languageHint, !language.isEmpty { - Text(language) - .font(.system(size: ClaudeTheme.messageSize(11), weight: .medium, design: .monospaced)) - .foregroundStyle(ClaudeTheme.textTertiary) - } - Spacer() - copyButton - } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(ClaudeTheme.codeHeaderBackground) - - Rectangle() - .fill(ClaudeTheme.border) - .frame(height: 0.5) - - ScrollView(.horizontal, showsIndicators: false) { - configuration.label - .monospaced() - .textual.fontScale(0.882) - .textual.lineSpacing(.fontScaled(0.39)) - .fixedSize() - .padding(12) - } - .frame(maxWidth: .infinity, alignment: .leading) - .background(ClaudeTheme.codeBackground) - } - .clipShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) - .overlay( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) - .strokeBorder(ClaudeTheme.border, lineWidth: 0.5) - ) - .textual.blockSpacing(.fontScaled(top: 0.88, bottom: 0.4)) - } - - private var copyButton: some View { - Button { - configuration.codeBlock.copyToPasteboard() - withAnimation { isCopied = true } - Task { - try? await Task.sleep(for: .seconds(2)) - withAnimation { isCopied = false } - } - } label: { - HStack(spacing: 4) { - Image(systemName: isCopied ? "checkmark" : "doc.on.doc") - .font(.caption2) - Text(isCopied ? String(localized: "Copied", bundle: .module) : String(localized: "Copy", bundle: .module)) - .font(.caption2) - } - .foregroundStyle(isCopied ? ClaudeTheme.statusSuccess : ClaudeTheme.textTertiary) - } - .buttonStyle(.plain) - } -} - -// MARK: - Local File Image Provider - -/// Renders images from `file://` URLs by loading them via NSImage, and falls -/// back to MarkdownUI's default network image provider for remote URLs. -private struct LocalFileImageProvider: ImageProvider { - func makeImage(url: URL?) -> some View { - Group { - if let url, url.isFileURL { - LocalFileImage(url: url) - } else { - DefaultImageProvider.default.makeImage(url: url) - } - } - } -} - -private struct LocalFileImage: View { - let url: URL - - var body: some View { - if let loaded = LocalFileImage.load(url: url) { - loaded.image - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: loaded.size.width) - } else { - Text("⚠︎ \(url.lastPathComponent)") - .font(.caption) - .foregroundStyle(ClaudeTheme.textTertiary) - } - } - - private struct Loaded { - let image: Image - let size: CGSize - } - - private static func load(url: URL) -> Loaded? { - #if canImport(AppKit) - guard let nsImage = NSImage(contentsOf: url) else { return nil } - return Loaded(image: Image(nsImage: nsImage), size: nsImage.size) - #elseif canImport(UIKit) - guard let data = try? Data(contentsOf: url), - let uiImage = UIImage(data: data) else { return nil } - return Loaded(image: Image(uiImage: uiImage), size: uiImage.size) - #else - return nil - #endif - } -} - -// MARK: - Markdown Link Helpers - -func sanitizeMarkdownLinkURLs(_ text: String) -> String { - let pattern = #"\[([^\]]*)\]\(([^)]*`[^)]*)\)"# - guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } - let range = NSRange(text.startIndex..., in: text) - var result = text - for match in regex.matches(in: text, range: range).reversed() { - guard let fullRange = Range(match.range, in: result), - let labelRange = Range(match.range(at: 1), in: result), - let urlRange = Range(match.range(at: 2), in: result) else { continue } - let label = String(result[labelRange]) - let url = String(result[urlRange]).replacingOccurrences(of: "`", with: "") - result.replaceSubrange(fullRange, with: "[\(label)](\(url))") - } - return result -} - -func autoLinkURLs(_ text: String) -> String { - let pattern = #"(?\[\]`]+"# - guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } - let range = NSRange(text.startIndex..., in: text) - var result = text - for match in regex.matches(in: text, range: range).reversed() { - guard let swiftRange = Range(match.range, in: result) else { continue } - let url = String(result[swiftRange]) - result.replaceSubrange(swiftRange, with: "[\(url)](\(url))") - } - return result -} - -// MARK: - Previews - -#Preview("Markdown") { +#Preview("Chat Markdown") { ScrollView { MarkdownContentView(text: """ # H1 Heading ## H2 Subheading - ### H3 Section heading - #### H4 Small heading - This is a **markdown** test. `Inline code` is also supported. + This is a **markdown** test with `inline code` and [a link](https://example.com). - > This is a blockquote. Use it to emphasize important content. + > This is a blockquote. - List item 1 - - List item 2 - - **Bold** list item 3 - - 1. Ordered list - 2. Second item - 3. Third item - - --- + - **Bold** list item 2 | Item | Value | - |------|-------| - | Swift files | 381 | - | Total lines | ~55,000 | - | SwiftUI : UIKit ratio | 87% : 13% | + | --- | --- | + | Swift files | Many | ```swift func hello() { - print("Hello, World!") + print("Hello") } ``` - - Regular text continues here. """) .padding() } diff --git a/Packages/Sources/RxCodeChatKit/MessageBubble.swift b/Packages/Sources/RxCodeChatKit/MessageBubble.swift index 2badeb5b..c76bb375 100644 --- a/Packages/Sources/RxCodeChatKit/MessageBubble.swift +++ b/Packages/Sources/RxCodeChatKit/MessageBubble.swift @@ -297,11 +297,11 @@ struct MessageBubble: View { // MARK: - Assistant Text Bubble private func assistantTextBubble(text: String, blockId: String, showsCursor: Bool) -> some View { - StreamingFadeInMarkdownContent( + MarkdownContentView( text: text, showsTrailingCursor: showsCursor, isCursorVisible: cursorVisible, - isStreaming: message.isStreaming + fadeNewText: message.isStreaming ) .frame(maxWidth: .infinity, alignment: .leading) .foregroundStyle(ClaudeTheme.textPrimary) @@ -324,104 +324,6 @@ struct MessageBubble: View { .accessibilityLabel("Assistant: \(text)") } - private struct StreamingFadeInMarkdownContent: View { - let text: String - let showsTrailingCursor: Bool - let isCursorVisible: Bool - let isStreaming: Bool - - private static let fadeDuration: TimeInterval = 0.35 - - @State private var committedText: String - @State private var fadingText: String? - @State private var fadingOpacity: Double = 1 - @State private var fadeTask: Task? - - init(text: String, showsTrailingCursor: Bool, isCursorVisible: Bool, isStreaming: Bool) { - self.text = text - self.showsTrailingCursor = showsTrailingCursor - self.isCursorVisible = isCursorVisible - self.isStreaming = isStreaming - _committedText = State(initialValue: text) - } - - var body: some View { - content - .onChange(of: text) { _, newText in - guard newText != currentText else { return } - if isStreaming { - fadeIn(newText) - } else { - fadeTask?.cancel() - committedText = newText - fadingText = nil - fadingOpacity = 1 - } - } - .onChange(of: isStreaming) { _, streaming in - guard !streaming else { return } - fadeTask?.cancel() - committedText = text - fadingText = nil - fadingOpacity = 1 - } - .onDisappear { - fadeTask?.cancel() - } - } - - @ViewBuilder - private var content: some View { - if let fadingText { - ZStack(alignment: .topLeading) { - MarkdownContentView( - text: fadingText, - showsTrailingCursor: showsTrailingCursor, - isCursorVisible: isCursorVisible - ) - .opacity(fadingOpacity) - - MarkdownContentView( - text: committedText, - showsTrailingCursor: false, - isCursorVisible: false - ) - } - } else { - MarkdownContentView( - text: committedText, - showsTrailingCursor: showsTrailingCursor, - isCursorVisible: isCursorVisible - ) - } - } - - private var currentText: String { - fadingText ?? committedText - } - - private func fadeIn(_ newText: String) { - fadeTask?.cancel() - committedText = currentText - fadingText = newText - fadingOpacity = 0 - fadeTask = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(16)) - guard !Task.isCancelled else { return } - withAnimation(.easeOut(duration: Self.fadeDuration)) { - fadingOpacity = 1 - } - try? await Task.sleep(for: .milliseconds(Int(Self.fadeDuration * 1_000))) - guard !Task.isCancelled, fadingText == newText else { return } - withTransaction(Transaction(animation: nil)) { - committedText = newText - fadingText = nil - fadingOpacity = 1 - } - } - } - } - private func streamingCursorBlockId(in renderBlocks: [AssistantRenderBlock]) -> String? { guard message.isStreaming, latestStreamingAssistantMessageId == message.id, @@ -745,6 +647,19 @@ struct MessageBubble: View { )) ?? AttributedString(text) } + private func autoLinkURLs(_ text: String) -> String { + let pattern = #"(?\[\]`]+"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } + let range = NSRange(text.startIndex..., in: text) + var result = text + for match in regex.matches(in: text, range: range).reversed() { + guard let swiftRange = Range(match.range, in: result) else { continue } + let url = String(result[swiftRange]) + result.replaceSubrange(swiftRange, with: "[\(url)](\(url))") + } + return result + } + /// Renders `[Image\d+]` tokens with accent-tinted chip styling. The same tokens /// are inserted into the input bar by `WindowState.insertImageToken` and drawn /// with a rounded background there via `ChipLayoutManager`; this mirrors that diff --git a/Packages/Sources/RxCodeChatKit/MessageListView.swift b/Packages/Sources/RxCodeChatKit/MessageListView.swift index b50f4e4f..cdfc6a0f 100644 --- a/Packages/Sources/RxCodeChatKit/MessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/MessageListView.swift @@ -5,13 +5,19 @@ import os #if os(macOS) +nonisolated enum MessageListViewScrollPolicy { + static func shouldRequestLiveBottomScroll(wasAtBottom: Bool) -> Bool { + wasAtBottom + } +} + /// Message scroll area — extracted from ChatView to isolate @Observable /// dependencies on `messages`. /// -/// Behavior: the transcript stays anchored to the bottom. A freshly opened -/// thread, a new send, and a streaming response all keep the latest message in -/// view. `AutoScrollAnchor` releases that anchor only when the user deliberately -/// scrolls up; `scrollPhase` keeps auto-scroll from fighting an active drag. +/// Behavior: freshly opened threads and new sends bring the latest turn into +/// view. Live assistant updates follow the bottom only while the user is still +/// at the bottom, so reading older messages is not interrupted by streaming +/// deltas or the streaming-to-settled handoff. struct MessageListView: View { @Environment(ChatBridge.self) private var chatBridge @Environment(WindowState.self) private var windowState @@ -166,7 +172,7 @@ struct MessageListView: View { } .onChange(of: chatBridge.messages.last?.content) { _, _ in guard isSessionReady else { return } - requestScrollToBottom() + requestScrollToBottomIfAtBottom() } .onChange(of: isSessionReady) { _, new in Self.log.info("[MessageList.ready] isSessionReady=\(new) sid=\(windowState.currentSessionId ?? "", privacy: .public) settled=\(settledItems.count)") @@ -195,7 +201,7 @@ struct MessageListView: View { if chatBridge.isStreaming { rebuildSettledItems() if !isSessionReady { isSessionReady = true } - requestScrollToBottom() + requestScrollToBottomIfAtBottom() Self.log.info("[MessageList.task] streaming-path settled=\(settledItems.count) sid=\(sid, privacy: .public)") return } @@ -256,18 +262,22 @@ struct MessageListView: View { private func handleStreamingChange(old: Bool, new: Bool) { logScrollState("streamingChange", extra: "old=\(old) new=\(new) lastRole=\(chatBridge.messages.last?.role.rawValue ?? "")") + let wasAtBottom = isAtBottom // Only react when streaming ends — the settled list doesn't change at start. guard old && !new else { - requestScrollToBottom() + requestScrollToBottomIfAtBottom(wasAtBottom) return } pendingIndicatorSpacerReduction = 0 rebuildSettledItems() - anchor.resetToBottom() // The just-finished turn moves out of `StreamingMessageView` and into // the settled list. That row handoff makes the scroll content reload and can - // momentarily snap the offset; re-assert the bottom across the handoff. - requestScrollToBottom() + // momentarily snap the offset. Re-assert the bottom only if the user was + // still following the bottom before the handoff. + if wasAtBottom { + anchor.resetToBottom() + requestScrollToBottom() + } } // MARK: - Helpers @@ -337,11 +347,12 @@ struct MessageListView: View { private func handleLastMessageChange() { guard isSessionReady, !chatBridge.isLoadingFromDisk else { return } + let wasAtBottom = isAtBottom rebuildSettledItems() guard let last = chatBridge.messages.last else { return } logScrollState("lastMessageChange", extra: "lastRole=\(last.role.rawValue) lastID=\(last.id.uuidString)") if last.role != .user { - requestScrollToBottom() + requestScrollToBottomIfAtBottom(wasAtBottom) } } @@ -369,6 +380,15 @@ struct MessageListView: View { } } + private func requestScrollToBottomIfAtBottom(_ atBottom: Bool? = nil) { + let shouldFollowBottom = atBottom ?? isAtBottom + guard MessageListViewScrollPolicy.shouldRequestLiveBottomScroll(wasAtBottom: shouldFollowBottom) else { + logScrollState("scrollToBottom.skippedNotAtBottom") + return + } + requestScrollToBottom() + } + /// Returns the last consecutive assistant sequence (including streaming turn) while streaming. /// Returns an empty array when not streaming so no separate streaming rows render. private func activeResponseMessages(from messages: [ChatMessage]) -> [ChatMessage] { diff --git a/Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings b/Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings index 79f05939..398e4f0b 100644 --- a/Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings +++ b/Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings @@ -304,6 +304,7 @@ } }, "⚠︎ %@" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { diff --git a/Packages/Sources/RxCodeChatKit/Settings.swift b/Packages/Sources/RxCodeChatKit/Settings.swift deleted file mode 100644 index 30d8283c..00000000 --- a/Packages/Sources/RxCodeChatKit/Settings.swift +++ /dev/null @@ -1,8 +0,0 @@ -enum RxCodeChatKitSettings { - static let markdownRenderer: MarkdownRendererKind = .markdownUI -} - -enum MarkdownRendererKind { - case textual - case markdownUI -} diff --git a/Packages/Sources/RxCodeMarkdown/MarkdownDocumentParser.swift b/Packages/Sources/RxCodeMarkdown/MarkdownDocumentParser.swift new file mode 100644 index 00000000..b22c2f01 --- /dev/null +++ b/Packages/Sources/RxCodeMarkdown/MarkdownDocumentParser.swift @@ -0,0 +1,535 @@ +import Foundation + +struct MarkdownDocument: Equatable { + var blocks: [MarkdownBlock] +} + +enum MarkdownBlock: Equatable { + case heading(level: Int, inlines: [MarkdownInline], range: Range) + case paragraph(inlines: [MarkdownInline], range: Range) + case list(ordered: Bool, items: [MarkdownListItem], range: Range) + case quote(inlines: [MarkdownInline], range: Range) + case codeBlock(language: String?, content: String, range: Range) + case image(alt: String, source: String, range: Range) + case table(headers: [String], rows: [[String]], range: Range) + case divider(range: Range) +} + +struct MarkdownListItem: Equatable { + var number: Int? + var inlines: [MarkdownInline] + var range: Range +} + +enum MarkdownInline: Equatable { + case text(String, range: Range) + case strong(String, range: Range) + case emphasis(String, range: Range) + case code(String, range: Range) + case link(label: String, destination: String, range: Range) + case image(alt: String, source: String, range: Range) +} + +enum MarkdownDocumentParser { + static func parse(_ text: String) -> MarkdownDocument { + let lines = sourceLines(in: text) + var blocks: [MarkdownBlock] = [] + var index = 0 + + while index < lines.count { + let line = lines[index] + let trimmed = line.text.trimmingCharacters(in: .whitespaces) + + if trimmed.isEmpty { + index += 1 + continue + } + + if let fence = fenceInfo(trimmed) { + let parsed = parseCodeBlock(from: index, fence: fence, lines: lines) + blocks.append(parsed.block) + index = parsed.nextIndex + continue + } + + if let image = blockImage(trimmed, range: line.startOffset..") { + let parsed = parseQuote(from: index, lines: lines) + blocks.append(parsed.block) + index = parsed.nextIndex + continue + } + + let parsed = parseParagraph(from: index, lines: lines) + blocks.append(parsed.block) + index = parsed.nextIndex + } + + return MarkdownDocument(blocks: blocks) + } + + static func parseInlines(_ text: String, baseOffset: Int = 0) -> [MarkdownInline] { + var result: [MarkdownInline] = [] + var index = text.startIndex + + func offset(_ position: String.Index) -> Int { + baseOffset + text.distance(from: text.startIndex, to: position) + } + + func appendText(from start: String.Index, to end: String.Index) { + guard start < end else { return } + result.append(.text(String(text[start.. URL? { + let cleaned = source.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { return nil } + if let url = URL(string: cleaned), url.scheme != nil { + return url + } + if let baseURL { + return URL(string: cleaned, relativeTo: baseURL)?.absoluteURL + } + return URL(fileURLWithPath: cleaned) + } +} + +private struct MarkdownSourceLine { + var text: String + var startOffset: Int + var endOffset: Int +} + +private struct MarkdownFence { + var marker: String + var language: String? +} + +private struct ParsedInlineLink { + var label: String + var destination: String + var range: Range + var endIndex: String.Index +} + +private extension MarkdownDocumentParser { + static func sourceLines(in text: String) -> [MarkdownSourceLine] { + var lines: [MarkdownSourceLine] = [] + var offset = 0 + for component in text.components(separatedBy: "\n") { + let endOffset = offset + component.count + lines.append(MarkdownSourceLine(text: component, startOffset: offset, endOffset: endOffset)) + offset = endOffset + 1 + } + return lines + } + + static func headingBlock(_ line: MarkdownSourceLine) -> MarkdownBlock? { + let trimmedLeading = line.text.trimmingCharacters(in: .whitespaces) + var level = 0 + for character in trimmedLeading { + guard character == "#" else { break } + level += 1 + } + guard (1...6).contains(level), + trimmedLeading.count > level, + trimmedLeading[trimmedLeading.index(trimmedLeading.startIndex, offsetBy: level)] == " " else { + return nil + } + let content = String(trimmedLeading.dropFirst(level + 1)).trimmingCharacters(in: .whitespaces) + let leadingWhitespace = line.text.count - line.text.trimmingCharacters(in: .whitespaces).count + let contentOffset = line.startOffset + leadingWhitespace + level + 1 + return .heading( + level: level, + inlines: parseInlines(content, baseOffset: contentOffset), + range: line.startOffset.. (block: MarkdownBlock, nextIndex: Int) { + var index = start + var paragraphLines: [MarkdownSourceLine] = [] + + while index < lines.count { + let line = lines[index] + let trimmed = line.text.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, + fenceInfo(trimmed) == nil, + blockImage(trimmed, range: line.startOffset..") else { + break + } + paragraphLines.append(line) + index += 1 + } + + let text = paragraphLines.map(\.text).joined(separator: " ") + let startOffset = paragraphLines.first?.startOffset ?? lines[start].startOffset + let endOffset = paragraphLines.last?.endOffset ?? lines[start].endOffset + return ( + .paragraph(inlines: parseInlines(text, baseOffset: startOffset), range: startOffset.. (block: MarkdownBlock, nextIndex: Int) { + var index = start + var quoteParts: [String] = [] + let startOffset = lines[start].startOffset + var endOffset = lines[start].endOffset + + while index < lines.count { + let trimmed = lines[index].text.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix(">") else { break } + var content = String(trimmed.dropFirst()) + if content.hasPrefix(" ") { + content.removeFirst() + } + quoteParts.append(content) + endOffset = lines[index].endOffset + index += 1 + } + + let text = quoteParts.joined(separator: "\n") + return (.quote(inlines: parseInlines(text, baseOffset: startOffset), range: startOffset.. (block: MarkdownBlock, nextIndex: Int) { + let firstMarker = listMarker(lines[start].text) + let ordered = firstMarker?.number != nil + var items: [MarkdownListItem] = [] + var index = start + var endOffset = lines[start].endOffset + + while index < lines.count { + guard let marker = listMarker(lines[index].text), + (marker.number != nil) == ordered else { break } + let content = String(lines[index].text[marker.contentStart...]) + let baseOffset = lines[index].startOffset + lines[index].text.distance(from: lines[index].text.startIndex, to: marker.contentStart) + items.append(MarkdownListItem( + number: marker.number, + inlines: parseInlines(content, baseOffset: baseOffset), + range: lines[index].startOffset.. (block: MarkdownBlock, nextIndex: Int) { + var index = start + 1 + var contentLines: [String] = [] + var endOffset = lines[start].endOffset + + while index < lines.count { + let trimmed = lines[index].text.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix(fence.marker) { + endOffset = lines[index].endOffset + index += 1 + break + } + contentLines.append(lines[index].text) + endOffset = lines[index].endOffset + index += 1 + } + + return ( + .codeBlock(language: fence.language, content: contentLines.joined(separator: "\n"), range: lines[start].startOffset.. (block: MarkdownBlock, nextIndex: Int) { + var index = start + 2 + var rows: [[String]] = [] + let headers = tableCells(lines[start].text) + var endOffset = lines[start + 1].endOffset + + while index < lines.count { + let trimmed = lines[index].text.trimmingCharacters(in: .whitespaces) + guard trimmed.contains("|"), !trimmed.isEmpty else { break } + rows.append(tableCells(lines[index].text)) + endOffset = lines[index].endOffset + index += 1 + } + + return (.table(headers: headers, rows: rows, range: lines[start].startOffset..) -> MarkdownBlock? { + if let parsed = parseMarkdownImage(trimmed, range: range) { + return .image(alt: parsed.alt, source: parsed.source, range: range) + } + if let parsed = parseHTMLImage(trimmed) { + return .image(alt: parsed.alt, source: parsed.source, range: range) + } + return nil + } + + static func parseMarkdownImage(_ text: String, range: Range) -> (alt: String, source: String)? { + guard text.hasPrefix("!["), + let closeBracket = text.firstIndex(of: "]"), + closeBracket < text.index(before: text.endIndex), + text[text.index(after: closeBracket)] == "(", + text.hasSuffix(")") else { + return nil + } + let alt = String(text[text.index(text.startIndex, offsetBy: 2).. (alt: String, source: String)? { + guard text.localizedCaseInsensitiveContains(" String? { + let pattern = "\(name)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s>]+))" + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]), + let match = regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) else { + return nil + } + for index in 1.. Bool { + let characters = text.filter { !$0.isWhitespace } + guard characters.count >= 3, + let first = characters.first, + first == "-" || first == "*" || first == "_" else { + return false + } + return characters.allSatisfy { $0 == first } + } + + static func fenceInfo(_ text: String) -> MarkdownFence? { + if text.hasPrefix("```") { + return MarkdownFence(marker: "```", language: fenceLanguage(text.dropFirst(3))) + } + if text.hasPrefix("~~~") { + return MarkdownFence(marker: "~~~", language: fenceLanguage(text.dropFirst(3))) + } + return nil + } + + static func fenceLanguage(_ text: S) -> String? { + let language = String(text).trimmingCharacters(in: .whitespacesAndNewlines) + return language.isEmpty ? nil : language + } + + static func listMarker(_ text: String) -> (number: Int?, contentStart: String.Index)? { + let trimmedStart = text.firstIndex { !$0.isWhitespace } ?? text.startIndex + guard trimmedStart < text.endIndex else { return nil } + + let marker = text[trimmedStart] + if marker == "-" || marker == "*" || marker == "+", + text.index(after: trimmedStart) < text.endIndex, + text[text.index(after: trimmedStart)] == " " { + return (nil, text.index(trimmedStart, offsetBy: 2)) + } + + var digitEnd = trimmedStart + while digitEnd < text.endIndex, text[digitEnd].isNumber { + digitEnd = text.index(after: digitEnd) + } + guard digitEnd > trimmedStart, + digitEnd < text.endIndex, + text[digitEnd] == ".", + text.index(after: digitEnd) < text.endIndex, + text[text.index(after: digitEnd)] == " ", + let number = Int(text[trimmedStart.. Bool { + guard index + 1 < lines.count, + lines[index].text.contains("|") else { + return false + } + return isTableSeparator(lines[index + 1].text) + } + + static func isTableSeparator(_ text: String) -> Bool { + let cells = tableCells(text) + guard !cells.isEmpty else { return false } + return cells.allSatisfy { cell in + let compact = cell.filter { !$0.isWhitespace } + return compact.count >= 3 + && compact.allSatisfy { $0 == "-" || $0 == ":" } + && compact.contains("-") + } + } + + static func tableCells(_ text: String) -> [String] { + var trimmed = text.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("|") { trimmed.removeFirst() } + if trimmed.hasSuffix("|") { trimmed.removeLast() } + return trimmed.split(separator: "|", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + } + + static func hasPrefix(_ prefix: String, in text: String, at index: String.Index) -> Bool { + text[index...].hasPrefix(prefix) + } + + static func rangeOf(_ needle: String, in text: String, after index: String.Index) -> String.Index? { + text[index...].range(of: needle)?.lowerBound + } + + static func parseLinkLikeInline(_ text: String, at index: String.Index, baseOffset: Int, isImage: Bool) -> ParsedInlineLink? { + let labelStart = isImage ? text.index(index, offsetBy: 2) : text.index(after: index) + guard labelStart < text.endIndex, + let closeBracket = text[labelStart...].firstIndex(of: "]"), + closeBracket < text.index(before: text.endIndex), + text[text.index(after: closeBracket)] == "(", + let closeParen = text[text.index(closeBracket, offsetBy: 2)...].firstIndex(of: ")") else { + return nil + } + let destinationStart = text.index(closeBracket, offsetBy: 2) + let end = text.index(after: closeParen) + let startOffset = baseOffset + text.distance(from: text.startIndex, to: index) + let endOffset = baseOffset + text.distance(from: text.startIndex, to: end) + return ParsedInlineLink( + label: String(text[labelStart.. ParsedInlineLink? { + guard text[index...].hasPrefix("http://") || text[index...].hasPrefix("https://") else { + return nil + } + var end = index + while end < text.endIndex { + let character = text[end] + if character.isWhitespace || "])<`".contains(character) { + break + } + end = text.index(after: end) + } + while end > index { + let previous = text.index(before: end) + if ".,;:!?".contains(text[previous]) { + end = previous + } else { + break + } + } + guard end > index else { return nil } + let startOffset = baseOffset + text.distance(from: text.startIndex, to: index) + let endOffset = baseOffset + text.distance(from: text.startIndex, to: end) + let destination = String(text[index.. CGFloat { + switch level { + case 1: bodyFontSize * 1.33 + case 2: bodyFontSize * 1.2 + case 3: bodyFontSize * 1.07 + default: bodyFontSize + } + } +} + +public final class MarkdownImageCache: @unchecked Sendable { + public static let shared = MarkdownImageCache() + + #if canImport(AppKit) + fileprivate let cache = NSCache() + #elseif canImport(UIKit) + fileprivate let cache = NSCache() + #endif + + public init() {} +} + +public struct MarkdownView: View { + public typealias LinkHandler = (URL) -> OpenURLAction.Result + + private static let newTextFadeDuration: Duration = .milliseconds(650) + + private let text: String + private let showsTrailingCursor: Bool + private let isCursorVisible: Bool + private let baseURL: URL? + private let style: MarkdownStyle + private let fadeNewText: Bool + private let imageCache: MarkdownImageCache + private let onOpenLink: LinkHandler? + + @State private var observedText: String + @State private var fadeSegments: [MarkdownFadeSegment] = [] + @State private var fadeTasks: [UUID: Task] = [:] + + public init( + text: String, + showsTrailingCursor: Bool = false, + isCursorVisible: Bool = true, + baseURL: URL? = nil, + style: MarkdownStyle = MarkdownStyle(), + fadeNewText: Bool = false, + imageCache: MarkdownImageCache = .shared, + onOpenLink: LinkHandler? = nil + ) { + self.text = text + self.showsTrailingCursor = showsTrailingCursor + self.isCursorVisible = isCursorVisible + self.baseURL = baseURL + self.style = style + self.fadeNewText = fadeNewText + self.imageCache = imageCache + self.onOpenLink = onOpenLink + _observedText = State(initialValue: text) + } + + public var body: some View { + content + } + + @ViewBuilder + private var content: some View { + let view = MarkdownDocumentView( + document: MarkdownDocumentParser.parse(renderedText), + baseURL: baseURL, + style: style, + imageCache: imageCache, + fadeSegments: fadeSegments + ) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .onChange(of: text) { _, newText in + updateFadeState(for: newText) + } + .onDisappear { + cancelFadeTasks() + } + + if let onOpenLink { + view.environment(\.openURL, OpenURLAction { url in + onOpenLink(url) + }) + } else { + view + } + } + + private var renderedText: String { + if showsTrailingCursor && isCursorVisible { + text + "\u{2009}\u{25CF}" + } else { + text + } + } + + private func updateFadeState(for newText: String) { + let oldText = observedText + observedText = newText + + guard fadeNewText, + let insertedRange = MarkdownTextDiffer.insertedRange(from: oldText, to: newText) else { + cancelFadeTasks() + fadeSegments = [] + return + } + + if insertedRange.lowerBound != oldText.count { + cancelFadeTasks() + fadeSegments = [] + } + + let segment = MarkdownFadeSegment(range: insertedRange, opacity: 0) + fadeSegments.append(segment) + fadeTasks[segment.id] = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(16)) + guard !Task.isCancelled else { return } + withAnimation(.easeOut(duration: Self.newTextFadeDuration.timeInterval)) { + setFadeSegmentOpacity(id: segment.id, opacity: 1) + } + try? await Task.sleep(for: Self.newTextFadeDuration) + guard !Task.isCancelled else { return } + fadeSegments.removeAll { $0.id == segment.id } + fadeTasks[segment.id] = nil + } + } + + private func setFadeSegmentOpacity(id: UUID, opacity: Double) { + guard let index = fadeSegments.firstIndex(where: { $0.id == id }) else { return } + fadeSegments[index].opacity = opacity + } + + private func cancelFadeTasks() { + fadeTasks.values.forEach { $0.cancel() } + fadeTasks = [:] + } +} + +private struct MarkdownDocumentView: View { + let document: MarkdownDocument + let baseURL: URL? + let style: MarkdownStyle + let imageCache: MarkdownImageCache + let fadeSegments: [MarkdownFadeSegment] + + var body: some View { + VStack(alignment: .leading, spacing: style.blockSpacing) { + ForEach(Array(document.blocks.enumerated()), id: \.offset) { _, block in + blockView(block) + } + } + } + + @ViewBuilder + private func blockView(_ block: MarkdownBlock) -> some View { + switch block { + case .heading(let level, let inlines, _): + InlineMarkdownText( + inlines: inlines, + style: style, + baseURL: baseURL, + fontSize: style.headingFontSize(level: level), + weight: level <= 2 ? .bold : .semibold, + fadeSegments: fadeSegments + ) + .padding(.top, level <= 2 ? 6 : 3) + case .paragraph(let inlines, _): + InlineMarkdownText( + inlines: inlines, + style: style, + baseURL: baseURL, + fontSize: style.bodyFontSize, + weight: .regular, + fadeSegments: fadeSegments + ) + case .list(let ordered, let items, _): + VStack(alignment: .leading, spacing: 5) { + ForEach(Array(items.enumerated()), id: \.offset) { itemIndex, item in + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(ordered ? "\(item.number ?? itemIndex + 1)." : "-") + .font(.system(size: style.bodyFontSize, weight: .semibold)) + .foregroundStyle(style.accentColor.opacity(opacity(for: item.range))) + .frame(minWidth: ordered ? 22 : 12, alignment: .trailing) + InlineMarkdownText( + inlines: item.inlines, + style: style, + baseURL: baseURL, + fontSize: style.bodyFontSize, + weight: .regular, + fadeSegments: fadeSegments + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + case .quote(let inlines, _): + HStack(alignment: .top, spacing: 10) { + Rectangle() + .fill(style.accentColor) + .frame(width: 3) + InlineMarkdownText( + inlines: inlines, + style: style, + baseURL: baseURL, + fontSize: style.bodyFontSize, + weight: .regular, + overrideColor: style.secondaryColor, + fadeSegments: fadeSegments + ) + } + .padding(.vertical, 2) + case .codeBlock(let language, let content, let range): + MarkdownCodeBlockView(language: language, content: content, style: style) + .opacity(opacity(for: range)) + case .image(let alt, let source, let range): + CachedMarkdownImage( + alt: alt, + source: source, + baseURL: baseURL, + style: style, + cache: imageCache + ) + .opacity(opacity(for: range)) + case .table(let headers, let rows, let range): + MarkdownTableView(headers: headers, rows: rows, style: style) + .opacity(opacity(for: range)) + case .divider(let range): + Rectangle() + .fill(style.borderColor) + .frame(height: 1) + .opacity(opacity(for: range)) + } + } + + private func opacity(for range: Range) -> Double { + fadeSegments + .filter { $0.range.overlaps(range) } + .map(\.opacity) + .min() ?? 1 + } +} + +private struct InlineMarkdownText: View { + let inlines: [MarkdownInline] + let style: MarkdownStyle + let baseURL: URL? + let fontSize: CGFloat + let weight: Font.Weight + var overrideColor: Color? + let fadeSegments: [MarkdownFadeSegment] + + var body: some View { + Text(attributedString) + .font(.system(size: fontSize, weight: weight)) + .lineSpacing(style.lineSpacing) + .fixedSize(horizontal: false, vertical: true) + } + + private var attributedString: AttributedString { + var output = AttributedString() + for inline in inlines { + output += attributed(inline) + } + return output + } + + private func attributed(_ inline: MarkdownInline) -> AttributedString { + switch inline { + case .text(let value, let range): + return segment(value, range: range, color: overrideColor ?? style.bodyColor, weight: weight) + case .strong(let value, let range): + return segment( + value, + range: range.removingDelimiters(prefix: 2, suffix: 2), + color: overrideColor ?? style.bodyColor, + weight: .semibold + ) + case .emphasis(let value, let range): + var attr = segment( + value, + range: range.removingDelimiters(prefix: 1, suffix: 1), + color: overrideColor ?? style.bodyColor, + weight: weight + ) + attr.inlinePresentationIntent = .emphasized + return attr + case .code(let value, let range): + let visibleRange = range.removingDelimiters(prefix: 1, suffix: 1) + var attr = segment(value, range: visibleRange, color: style.codeTextColor, weight: .regular, design: .monospaced) + attr.backgroundColor = style.codeBackground.opacity(opacity(for: visibleRange)) + return attr + case .link(let label, let destination, let range): + let visibleRange = linkLabelRange(label: label, destination: destination, range: range) + var attr = segment(label, range: visibleRange, color: style.accentColor, weight: weight) + attr.link = MarkdownDocumentParser.resolvedURL(for: destination, baseURL: baseURL) + attr.underlineStyle = .single + return attr + case .image(let alt, _, let range): + let label = alt.isEmpty ? "[image]" : alt + let visibleRange = alt.isEmpty ? range.clampedLength(to: label.count) : range.removingDelimiters(prefix: 2, suffix: 0).clampedLength(to: alt.count) + return segment(label, range: visibleRange, color: style.secondaryColor, weight: weight) + } + } + + private func segment( + _ value: String, + range: Range, + color: Color, + weight: Font.Weight, + design: Font.Design = .default + ) -> AttributedString { + var output = AttributedString() + for part in MarkdownFadeSplitter.split(value, range: range, fadeSegments: fadeSegments) { + var attr = AttributedString(part.value) + attr.font = .system(size: fontSize, weight: weight, design: design) + attr.foregroundColor = color.opacity(part.opacity) + output += attr + } + return output + } + + private func opacity(for range: Range) -> Double { + fadeSegments + .filter { $0.range.overlaps(range) } + .map(\.opacity) + .min() ?? 1 + } + + private func linkLabelRange(label: String, destination: String, range: Range) -> Range { + if label == destination, range.count == label.count { + return range + } + return range.removingDelimiters(prefix: 1, suffix: 0).clampedLength(to: label.count) + } +} + +struct MarkdownFadeSegment: Equatable, Identifiable { + let id: UUID + var range: Range + var opacity: Double + + init(id: UUID = UUID(), range: Range, opacity: Double) { + self.id = id + self.range = range + self.opacity = opacity + } +} + +struct MarkdownFadePart: Equatable { + var value: String + var shouldFade: Bool + var opacity: Double + + init(value: String, shouldFade: Bool, opacity: Double? = nil) { + self.value = value + self.shouldFade = shouldFade + self.opacity = opacity ?? (shouldFade ? 0 : 1) + } +} + +enum MarkdownFadeSplitter { + static func split( + _ value: String, + range: Range, + fadeSegments: [MarkdownFadeSegment] + ) -> [MarkdownFadePart] { + let intersectingSegments = fadeSegments.filter { $0.range.overlaps(range) } + guard !intersectingSegments.isEmpty else { + return [MarkdownFadePart(value: value, shouldFade: false)] + } + + let boundaries = Set( + [range.lowerBound, range.upperBound] + + intersectingSegments.flatMap { segment in + [ + max(range.lowerBound, segment.range.lowerBound), + min(range.upperBound, segment.range.upperBound), + ] + } + ) + let sortedBoundaries = boundaries.sorted() + + return zip(sortedBoundaries, sortedBoundaries.dropFirst()).compactMap { lowerBound, upperBound in + guard lowerBound < upperBound else { return nil } + let partRange = lowerBound.. Range? { + guard oldText != newText, newText.count > oldText.count else { + return nil + } + + let prefixLength = commonPrefixLength(oldText, newText) + let suffixLength = commonSuffixLength( + oldText, + newText, + excludingPrefixLength: prefixLength + ) + let insertedEnd = newText.count - suffixLength + guard prefixLength < insertedEnd else { + return nil + } + + return prefixLength.. Int { + var length = 0 + var lhsIndex = lhs.startIndex + var rhsIndex = rhs.startIndex + + while lhsIndex < lhs.endIndex, + rhsIndex < rhs.endIndex, + lhs[lhsIndex] == rhs[rhsIndex] { + length += 1 + lhs.formIndex(after: &lhsIndex) + rhs.formIndex(after: &rhsIndex) + } + + return length + } + + private static func commonSuffixLength( + _ lhs: String, + _ rhs: String, + excludingPrefixLength prefixLength: Int + ) -> Int { + var length = 0 + var lhsIndex = lhs.endIndex + var rhsIndex = rhs.endIndex + let maximumLength = min(lhs.count, rhs.count) - prefixLength + + while length < maximumLength { + let previousLHSIndex = lhs.index(before: lhsIndex) + let previousRHSIndex = rhs.index(before: rhsIndex) + guard lhs[previousLHSIndex] == rhs[previousRHSIndex] else { break } + length += 1 + lhsIndex = previousLHSIndex + rhsIndex = previousRHSIndex + } + + return length + } +} + +private extension Range where Bound == Int { + var count: Int { + upperBound - lowerBound + } + + func removingDelimiters(prefix: Int, suffix: Int) -> Range { + let lower = Swift.min(upperBound, lowerBound + prefix) + let upper = Swift.max(lower, upperBound - suffix) + return lower.. Range { + lowerBound.. Block quotes stay native SwiftUI. + + - Cached network image support + - Custom component style + - Fade in appended text + + | Component | Status | + | --- | --- | + | Parser | Native | + | Images | Cached | + + ```swift + MarkdownView(text: markdown, fadeNewText: true) + ``` + """, + fadeNewText: true + ) + .padding(24) + } + .frame(width: 520, height: 620) +} diff --git a/Packages/Tests/RxCodeChatKitTests/MessageListViewScrollPolicyTests.swift b/Packages/Tests/RxCodeChatKitTests/MessageListViewScrollPolicyTests.swift new file mode 100644 index 00000000..d147db01 --- /dev/null +++ b/Packages/Tests/RxCodeChatKitTests/MessageListViewScrollPolicyTests.swift @@ -0,0 +1,11 @@ +import Testing +@testable import RxCodeChatKit + +@Suite("MessageListView scroll policy") +struct MessageListViewScrollPolicyTests { + @Test("Live assistant updates follow bottom only when already at bottom") + func liveAssistantUpdatesFollowBottomOnlyWhenAnchored() { + #expect(MessageListViewScrollPolicy.shouldRequestLiveBottomScroll(wasAtBottom: true)) + #expect(!MessageListViewScrollPolicy.shouldRequestLiveBottomScroll(wasAtBottom: false)) + } +} diff --git a/Packages/Tests/RxCodeCoreTests/LocalFileLinkTests.swift b/Packages/Tests/RxCodeCoreTests/LocalFileLinkTests.swift new file mode 100644 index 00000000..89ffa2cc --- /dev/null +++ b/Packages/Tests/RxCodeCoreTests/LocalFileLinkTests.swift @@ -0,0 +1,16 @@ +import Foundation +import Testing +@testable import RxCodeCore + +struct LocalFileLinkTests { + @Test("file URLs with spaces and line suffix parse as local file links") + func parsesFileURLWithSpacesAndLineSuffix() { + let url = URL(fileURLWithPath: "/Users/example/Application Support/RxCode/file.swift:12") + let link = LocalFileLink.parse(url) + + #expect(link == LocalFileLink( + path: "/Users/example/Application Support/RxCode/file.swift", + line: 12 + )) + } +} diff --git a/Packages/Tests/RxCodeMarkdownTests/MarkdownDocumentParserTests.swift b/Packages/Tests/RxCodeMarkdownTests/MarkdownDocumentParserTests.swift new file mode 100644 index 00000000..785e6b15 --- /dev/null +++ b/Packages/Tests/RxCodeMarkdownTests/MarkdownDocumentParserTests.swift @@ -0,0 +1,193 @@ +import Foundation +import Testing +@testable import RxCodeMarkdown + +struct MarkdownDocumentParserTests { + @Test("Parser recognizes common markdown block components") + func parsesCommonBlocks() { + let document = MarkdownDocumentParser.parse(""" + # Title + + Paragraph with **bold** and [link](https://example.com). + + > Quoted text + + - One + - Two + + | Name | Value | + | --- | --- | + | Files | 10 | + + ```swift + let value = 1 + ``` + + ![Alt](https://example.com/image.png) + + --- + """) + + #expect(document.blocks.count == 8) + + guard case .heading(let level, let headingInlines, _) = document.blocks[0] else { + Issue.record("Expected heading block") + return + } + #expect(level == 1) + #expect(headingInlines == [.text("Title", range: 2..<7)]) + + guard case .paragraph(let paragraphInlines, _) = document.blocks[1] else { + Issue.record("Expected paragraph block") + return + } + #expect(paragraphInlines.contains(.strong("bold", range: 24..<32))) + #expect(paragraphInlines.contains(.link(label: "link", destination: "https://example.com", range: 37..<64))) + + guard case .list(let ordered, let items, _) = document.blocks[3] else { + Issue.record("Expected list block") + return + } + #expect(!ordered) + #expect(items.count == 2) + + guard case .table(let headers, let rows, _) = document.blocks[4] else { + Issue.record("Expected table block") + return + } + #expect(headers == ["Name", "Value"]) + #expect(rows == [["Files", "10"]]) + + guard case .codeBlock(let language, let content, _) = document.blocks[5] else { + Issue.record("Expected code block") + return + } + #expect(language == "swift") + #expect(content == "let value = 1") + } + + @Test("Inline parser recognizes links, bare URLs, code, and images") + func parsesInlineComponents() { + let inlines = MarkdownDocumentParser.parseInlines( + "Open [docs](docs/index.md), `code`, https://example.com, and ![alt](image.png)", + baseOffset: 10 + ) + + #expect(inlines.contains(.link(label: "docs", destination: "docs/index.md", range: 15..<36))) + #expect(inlines.contains(.code("code", range: 38..<44))) + #expect(inlines.contains(.link(label: "https://example.com", destination: "https://example.com", range: 46..<65))) + #expect(inlines.contains(.image(alt: "alt", source: "image.png", range: 71..<88))) + } + + @Test("Relative image URLs resolve against the supplied base URL") + func resolvesRelativeURLs() { + let baseURL = URL(fileURLWithPath: "/tmp/docs/guide.md") + let resolved = MarkdownDocumentParser.resolvedURL(for: "assets/image.png", baseURL: baseURL) + + #expect(resolved == URL(fileURLWithPath: "/tmp/docs/assets/image.png")) + } + + @Test("Absolute URLs are preserved") + func preservesAbsoluteURLs() { + let resolved = MarkdownDocumentParser.resolvedURL(for: "https://example.com/image.png", baseURL: nil) + + #expect(resolved == URL(string: "https://example.com/image.png")) + } + + @Test("Absolute local paths resolve as file URLs") + func resolvesAbsoluteLocalPathsAsFileURLs() { + let resolved = MarkdownDocumentParser.resolvedURL( + for: "/Users/example/Application Support/RxCode/file.swift:12", + baseURL: nil + ) + + #expect(resolved == URL(fileURLWithPath: "/Users/example/Application Support/RxCode/file.swift:12")) + #expect(resolved?.isFileURL == true) + } + + @Test("Fade splitter keeps existing prefix opaque when text is appended inside one inline run") + func splitsInlineRunAtFadeBoundary() { + let parts = MarkdownFadeSplitter.split( + "Existing new", + range: 0..<12, + fadeSegments: [ + MarkdownFadeSegment(range: 8..<12, opacity: 0.4), + ] + ) + + #expect(parts == [ + MarkdownFadePart(value: "Existing", shouldFade: false), + MarkdownFadePart(value: " new", shouldFade: true, opacity: 0.4), + ]) + } + + @Test("Fade splitter does not fade runs entirely before the boundary") + func keepsExistingRunOpaque() { + let parts = MarkdownFadeSplitter.split( + "Existing", + range: 0..<8, + fadeSegments: [ + MarkdownFadeSegment(range: 8..<12, opacity: 0.4), + ] + ) + + #expect(parts == [ + MarkdownFadePart(value: "Existing", shouldFade: false), + ]) + } + + @Test("Fade splitter handles multiple active fade ranges") + func splitsMultipleFadeRanges() { + let parts = MarkdownFadeSplitter.split( + "Existing new more", + range: 0..<17, + fadeSegments: [ + MarkdownFadeSegment(range: 9..<12, opacity: 0.5), + MarkdownFadeSegment(range: 13..<17, opacity: 0.2), + ] + ) + + #expect(parts == [ + MarkdownFadePart(value: "Existing ", shouldFade: false), + MarkdownFadePart(value: "new", shouldFade: true, opacity: 0.5), + MarkdownFadePart(value: " ", shouldFade: false), + MarkdownFadePart(value: "more", shouldFade: true, opacity: 0.2), + ]) + } + + @Test("Fade splitter clamps source ranges longer than visible text") + func clampsMismatchedSourceAndVisibleRanges() { + let parts = MarkdownFadeSplitter.split( + "link", + range: 0..<19, + fadeSegments: [ + MarkdownFadeSegment(range: 1..<5, opacity: 0.3), + ] + ) + + #expect(parts == [ + MarkdownFadePart(value: "l", shouldFade: false), + MarkdownFadePart(value: "ink", shouldFade: true, opacity: 0.3), + ]) + } + + @Test("Text differ returns only the appended range") + func detectsAppendedRange() { + let range = MarkdownTextDiffer.insertedRange( + from: "Existing", + to: "Existing new" + ) + + #expect(range == 8..<12) + } + + @Test("Text differ preserves unchanged suffix around an insertion") + func detectsInsertedRangeBeforeSuffix() { + let range = MarkdownTextDiffer.insertedRange( + from: "Hello world", + to: "Hello new world" + ) + + #expect(range == 6..<10) + } +} diff --git a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 42cfc701..406d1a26 100644 --- a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "d763c7a5f7c6c058157fe0ff994249a039d6a9b2c97db6849d68d74c036c4de7", + "originHash" : "556f462e7c0c84051129b525014f8f88c505f51ef56a64fbb8e65638429bb39c", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -109,15 +109,6 @@ "version" : "2.30910.0" } }, - { - "identity" : "networkimage", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/NetworkImage", - "state" : { - "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", - "version" : "6.0.1" - } - }, { "identity" : "promises", "kind" : "remoteSourceControl", @@ -154,15 +145,6 @@ "version" : "1.7.1" } }, - { - "identity" : "swift-cmark", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-cmark", - "state" : { - "revision" : "924936d0427cb25a61169739a7660230bffa6ea6", - "version" : "0.8.0" - } - }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -172,15 +154,6 @@ "version" : "1.5.1" } }, - { - "identity" : "swift-concurrency-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras", - "state" : { - "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", - "version" : "1.3.2" - } - }, { "identity" : "swift-json-schema", "kind" : "remoteSourceControl", @@ -208,15 +181,6 @@ "version" : "1.13.0" } }, - { - "identity" : "swift-markdown-ui", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/swift-markdown-ui", - "state" : { - "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", - "version" : "2.4.1" - } - }, { "identity" : "swift-protobuf", "kind" : "remoteSourceControl", @@ -235,24 +199,6 @@ "version" : "1.13.0" } }, - { - "identity" : "swiftui-math", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/swiftui-math", - "state" : { - "revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71", - "version" : "0.1.0" - } - }, - { - "identity" : "textual", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/textual", - "state" : { - "revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38", - "version" : "0.3.1" - } - }, { "identity" : "viewinspector", "kind" : "remoteSourceControl", diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index e20ca436..466fe0fb 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -1878,6 +1878,7 @@ } }, "API_KEY" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -7304,6 +7305,9 @@ } } } + }, + "Key" : { + }, "KEY" : { "localizations" : { diff --git a/RxCode/Views/Hooks/BashEnvironmentEditor.swift b/RxCode/Views/Hooks/BashEnvironmentEditor.swift index 77d6e271..25cfc1f5 100644 --- a/RxCode/Views/Hooks/BashEnvironmentEditor.swift +++ b/RxCode/Views/Hooks/BashEnvironmentEditor.swift @@ -137,7 +137,7 @@ struct BashEnvironmentEditor: View { } else { ForEach(preset.wrappedValue.manualVars.indices, id: \.self) { i in HStack(spacing: 8) { - TextField("API_KEY", text: Binding( + TextField("Key", text: Binding( get: { preset.wrappedValue.manualVars[i].key }, set: { preset.wrappedValue.manualVars[i].key = $0 } )) diff --git a/RxCode/Views/RunProfile/RunProfileDetailForm+Environments.swift b/RxCode/Views/RunProfile/RunProfileDetailForm+Environments.swift index 3ca5a5ef..a6d3f896 100644 --- a/RxCode/Views/RunProfile/RunProfileDetailForm+Environments.swift +++ b/RxCode/Views/RunProfile/RunProfileDetailForm+Environments.swift @@ -166,7 +166,7 @@ extension RunProfileDetailForm { // Variable rows ForEach(preset.wrappedValue.manualVars.indices, id: \.self) { i in HStack(spacing: 8) { - TextField("API_KEY", text: Binding( + TextField("Key", text: Binding( get: { preset.wrappedValue.manualVars[i].key }, set: { preset.wrappedValue.manualVars[i].key = $0 } )) diff --git a/RxCodeTests/PlanCardViewTests.swift b/RxCodeTests/PlanCardViewTests.swift index 7b96c309..d4a38457 100644 --- a/RxCodeTests/PlanCardViewTests.swift +++ b/RxCodeTests/PlanCardViewTests.swift @@ -236,13 +236,10 @@ final class PlanCardViewTests: XCTestCase { // MARK: - Helpers - // PlanSheetView's body renders the plan markdown through MarkdownUI, whose - // view tree crashes ViewInspector's `findAll(ViewType.Text.self)` traversal - // with "Index out of range". The sheet tests below only assert on the sheet - // chrome (decision buttons, footer, feedback composer) — never the rendered - // body — so they build the plan with empty markdown. An empty body renders - // the plain `Text` fallback in `PlanSheetView.planBody`, keeping the whole - // inspected tree traversable. + // The sheet tests below only assert on the sheet chrome (decision buttons, + // footer, feedback composer) — never the rendered markdown body — so they + // build the plan with empty markdown. An empty body renders the plain `Text` + // fallback in `PlanSheetView.planBody`, keeping the inspected tree small. private let planMd = "" private func makeChip( diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index b0915c92..5414abc3 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -34,7 +34,7 @@ Android), a Go relay server for mobile sync, and the public website. - Swift tools version: 6.2 - Main app bundle ID: `com.rxlab.RxCode` - App-level dependencies: SwiftTerm, Sparkle -- Package dependencies: ViewInspector, Textual, MarkdownUI +- Package dependencies: ViewInspector - `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` and `SWIFT_APPROACHABLE_CONCURRENCY = YES` are enabled for app/mobile targets. - App Sandbox is disabled for the main macOS app because RxCode integrates with diff --git a/website/README.md b/website/README.md index 2ddb274a..1836b88e 100644 --- a/website/README.md +++ b/website/README.md @@ -23,6 +23,15 @@ You can start editing the page by modifying `app/page.tsx`. The page auto-update Set `NEXT_PUBLIC_GOOGLE_ANALYTICS_ID` to enable Google Analytics page-view tracking and CTA click events. +Set `NEXT_PUBLIC_APP_STORE_URL` to show the public App Store download badge in +the mobile companion section. + +Set `NEXT_PUBLIC_IOS_EXTERNAL_TESTFLIGHT_URL` to show a TestFlight download +button for the iOS external test. + +Set `NEXT_PUBLIC_GOOGLE_PLAY_EXTERNAL_TEST_URL` to show a Google Play download +button for the Android external test. + This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. ## Learn More diff --git a/website/app/analytics.tsx b/website/app/analytics.tsx index db271f60..a164becf 100644 --- a/website/app/analytics.tsx +++ b/website/app/analytics.tsx @@ -6,7 +6,9 @@ export const isGoogleAnalyticsEnabled = Boolean(GOOGLE_ANALYTICS_ID); export type AnalyticsEventName = | "download_button_click" - | "app_store_button_click"; + | "app_store_button_click" + | "testflight_button_click" + | "google_play_button_click"; type AnalyticsEventParams = Record; @@ -54,4 +56,3 @@ export function trackAnalyticsEvent( window.gtag?.("event", eventName, params); } - diff --git a/website/app/globals.css b/website/app/globals.css index 6d03c9ef..74b563fa 100644 --- a/website/app/globals.css +++ b/website/app/globals.css @@ -241,6 +241,54 @@ body { font-weight: 600; } +.legal-content { + color: var(--color-on-surface); + line-height: 1.7; + font-size: 1rem; +} + +.legal-content > :first-child { + margin-top: 0; +} + +.legal-content h2 { + font-family: var(--font-display); + font-size: 1.45rem; + font-weight: 600; + color: var(--color-on-surface); + margin-top: 1.8em; + margin-bottom: 0.65em; + padding-bottom: 0.35em; + border-bottom: 1px solid var(--color-surface-variant); +} + +.legal-content p { + margin: 0.9em 0; + color: var(--color-on-surface-variant); +} + +.legal-content a { + color: var(--color-primary); + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--color-primary) 40%, transparent); + text-underline-offset: 3px; +} + +.legal-content a:hover { + text-decoration-color: var(--color-primary); +} + +.legal-content ul, +.legal-content ol { + margin: 0.75em 0; + padding-left: 1.4em; + color: var(--color-on-surface-variant); +} + +.legal-content li { + margin: 0.35em 0; +} + /* --- Agent collaboration diagram --- */ @keyframes agent-stream { to { diff --git a/website/app/legal-page.tsx b/website/app/legal-page.tsx new file mode 100644 index 00000000..4db1270f --- /dev/null +++ b/website/app/legal-page.tsx @@ -0,0 +1,147 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; + +const GITHUB_REPO_URL = "https://github.com/rxtech-lab/rxcode"; + +type LegalPageProps = { + eyebrow: string; + title: string; + description: string; + children: ReactNode; +}; + +export function LegalPage({ + eyebrow, + title, + description, + children, +}: LegalPageProps) { + return ( + <> + +
+
+
+
+ + + {eyebrow} + +

+ {title} +

+

+ {description} +

+
+
+
+
+ {children} +
+
+
+