diff --git a/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c5a9bd53..c4831161 100644 --- a/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -129,10 +129,10 @@ { "identity" : "swift-markdown-ui", "kind" : "remoteSourceControl", - "location" : "https://github.com/intitni/swift-markdown-ui", + "location" : "https://github.com/gonzalezreal/swift-markdown-ui", "state" : { - "branch" : "main", - "revision" : "95897491aaa3e32903fa17376d99e9f4067d3f23" + "revision" : "12b351a75201a8124c2f2e1f9fc6ef5cd812c0b9", + "version" : "2.1.0" } } ], diff --git a/Copilot for Xcode/DebugView.swift b/Copilot for Xcode/DebugView.swift index a9dfa38d..af29d711 100644 --- a/Copilot for Xcode/DebugView.swift +++ b/Copilot for Xcode/DebugView.swift @@ -9,6 +9,7 @@ final class DebugSettings: ObservableObject { var preCacheOnFileOpen: Bool @AppStorage(\.runNodeWithInteractiveLoggedInShell) var runNodeWithInteractiveLoggedInShell: Bool + @AppStorage(\.useCustomScrollViewWorkaround) var useCustomScrollViewWorkaround init() {} } @@ -30,6 +31,10 @@ struct DebugSettingsView: View { Text("Run node with interactive logged-in bash") } .toggleStyle(.switch) + Toggle(isOn: $settings.useCustomScrollViewWorkaround) { + Text("Use custom scroll view workaround for smooth scrolling") + } + .toggleStyle(.switch) } }.buttonStyle(.copilot) } diff --git a/Core/Package.swift b/Core/Package.swift index 6e5024c8..1a49421c 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -37,7 +37,7 @@ let package = Package( .package(url: "https://github.com/raspu/Highlightr", from: "2.1.0"), .package(url: "https://github.com/JohnSundell/Splash", branch: "master"), .package(url: "https://github.com/nmdias/FeedKit", from: "9.1.2"), - .package(url: "https://github.com/intitni/swift-markdown-ui", branch: "main"), + .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.1.0"), .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.0.0"), ], targets: [ diff --git a/Core/Sources/Preferences/Keys.swift b/Core/Sources/Preferences/Keys.swift index adc1ed1d..7421a7c1 100644 --- a/Core/Sources/Preferences/Keys.swift +++ b/Core/Sources/Preferences/Keys.swift @@ -174,6 +174,7 @@ public struct UserDefaultPreferenceKeys { public var preCacheOnFileOpen: FeatureFlags.PreCacheOnFileOpen { .init() } public var runNodeWithInteractiveLoggedInShell: FeatureFlags .RunNodeWithInteractiveLoggedInShell { .init() } + public var useCustomScrollViewWorkaround: FeatureFlags.UseCustomScrollViewWorkaround { .init() } } public enum FeatureFlags { @@ -191,4 +192,9 @@ public enum FeatureFlags { public let defaultValue = true public let key = "FeatureFlag-RunNodeWithInteractiveLoggedInShell" } + + public struct UseCustomScrollViewWorkaround: UserDefaultPreferenceKey { + public let defaultValue = true + public let key = "FeatureFlag-UseCustomScrollViewWorkaround" + } } diff --git a/Core/Sources/SuggestionWidget/CopyButton.swift b/Core/Sources/SuggestionWidget/CopyButton.swift new file mode 100644 index 00000000..fced0be4 --- /dev/null +++ b/Core/Sources/SuggestionWidget/CopyButton.swift @@ -0,0 +1,34 @@ +import AppKit +import SwiftUI + +struct CopyButton: View { + var copy: () -> Void + @State var isCopied = false + var body: some View { + Button(action: { + withAnimation(.linear(duration: 0.1)) { + isCopied = true + } + copy() + Task { + try await Task.sleep(nanoseconds: 1_000_000_000) + withAnimation(.linear(duration: 0.1)) { + isCopied = false + } + } + }) { + Image(systemName: isCopied ? "checkmark.circle" : "doc.on.doc") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + .frame(width: 20, height: 20, alignment: .center) + .foregroundColor(.secondary) + .background( + .regularMaterial, + in: RoundedRectangle(cornerRadius: 4, style: .circular) + ) + .padding(4) + } + .buttonStyle(.borderless) + } +} diff --git a/Core/Sources/SuggestionWidget/CustomScrollView/CustomScrollView.swift b/Core/Sources/SuggestionWidget/CustomScrollView/CustomScrollView.swift new file mode 100644 index 00000000..503eab7b --- /dev/null +++ b/Core/Sources/SuggestionWidget/CustomScrollView/CustomScrollView.swift @@ -0,0 +1,60 @@ +import AppKit +import SwiftUI +import Combine + +/// Used to workaround a SwiftUI bug. https://github.com/intitni/CopilotForXcode/issues/122 +struct CustomScrollView: View { + @ViewBuilder var content: () -> Content + @State var height: Double = 100 + @AppStorage(\.useCustomScrollViewWorkaround) var useNSScrollViewWrapper + + var body: some View { + if useNSScrollViewWrapper { + List { + content() + .listRowInsets(EdgeInsets(top: 0, leading: -8, bottom: 0, trailing: -8)) + } + .listStyle(.plain) + .frame(idealHeight: height) + .background { + ComputeHeight(height: $height) { + content() + } + .frame(maxWidth: .infinity) + .opacity(0) + } + } else { + ScrollView { + content() + } + } + } +} + +private struct ComputeHeight: NSViewRepresentable { + @Binding var height: Double + @ViewBuilder var content: () -> Content + + func makeNSView(context: Context) -> NSView { + let view = NSView() + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + updateHeight(nsView) + } + + func updateHeight(_ nsView: NSView) { + let contentView = content() + let hostingView = NSHostingView( + rootView: contentView.frame(width: nsView.frame.width == 0 ? 200 : nsView.frame.width) + ) + let size = hostingView.fittingSize + + if height != size.height { + Task { @MainActor in + height = size.height + } + } + } +} diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift index 2e8c04fc..dd300341 100644 --- a/Core/Sources/SuggestionWidget/Styles.swift +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -1,4 +1,5 @@ import AppKit +import MarkdownUI import SwiftUI enum Style { @@ -53,3 +54,29 @@ extension View { ) } } + +extension MarkdownUI.Theme { + static var custom: MarkdownUI.Theme { + .gitHub.text { + BackgroundColor(Color.clear) + } + .codeBlock { configuration in + configuration.label + .relativeLineSpacing(.em(0.225)) + .markdownTextStyle { + FontFamilyVariant(.monospaced) + FontSize(.em(0.85)) + } + .padding(16) + .background(Color(nsColor: .textBackgroundColor).opacity(0.7)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(alignment: .topTrailing) { + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(configuration.content, forType: .string) + } + } + .markdownMargin(top: 0, bottom: 16) + } + } +} diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ChatPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ChatPanel.swift index 5e5ad750..028ecb52 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ChatPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ChatPanel.swift @@ -2,6 +2,8 @@ import AppKit import MarkdownUI import SwiftUI +private let r: Double = 8 + struct ChatPanel: View { let chat: ChatProvider @Namespace var inputAreaNamespace @@ -12,13 +14,11 @@ struct ChatPanel: View { ChatPanelToolbar(chat: chat) Divider() ChatPanelMessages( - chat: chat, - inputAreaNamespace: inputAreaNamespace + chat: chat ) Divider() ChatPanelInputArea( chat: chat, - inputAreaNamespace: inputAreaNamespace, typedMessage: $typedMessage ) } @@ -60,60 +60,49 @@ struct ChatPanelToolbar: View { struct ChatPanelMessages: View { @ObservedObject var chat: ChatProvider - var inputAreaNamespace: Namespace.ID - @Environment(\.colorScheme) var colorScheme @AppStorage(\.disableLazyVStack) var disableLazyVStack - - @ViewBuilder - func vstack(@ViewBuilder content: () -> some View) -> some View { - if disableLazyVStack { - VStack(spacing: 4) { - content() - } - } else { - LazyVStack(spacing: 4) { - content() - } + @State var height: Double = 0 + + struct HeightPreferenceKey: PreferenceKey { + static var defaultValue: Double = 0 + static func reduce(value: inout Double, nextValue: () -> Double) { + value = nextValue() + value } } - + + struct UpdateHeightModifier: ViewModifier { + func body(content: Content) -> some View { + content + .background { + GeometryReader { proxy in + Color.clear + .preference(key: HeightPreferenceKey.self, value: proxy.size.height) + } + } + } + } + var body: some View { - ScrollView { - vstack { - let r = 8 as Double - + List { + Group { Spacer() + .modifier(UpdateHeightModifier()) if chat.isReceivingMessage { - Button(action: { - chat.stop() - }) { - HStack(spacing: 4) { - Image(systemName: "stop.fill") - Text("Stop Responding") - } - .rotationEffect(Angle(degrees: 180)) - .padding(8) - .background( - .regularMaterial, - in: RoundedRectangle(cornerRadius: r, style: .continuous) - ) - .overlay { - RoundedRectangle(cornerRadius: r, style: .continuous) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - } - } - .buttonStyle(.plain) - .scaleEffect(x: -1, y: 1, anchor: .center) + StopRespondingButton(chat: chat) + .padding(.vertical, 4) + .listRowInsets(EdgeInsets(top: 0, leading: -8, bottom: 0, trailing: -8)) + .modifier(UpdateHeightModifier()) } if chat.history.isEmpty { Text("New Chat") .frame(maxWidth: .infinity, alignment: .center) - .padding() - .rotationEffect(Angle(degrees: 180)) - .scaleEffect(x: -1, y: 1, anchor: .center) + .padding(.vertical) + .scaleEffect(x: -1, y: -1, anchor: .center) .foregroundStyle(.secondary) + .listRowInsets(EdgeInsets(top: 0, leading: -8, bottom: 0, trailing: -8)) + .modifier(UpdateHeightModifier()) } ForEach(chat.history.reversed(), id: \.id) { message in @@ -121,90 +110,142 @@ struct ChatPanelMessages: View { .text if message.isUser { - Markdown(text) - .textSelection(.enabled) - .markdownTheme(.gitHub.text { - BackgroundColor(Color.clear) - }) - .markdownCodeSyntaxHighlighter( - ChatCodeSyntaxHighlighter(brightMode: colorScheme != .dark) - ) - .frame(alignment: .trailing) - .padding() - .background { - RoundedCorners(tl: r, tr: r, bl: r, br: 0) - .fill(Color.userChatContentBackground) - } - .overlay { - RoundedCorners(tl: r, tr: r, bl: r, br: 0) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - } - .padding(.leading) - .padding(.trailing, 8) - .rotationEffect(Angle(degrees: 180)) - .scaleEffect(x: -1, y: 1, anchor: .center) - .shadow(color: .black.opacity(0.1), radius: 2) - .frame(maxWidth: .infinity, alignment: .trailing) - .contextMenu { - Button("Copy") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - } - } + UserMessage(text: text) + .listRowInsets(EdgeInsets(top: 0, leading: -8, bottom: 0, trailing: -8)) + .padding(.vertical, 4) } else { - HStack(alignment: .bottom, spacing: 2) { - Markdown(text) - .textSelection(.enabled) - .markdownTheme(.gitHub.text { - BackgroundColor(Color.clear) - }) - .markdownCodeSyntaxHighlighter( - ChatCodeSyntaxHighlighter(brightMode: colorScheme != .dark) - ) - .frame(alignment: .leading) - .padding() - .background { - RoundedCorners(tl: r, tr: r, bl: 0, br: r) - .fill(Color.contentBackground) - } - .overlay { - RoundedCorners(tl: r, tr: r, bl: 0, br: r) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - } - .padding(.leading, 8) - .rotationEffect(Angle(degrees: 180)) - .scaleEffect(x: -1, y: 1, anchor: .center) - .shadow(color: .black.opacity(0.1), radius: 2) - .contextMenu { - Button("Copy") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - } - } - - CopyButton { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - } - .rotationEffect(Angle(degrees: 180)) - .scaleEffect(x: -1, y: 1, anchor: .center) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.trailing, 2) + BotMessage(text: text) + .listRowInsets(EdgeInsets(top: 0, leading: -8, bottom: 0, trailing: -8)) + .padding(.vertical, 4) } } + .listItemTint(.clear) + .modifier(UpdateHeightModifier()) Spacer() + .modifier(UpdateHeightModifier()) + } + .scaleEffect(x: -1, y: 1, anchor: .center) + } + .id("\(chat.history.count), \(chat.isReceivingMessage)") + .listStyle(.plain) + .frame(idealHeight: max(50, height + 16)) + .scaleEffect(x: 1, y: -1, anchor: .center) + .onPreferenceChange(HeightPreferenceKey.self) { newHeight in + height = newHeight + } + } +} + +private struct StopRespondingButton: View { + let chat: ChatProvider + + var body: some View { + Button(action: { + chat.stop() + }) { + HStack(spacing: 4) { + Image(systemName: "stop.fill") + Text("Stop Responding") + } + .padding(8) + .background( + .regularMaterial, + in: RoundedRectangle(cornerRadius: r, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: r, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + } + .buttonStyle(.borderless) + .scaleEffect(x: -1, y: -1, anchor: .center) + .frame(maxWidth: .infinity, alignment: .center) + } +} + +private struct UserMessage: View { + let text: String + @Environment(\.colorScheme) var colorScheme + + var body: some View { + Markdown(text) + .textSelection(.enabled) + .markdownTheme(.custom) + .markdownCodeSyntaxHighlighter( + ChatCodeSyntaxHighlighter(brightMode: colorScheme != .dark) + ) + .frame(alignment: .leading) + .padding() + .background { + RoundedCorners(tl: r, tr: r, bl: r, br: 0) + .fill(Color.userChatContentBackground) + } + .overlay { + RoundedCorners(tl: r, tr: r, bl: r, br: 0) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + .padding(.leading) + .padding(.trailing, 8) + .scaleEffect(x: -1, y: -1, anchor: .center) + .shadow(color: .black.opacity(0.1), radius: 2) + .frame(maxWidth: .infinity, alignment: .leading) + .contextMenu { + Button("Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + .buttonStyle(.borderless) } + } +} + +private struct BotMessage: View { + let text: String + @Environment(\.colorScheme) var colorScheme + + var body: some View { + HStack(alignment: .bottom, spacing: 2) { + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + .scaleEffect(x: -1, y: -1, anchor: .center) + + Markdown(text) + .textSelection(.enabled) + .markdownTheme(.custom) + .markdownCodeSyntaxHighlighter( + ChatCodeSyntaxHighlighter(brightMode: colorScheme != .dark) + ) + .frame(alignment: .trailing) + .padding() + .background { + RoundedCorners(tl: r, tr: r, bl: 0, br: r) + .fill(Color.contentBackground) + } + .overlay { + RoundedCorners(tl: r, tr: r, bl: 0, br: r) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + .padding(.leading, 8) + .scaleEffect(x: -1, y: -1, anchor: .center) + .shadow(color: .black.opacity(0.1), radius: 2) + .contextMenu { + Button("Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + .buttonStyle(.borderless) + } } - .rotationEffect(Angle(degrees: 180)) - .scaleEffect(x: -1, y: 1, anchor: .center) + .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.trailing, 2) } } struct ChatPanelInputArea: View { @ObservedObject var chat: ChatProvider - var inputAreaNamespace: Namespace.ID @Binding var typedMessage: String @FocusState var isInputAreaFocused: Bool @@ -375,38 +416,6 @@ struct GlobalChatSwitchToggleStyle: ToggleStyle { } } -struct CopyButton: View { - var copy: () -> Void - @State var isCopied = false - var body: some View { - Button(action: { - withAnimation(.linear(duration: 0.1)) { - isCopied = true - } - copy() - Task { - try await Task.sleep(nanoseconds: 1_000_000_000) - withAnimation(.linear(duration: 0.1)) { - isCopied = false - } - } - }) { - Image(systemName: isCopied ? "checkmark.circle" : "doc.on.doc") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) - .frame(width: 20, height: 20, alignment: .center) - .foregroundColor(.secondary) - .background( - .regularMaterial, - in: RoundedRectangle(cornerRadius: 4, style: .circular) - ) - .padding(4) - } - .buttonStyle(.plain) - } -} - // MARK: - Previews struct ChatPanel_Preview: PreviewProvider { @@ -419,7 +428,12 @@ struct ChatPanel_Preview: PreviewProvider { .init( id: "2", isUser: false, - text: "**Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you?" + text: """ + ```swift + func foo() {} + ``` + **Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you? + """ ), .init(id: "5", isUser: false, text: "Yooo"), .init(id: "4", isUser: true, text: "Yeeeehh"), diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift index 6787d7fc..c7c7b329 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift @@ -48,7 +48,7 @@ struct CodeBlockSuggestionPanel: View { var body: some View { VStack(spacing: 0) { - ScrollView { + CustomScrollView { CodeBlock( code: suggestion.code, language: suggestion.language, diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift index 72f37739..631e83f8 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift @@ -7,7 +7,7 @@ struct PromptToCodePanel: View { var body: some View { VStack(spacing: 0) { - ScrollView { + CustomScrollView { VStack(spacing: 0) { if !provider.errorMessage.isEmpty { Text(provider.errorMessage)